diff --git a/.gemini/common/constraints.md b/.gemini/common/constraints.md deleted file mode 100644 index 1b563eab3b8..00000000000 --- a/.gemini/common/constraints.md +++ /dev/null @@ -1,8 +0,0 @@ -## 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/.gemini/common/docs.md b/.gemini/common/docs.md deleted file mode 100644 index 1a718005ad7..00000000000 --- a/.gemini/common/docs.md +++ /dev/null @@ -1,9 +0,0 @@ -## 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. - -@../tools/test_docs.md diff --git a/.gemini/tasks/scalar_op.md b/.gemini/tasks/scalar_op.md deleted file mode 100644 index a9318d54824..00000000000 --- a/.gemini/tasks/scalar_op.md +++ /dev/null @@ -1,67 +0,0 @@ -## 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). diff --git a/.gemini/tools/style_nox.md b/.gemini/tools/style_nox.md deleted file mode 100644 index 894fd102363..00000000000 --- a/.gemini/tools/style_nox.md +++ /dev/null @@ -1,18 +0,0 @@ -## Code Style with nox - -- 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. diff --git a/.gemini/tools/test_docs.md b/.gemini/tools/test_docs.md deleted file mode 100644 index 5cb988186c7..00000000000 --- a/.gemini/tools/test_docs.md +++ /dev/null @@ -1,10 +0,0 @@ -## 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 -``` diff --git a/.gemini/tools/test_nox.md b/.gemini/tools/test_nox.md deleted file mode 100644 index 023ada1b61f..00000000000 --- a/.gemini/tools/test_nox.md +++ /dev/null @@ -1,28 +0,0 @@ -## Testing with nox - -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.14 -- -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.14 -- -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. diff --git a/.gemini/tools/test_pytest.md b/.gemini/tools/test_pytest.md deleted file mode 100644 index 5228ae06ba8..00000000000 --- a/.gemini/tools/test_pytest.md +++ /dev/null @@ -1,9 +0,0 @@ -## Testing with pytest - -Use `pytest` to instrument our tests. - -- To test your changes, run `pytest`: - - ```bash - pytest :: - ``` diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml new file mode 100644 index 00000000000..51b21a62b7b --- /dev/null +++ b/.github/.OwlBot.lock.yaml @@ -0,0 +1,17 @@ +# 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. +docker: + image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest + digest: sha256:a7aef70df5f13313ddc027409fc8f3151422ec2a57ac8730fce8fa75c060d5bb +# created: 2025-04-10T17:00:10.042601326Z diff --git a/.github/.OwlBot.yaml b/.github/.OwlBot.yaml new file mode 100644 index 00000000000..c379bd3092d --- /dev/null +++ b/.github/.OwlBot.yaml @@ -0,0 +1,18 @@ +# 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. + +docker: + image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest + +begin-after-commit-hash: 92006bb3cdc84677aa93c7f5235424ec2b157146 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000000..7686a50da62 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,12 @@ +# Code owners file. +# This file controls who is tagged for review for any given pull request. +# +# For syntax help see: +# https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax +# Note: This file is autogenerated. To make changes to the codeowner team, please update .repo-metadata.json. + +# @googleapis/yoshi-python @googleapis/api-bigquery-dataframe are the default owners for changes in this repo +* @googleapis/yoshi-python @googleapis/api-bigquery-dataframe + +# @googleapis/python-samples-reviewers @googleapis/api-bigquery-dataframe are the default owners for samples changes +/samples/ @googleapis/python-samples-reviewers @googleapis/api-bigquery-dataframe diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000000..939e5341e74 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows [Google's Open Source Community +Guidelines](https://opensource.google.com/conduct/). diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 00000000000..4540caf5e73 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,60 @@ +--- +name: Bug report +about: Create a report to help us improve + +--- + +Thanks for stopping by to let us know something could be better! + +**PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. + +Please run down the following list and make sure you've tried the usual "quick fixes": + + - Search the issues already opened: https://github.com/googleapis/python-bigquery-dataframes/issues + - Search StackOverflow: https://stackoverflow.com/questions/tagged/google-cloud-platform+python + +If you are still having issues, please be sure to include as much information as possible: + +#### Environment details + + - OS type and version: + - Python version: `python --version` + - pip version: `pip --version` + - `bigframes` version: `pip show bigframes` + + +```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 + + 1. ? + 2. ? + +#### Code example + +```python +# example +``` + +#### Stack trace +``` +# example +``` + +Making sure to follow these steps will guarantee the quickest resolution possible. + +Thanks! diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..6365857f33c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,18 @@ +--- +name: Feature request +about: Suggest an idea for this library + +--- + +Thanks for stopping by to let us know something could be better! + +**PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. + + **Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + **Describe the solution you'd like** +A clear and concise description of what you want to happen. + **Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + **Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/support_request.md b/.github/ISSUE_TEMPLATE/support_request.md new file mode 100644 index 00000000000..99586903212 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/support_request.md @@ -0,0 +1,7 @@ +--- +name: Support request +about: If you have a support contract with Google, please create an issue in the Google Cloud Support console. + +--- + +**PLEASE READ**: If you have a support contract with Google, please create an issue in the [support console](https://cloud.google.com/support/) instead of filing on GitHub. This will ensure a timely response. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000000..3e59d9a70d1 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,7 @@ +Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: +- [ ] Make sure to open an issue as a [bug/issue](https://github.com/googleapis/python-bigquery-dataframes/issues/new/choose) before writing your code! That way we can discuss the change, evaluate designs, and agree on the general idea +- [ ] Ensure the tests and linter pass +- [ ] Code coverage does not decrease (if any source code was changed) +- [ ] Appropriate docs were updated (if necessary) + +Fixes # 🦕 diff --git a/.github/auto-approve.yml b/.github/auto-approve.yml new file mode 100644 index 00000000000..311ebbb853a --- /dev/null +++ b/.github/auto-approve.yml @@ -0,0 +1,3 @@ +# https://github.com/googleapis/repo-automation-bots/tree/main/packages/auto-approve +processes: + - "OwlBotTemplateChanges" diff --git a/.github/auto-label.yaml b/.github/auto-label.yaml new file mode 100644 index 00000000000..21786a4eb08 --- /dev/null +++ b/.github/auto-label.yaml @@ -0,0 +1,20 @@ +# 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. +requestsize: + enabled: true + +path: + pullrequest: true + paths: + samples: "samples" diff --git a/.github/blunderbuss.yml b/.github/blunderbuss.yml new file mode 100644 index 00000000000..8d9cb1008e7 --- /dev/null +++ b/.github/blunderbuss.yml @@ -0,0 +1,17 @@ +# Blunderbuss config +# +# This file controls who is assigned for pull requests and issues. +# Note: This file is autogenerated. To make changes to the assignee +# team, please update `codeowner_team` in `.repo-metadata.json`. +assign_issues: + - googleapis/api-bigquery-dataframe + +assign_issues_by: + - labels: + - "samples" + to: + - googleapis/python-samples-reviewers + - googleapis/api-bigquery-dataframe + +assign_prs: + - googleapis/api-bigquery-dataframe diff --git a/.github/header-checker-lint.yml b/.github/header-checker-lint.yml new file mode 100644 index 00000000000..6fe78aa7987 --- /dev/null +++ b/.github/header-checker-lint.yml @@ -0,0 +1,15 @@ +{"allowedCopyrightHolders": ["Google LLC"], + "allowedLicenses": ["Apache-2.0", "MIT", "BSD-3"], + "ignoreFiles": ["**/requirements.txt", "**/requirements-test.txt", "**/__init__.py", "samples/**/constraints.txt", "samples/**/constraints-test.txt"], + "sourceFileExtensions": [ + "ts", + "js", + "java", + "sh", + "Dockerfile", + "yaml", + "py", + "html", + "txt" + ] +} \ No newline at end of file diff --git a/.github/release-please.yml b/.github/release-please.yml new file mode 100644 index 00000000000..7c2b8d9e8a9 --- /dev/null +++ b/.github/release-please.yml @@ -0,0 +1,10 @@ +releaseType: python +handleGHRelease: true +extraFiles: + - bigframes/version.py + - third_party/bigframes_vendored/version.py + +branches: + - branch: v1 + handleGHRelease: true + releaseType: python diff --git a/.github/release-trigger.yml b/.github/release-trigger.yml new file mode 100644 index 00000000000..4fbd4aa427b --- /dev/null +++ b/.github/release-trigger.yml @@ -0,0 +1,2 @@ +enabled: true +multiScmName: python-bigquery-dataframes diff --git a/third_party/bigframes_vendored/sqlglot/py.typed b/.github/snippet-bot.yml similarity index 100% rename from third_party/bigframes_vendored/sqlglot/py.typed rename to .github/snippet-bot.yml diff --git a/.github/sync-repo-settings.yaml b/.github/sync-repo-settings.yaml new file mode 100644 index 00000000000..80bfd5f9512 --- /dev/null +++ b/.github/sync-repo-settings.yaml @@ -0,0 +1,52 @@ +# 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' + - 'mypy' + - 'unit (3.9)' + - 'unit (3.10)' + - 'unit (3.11)' + - 'unit (3.12)' + - 'cover' + - 'Kokoro presubmit' + - 'Kokoro windows' +- pattern: v1 + requiresCodeOwnerReviews: true + requiresStrictStatusChecks: false + requiredStatusCheckContexts: + - 'OwlBot Post Processor' + - 'conventionalcommits.org' + - 'cla/google' + - 'docs' + - 'lint' + - 'mypy' + - '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.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000000..2833fe98fff --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,38 @@ +on: + pull_request: + branches: + - main +name: docs +jobs: + docs: + 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 docs + run: | + nox -s docs + docfx: + 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 docfx + run: | + nox -s docfx diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000000..1051da0bdda --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,25 @@ +on: + pull_request: + branches: + - main +name: lint +jobs: + lint: + 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 lint + run: | + nox -s lint + - name: Run lint_setup_py + run: | + nox -s lint_setup_py diff --git a/.github/workflows/mypy.yml b/.github/workflows/mypy.yml new file mode 100644 index 00000000000..e6a79291d00 --- /dev/null +++ b/.github/workflows/mypy.yml @@ -0,0 +1,22 @@ +on: + pull_request: + 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 new file mode 100644 index 00000000000..a7805de447f --- /dev/null +++ b/.github/workflows/unittest.yml @@ -0,0 +1,61 @@ +on: + pull_request: + branches: + - main +name: unittest +jobs: + unit: + # TODO(https://github.com/googleapis/gapic-generator-python/issues/2303): use `ubuntu-latest` once this bug is fixed. + # Use ubuntu-22.04 until Python 3.7 is removed from the test matrix + # https://docs.github.com/en/actions/using-github-hosted-runners/using-github-hosted-runners/about-github-hosted-runners#standard-github-hosted-runners-for-public-repositories + runs-on: ubuntu-22.04 + strategy: + matrix: + python: ['3.9', '3.10', '3.11', '3.12', '3.13'] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + - name: Install nox + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install nox + - name: Run unit tests + env: + COVERAGE_FILE: .coverage-${{ matrix.python }} + run: | + nox -s unit-${{ matrix.python }} + - name: Upload coverage results + uses: actions/upload-artifact@v4 + with: + name: coverage-artifact-${{ matrix.python }} + path: .coverage-${{ matrix.python }} + include-hidden-files: true + + cover: + runs-on: ubuntu-latest + needs: + - unit + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.10" + - name: Install coverage + run: | + python -m pip install --upgrade setuptools pip wheel + python -m pip install coverage + - name: Download coverage results + uses: actions/download-artifact@v4 + with: + path: .coverage-results/ + - name: Report coverage results + run: | + find .coverage-results -type f -name '*.zip' -exec unzip {} \; + coverage combine .coverage-results/**/.coverage* + coverage report --show-missing --fail-under=35 diff --git a/.gitignore b/.gitignore index d92dcc6b474..d083ea1ddc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,223 +1,64 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class +*.py[cod] +*.sw[op] # C extensions *.so -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg +# Packages *.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec +*.egg-info +dist +build +eggs +.eggs +parts +bin +var +sdist +develop-eggs +.installed.cfg +lib +lib64 +__pycache__ # Installer logs pip-log.txt -pip-delete-this-directory.txt # Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ .coverage -.coverage.* +.nox .cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -*.lcov -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -# Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -# poetry.lock -# poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -# pdm.lock -# pdm.toml -.pdm-python -.pdm-build/ +.pytest_cache -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -# pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi/* -!.pixi/config.toml -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ +# Mac +.DS_Store -# Celery stuff -celerybeat-schedule* -celerybeat.pid +# JetBrains +.idea -# Redis -*.rdb -*.aof -*.pid +# VS Code +.vscode -# RabbitMQ -mnesia/ -rabbitmq/ -rabbitmq-data/ +# emacs +*~ -# ActiveMQ -activemq-data/ +# Built documentation +docs/_build +bigquery/docs/generated +docs.metadata -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv +# Virtual environment env/ venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -# .idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ -# Temporary file for partial code execution -tempCodeRunnerFile.py - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ +# Test logs +coverage.xml +*sponge_log.xml -# Streamlit -.streamlit/secrets.toml +# System test environment variables. +system_tests/local_test_setup -# Tests -*sponge_log.xml +# Make sure a generated file isn't accidentally committed. +pylintrc +pylintrc.test diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 01b0af912ee..6cc03455da4 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -1,11 +1,11 @@ #!/bin/bash -# Copyright 2022 Google LLC +# 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 +# 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, @@ -13,24 +13,43 @@ # See the License for the specific language governing permissions and # limitations under the License. -# `-e` enables the script to automatically fail when a command fails -# `-o pipefail` sets the exit code to non-zero if any command fails, -# or zero if all commands in the pipeline exit successfully. set -eo pipefail -cd "${KOKORO_ARTIFACTS_DIR}/git/bigframes" -pwd +PROJECT_SCM="github/python-bigquery-dataframes" -# If NOX_SESSION is set, it only runs the specified session, -# otherwise run all the sessions. -NOX_SESSION_ARG="" +if [[ -z "${PROJECT_ROOT:-}" ]]; then + PROJECT_ROOT="${KOKORO_ARTIFACTS_DIR}/${PROJECT_SCM}" +fi + +cd "${PROJECT_ROOT}" + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +# Workaround https://github.com/pytest-dev/pytest/issues/9567 +export PY_IGNORE_IMPORTMISMATCH=1 -# IF NOX_FILE is set, it runs the specific nox file, -# otherwise it runs noxfile.py in the package directory. -NOX_FILE_ARG="" +# Debug: show build environment +env | grep KOKORO -[[ -z "${NOX_SESSION}" ]] || NOX_SESSION_ARG="-s ${NOX_SESSION}" +# Install pip +python3 -m pip install --upgrade --quiet pip +python3 -m pip --version -[[ -z "${NOX_FILE}" ]] || NOX_FILE_ARG="-f ${NOX_FILE}" +# Remove old nox +python3 -m pip uninstall --yes --quiet nox-automation + +# Install nox +python3 -m pip install --upgrade --quiet nox +python3 -m nox --version + +# If NOX_SESSION is set, it only runs the specified session, +# otherwise run all the sessions. +if [[ -n "${NOX_SESSION:-}" ]]; then + python3 -m nox --stop-on-first-error -s ${NOX_SESSION:-} +else + python3 -m nox --stop-on-first-error +fi -python3 -m nox ${NOX_SESSION_ARG} $NOX_FILE_ARG +# Prevent kokoro from trying to collect many mb of artifacts, wasting several minutes +sudo rm -rf "${KOKORO_ARTIFACTS_DIR?}"/* diff --git a/.kokoro/buildwheel.cfg b/.kokoro/buildwheel.cfg deleted file mode 100644 index af89c99e419..00000000000 --- a/.kokoro/buildwheel.cfg +++ /dev/null @@ -1,22 +0,0 @@ -# -*- protobuffer -*- -# proto-file: google3/devtools/kokoro/config/proto/build.proto -# proto-message: BuildConfig - -build_file: "bigframes-internal/bigframes/.kokoro/buildwheel.sh" -container_properties { - docker_image: "us-docker.pkg.dev/artifact-foundry-prod/docker-3p-trusted/python@sha256:0b3498e251759df85a00474be7d3b791d6abe1600ce3531a649e42964749655f" -} - -fileset_artifacts { - name: "artifacts" - artifact_globs: "artifacts/*" - error_if_missing: true - destinations { - store_attestation: true - gcs { - gcs_root_path: "oss-exit-gate-prod-projects-bucket/bigframes/pypi/attestations" - } - } - generate_sbom_from_fileset: true - generate_attestation: true -} diff --git a/.kokoro/buildwheel.sh b/.kokoro/buildwheel.sh deleted file mode 100755 index b64b6412b02..00000000000 --- a/.kokoro/buildwheel.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "${KOKORO_ARTIFACTS_DIR}/git/bigframes-internal/bigframes" - -### Set up Airlock for Bookworm -rm -f /etc/apt/sources.list.d/* /etc/apt/sources.list -echo 'deb https://us-apt.pkg.dev/remote/artifact-foundry-prod/debian-3p-remote-bookworm bookworm main' | \ - tee -a /etc/apt/sources.list.d/artifact-registry.list - -# Set up Airlock for Python -cat > "$HOME/.pypirc" < "$HOME/.pip/pip.conf" <&2 ;} +function println { printf '%s\n' "$(now) $*" ;} + + +# Populates requested secrets set in SECRET_MANAGER_KEYS from service account: +# kokoro-trampoline@cloud-devrel-kokoro-resources.iam.gserviceaccount.com +SECRET_LOCATION="${KOKORO_GFILE_DIR}/secret_manager" +msg "Creating folder on disk for secrets: ${SECRET_LOCATION}" +mkdir -p ${SECRET_LOCATION} +for key in $(echo ${SECRET_MANAGER_KEYS} | sed "s/,/ /g") +do + msg "Retrieving secret ${key}" + docker run --entrypoint=gcloud \ + --volume=${KOKORO_GFILE_DIR}:${KOKORO_GFILE_DIR} \ + gcr.io/google.com/cloudsdktool/cloud-sdk \ + secrets versions access latest \ + --project cloud-devrel-kokoro-resources \ + --secret ${key} > \ + "${SECRET_LOCATION}/${key}" + if [[ $? == 0 ]]; then + msg "Secret written to ${SECRET_LOCATION}/${key}" + else + msg "Error retrieving secret ${key}" + fi +done diff --git a/.kokoro/presubmit/common.cfg b/.kokoro/presubmit/common.cfg index 5d40578ac79..97e0651aa92 100644 --- a/.kokoro/presubmit/common.cfg +++ b/.kokoro/presubmit/common.cfg @@ -7,4 +7,4 @@ action { } } -build_file: "bigframes/.kokoro/build.sh" +build_file: "python-bigquery-dataframes/.kokoro/build.sh" diff --git a/.kokoro/presubmit/doctest.cfg b/.kokoro/presubmit/doctest.cfg new file mode 100644 index 00000000000..2aad95beed4 --- /dev/null +++ b/.kokoro/presubmit/doctest.cfg @@ -0,0 +1,12 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "cleanup doctest" +} + +env_vars: { + key: "GOOGLE_CLOUD_PROJECT" + value: "bigframes-testing" +} diff --git a/.kokoro/presubmit/e2e-gerrit.cfg b/.kokoro/presubmit/e2e-gerrit.cfg new file mode 100644 index 00000000000..19913344b6c --- /dev/null +++ b/.kokoro/presubmit/e2e-gerrit.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "system_noextras e2e notebook" +} diff --git a/.kokoro/presubmit/e2e.cfg b/.kokoro/presubmit/e2e.cfg new file mode 100644 index 00000000000..e049dd30b3a --- /dev/null +++ b/.kokoro/presubmit/e2e.cfg @@ -0,0 +1,17 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "e2e unit_prerelease system_prerelease system_noextras" +} + +env_vars: { + key: "GOOGLE_CLOUD_PROJECT" + value: "bigframes-load-testing" +} + +env_vars: { + key: "BIGFRAMES_TEST_MODEL_VERTEX_ENDPOINT" + value: "https://us-central1-aiplatform.googleapis.com/v1/projects/272725758477/locations/us-central1/endpoints/590545496255234048" +} diff --git a/.kokoro/presubmit/notebook.cfg b/.kokoro/presubmit/notebook.cfg new file mode 100644 index 00000000000..cc73c3bea40 --- /dev/null +++ b/.kokoro/presubmit/notebook.cfg @@ -0,0 +1,12 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "notebook" +} + +env_vars: { + key: "GOOGLE_CLOUD_PROJECT" + value: "bigframes-testing" +} diff --git a/.kokoro/presubmit/prerelease-deps.cfg b/.kokoro/presubmit/prerelease-deps.cfg new file mode 100644 index 00000000000..3595fb43f5c --- /dev/null +++ b/.kokoro/presubmit/prerelease-deps.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "prerelease_deps" +} diff --git a/.kokoro/presubmit/presubmit-docs-linux.cfg b/.kokoro/presubmit/presubmit-docs-linux.cfg deleted file mode 100644 index 93832f9e5aa..00000000000 --- a/.kokoro/presubmit/presubmit-docs-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "docs docfx" -} diff --git a/.kokoro/presubmit/presubmit-doctest-linux.cfg b/.kokoro/presubmit/presubmit-doctest-linux.cfg deleted file mode 100644 index af74ca0fbcd..00000000000 --- a/.kokoro/presubmit/presubmit-doctest-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "doctest" -} diff --git a/.kokoro/presubmit/presubmit-e2e-linux.cfg b/.kokoro/presubmit/presubmit-e2e-linux.cfg deleted file mode 100644 index ed73a083ee0..00000000000 --- a/.kokoro/presubmit/presubmit-e2e-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "e2e" -} diff --git a/.kokoro/presubmit/presubmit-gerrit.cfg b/.kokoro/presubmit/presubmit-gerrit.cfg new file mode 100644 index 00000000000..18a4c35325b --- /dev/null +++ b/.kokoro/presubmit/presubmit-gerrit.cfg @@ -0,0 +1 @@ +# Format: //devtools/kokoro/config/proto/build.proto diff --git a/.kokoro/presubmit/presubmit-lint-linux.cfg b/.kokoro/presubmit/presubmit-lint-linux.cfg deleted file mode 100644 index 490cafab878..00000000000 --- a/.kokoro/presubmit/presubmit-lint-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "lint mypy" -} diff --git a/.kokoro/presubmit/presubmit-prerelease-linux.cfg b/.kokoro/presubmit/presubmit-prerelease-linux.cfg deleted file mode 100644 index bfc635fbf30..00000000000 --- a/.kokoro/presubmit/presubmit-prerelease-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "unit_prerelease system_prerelease" -} diff --git a/.kokoro/presubmit/presubmit-system-linux.cfg b/.kokoro/presubmit/presubmit-system-linux.cfg deleted file mode 100644 index 9fa4e1b73b2..00000000000 --- a/.kokoro/presubmit/presubmit-system-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "system" -} diff --git a/.kokoro/presubmit/presubmit-unit-linux.cfg b/.kokoro/presubmit/presubmit-unit-linux.cfg deleted file mode 100644 index 09f4f4b8311..00000000000 --- a/.kokoro/presubmit/presubmit-unit-linux.cfg +++ /dev/null @@ -1,6 +0,0 @@ -# Format: //devtools/kokoro/config/proto/build.proto - -env_vars: { - key: "NOX_SESSION" - value: "unit" -} diff --git a/.kokoro/presubmit/presubmit.cfg b/.kokoro/presubmit/presubmit.cfg new file mode 100644 index 00000000000..8f43917d92f --- /dev/null +++ b/.kokoro/presubmit/presubmit.cfg @@ -0,0 +1 @@ +# Format: //devtools/kokoro/config/proto/build.proto \ No newline at end of file diff --git a/.kokoro/presubmit/windows.cfg b/.kokoro/presubmit/windows.cfg new file mode 100644 index 00000000000..806986138dd --- /dev/null +++ b/.kokoro/presubmit/windows.cfg @@ -0,0 +1,3 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +build_file: "python-bigquery-dataframes/scripts/windows/build.bat" diff --git a/.kokoro/release-nightly.sh b/.kokoro/release-nightly.sh new file mode 100755 index 00000000000..124e4b8b486 --- /dev/null +++ b/.kokoro/release-nightly.sh @@ -0,0 +1,124 @@ +#!/bin/bash +# Copyright 2020 Google LLC +# +# Licensed under the Apache License, Version 2.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. + +# Based loosely on +# https://github.com/googleapis/python-bigquery/blob/main/.kokoro/release.sh + +set -eo pipefail +set -x + +# Parse command line arguments +DRY_RUN= +while [ $# -gt 0 ] ; do + case "$1" in + -d | --dry-run ) + DRY_RUN=true + ;; + -h | --help ) + echo -e "USAGE: `basename $0` [ -d | --dry-run ]" + exit + ;; + esac + shift 1; +done + +if [[ -z "${KOKORO_GOB_COMMIT}" ]]; then + PROJECT_SCM="github/python-bigquery-dataframes" +else + PROJECT_SCM="git/bigframes" +fi + +if [ -z "${PROJECT_ROOT:-}" ]; then + PROJECT_ROOT="${KOKORO_ARTIFACTS_DIR}/${PROJECT_SCM}" +fi + +# Move into the package, build the distribution and upload to shared bucket. +# See internal bug 274624240 for details. + +cd "${PROJECT_ROOT}" +rm -rf build dist + +# Workaround the fact that the repository that has been fetched before the +# build script. See: go/kokoro-native-docker-migration#known-issues and +# internal issue b/261050975. +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==25.0.1 + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +# Install dependencies, as the following steps depend on it +python3.10 -m pip install -e .[all] + +# Update version string to include git hash and date +CURRENT_DATE=$(date '+%Y%m%d') +GIT_HASH=$(git rev-parse --short HEAD) +BIGFRAMES_VERSION=$(python3.10 -c "import bigframes; print(bigframes.__version__)") +RELEASE_VERSION=${BIGFRAMES_VERSION}dev${CURRENT_DATE}+${GIT_HASH} +sed -i -e "s/$BIGFRAMES_VERSION/$RELEASE_VERSION/g" bigframes/version.py + +# Generate the package wheel +python3.10 setup.py sdist bdist_wheel + +# Make sure that the wheel file is generated +VERSION_WHEEL=`ls dist/bigframes-*.whl` +num_wheel_files=`echo $VERSION_WHEEL | wc -w` +if [ $num_wheel_files -ne 1 ] ; then + echo "Exactly one wheel file should have been generated, found $num_wheel_files: $VERSION_WHEEL" + exit -1 +fi + +# Create a copy of the wheel with a well known, version agnostic name +LATEST_WHEEL=dist/bigframes-latest-py2.py3-none-any.whl +cp $VERSION_WHEEL $LATEST_WHEEL +cp dist/bigframes-*.tar.gz dist/bigframes-latest.tar.gz + +if ! [ ${DRY_RUN} ]; then +for gcs_path in gs://vertex_sdk_private_releases/bigframe/ \ + gs://dl-platform-colab/bigframes/ \ + gs://bigframes-wheels/; + do + gsutil cp -v dist/* ${gcs_path} + gsutil cp -v LICENSE ${gcs_path} + gsutil -m cp -r -v "notebooks/" ${gcs_path}notebooks/ + + done + + # publish API coverage information to BigQuery + # Note: only the kokoro service account has permission to write to this + # table, if you want to test this step, point it to a table you have + # write access to + COVERAGE_TABLE=bigframes-metrics.coverage_report.bigframes_coverage_nightly + python3.10 scripts/publish_api_coverage.py \ + bigquery \ + --bigframes_version=$BIGFRAMES_VERSION \ + --release_version=$RELEASE_VERSION \ + --bigquery_table=$COVERAGE_TABLE +fi + +# Undo the file changes, in case this script is running on a +# non-temporary instance of the bigframes repo +# TODO: This doesn't work with (set -eo pipefail) if the failure happened after +# the changes were made but before this cleanup, because the script would +# terminate with the failure itself. See if we can ensure the cleanup. +sed -i -e "s/$RELEASE_VERSION/$BIGFRAMES_VERSION/g" bigframes/version.py + +if ! [ ${DRY_RUN} ]; then + # Copy docs and wheels to Google Drive + python3.10 scripts/upload_to_google_drive.py +fi diff --git a/.kokoro/release.cfg b/.kokoro/release.cfg deleted file mode 100644 index 18c85a69d63..00000000000 --- a/.kokoro/release.cfg +++ /dev/null @@ -1,23 +0,0 @@ -# -*- protobuffer -*- -# proto-file: google3/devtools/kokoro/config/proto/build.proto -# proto-message: BuildConfig - -build_file: "bigframes-internal/bigframes/.kokoro/release.sh" -container_properties { - docker_image: "us-docker.pkg.dev/artifact-foundry-prod/docker-3p-trusted/ubuntu:22.04" -} - -fileset_artifacts { - name: "manifest" - artifact_globs: "manifest.json" - error_if_missing: true - destinations { - store_attestation: false - gcs { - gcs_root_path: "oss-exit-gate-prod-projects-bucket/bigframes/pypi/manifests" - populate_content_type: true - } - } - generate_sbom_from_fileset: false - generate_attestation: false -} diff --git a/.kokoro/release.sh b/.kokoro/release.sh deleted file mode 100755 index 58b865a6f54..00000000000 --- a/.kokoro/release.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/bash -set -euo pipefail - -cd "${KOKORO_ARTIFACTS_DIR}" - -cat > manifest.json <<'EOF' -{ - "publish_all": true -} -EOF diff --git a/.kokoro/requirements/build.in b/.kokoro/requirements/build.in deleted file mode 100644 index bd63670492d..00000000000 --- a/.kokoro/requirements/build.in +++ /dev/null @@ -1,6 +0,0 @@ ---only-binary :all: -twine>=6.2.0 -build>=1.3.0 -wheel>=0.46.2 -keyring>=25.7.0 -keyrings.google-artifactregistry-auth>=1.1.2 diff --git a/.kokoro/requirements/build.txt b/.kokoro/requirements/build.txt deleted file mode 100644 index ed4ce023d35..00000000000 --- a/.kokoro/requirements/build.txt +++ /dev/null @@ -1,447 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.13 -# by the following command: -# -# pip-compile --generate-hashes --output-file=.kokoro/requirements/build.txt .kokoro/requirements/build.in -# ---only-binary :all: - -build==1.3.0 \ - --hash=sha256:7145f0b5061ba90a1500d60bd1b13ca0a8a4cebdd0cc16ed8adf1c0e739f43b4 - # via -r build.in -cachetools==6.2.2 \ - --hash=sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace - # via google-auth -certifi==2025.11.12 \ - --hash=sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b - # via requests -cffi==2.0.0 \ - --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ - --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ - --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ - --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ - --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ - --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ - --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ - --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ - --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ - --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ - --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ - --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ - --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ - --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ - --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ - --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ - --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ - --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ - --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ - --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ - --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ - --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ - --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ - --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ - --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ - --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ - --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ - --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ - --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ - --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ - --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ - --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ - --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ - --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ - --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ - --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ - --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ - --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ - --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ - --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ - --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ - --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ - --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ - --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ - --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ - --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ - --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ - --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ - --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ - --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ - --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ - --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ - --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ - --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ - --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ - --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ - --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ - --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ - --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ - --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ - --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ - --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ - --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ - --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ - --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ - --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ - --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ - --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ - --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ - --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ - --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ - --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ - --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ - --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ - --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ - --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ - --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ - --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ - --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ - --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ - --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ - --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ - --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf - # via cryptography -charset-normalizer==3.4.4 \ - --hash=sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad \ - --hash=sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93 \ - --hash=sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394 \ - --hash=sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89 \ - --hash=sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc \ - --hash=sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86 \ - --hash=sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63 \ - --hash=sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d \ - --hash=sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f \ - --hash=sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8 \ - --hash=sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0 \ - --hash=sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505 \ - --hash=sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161 \ - --hash=sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af \ - --hash=sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152 \ - --hash=sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318 \ - --hash=sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72 \ - --hash=sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4 \ - --hash=sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e \ - --hash=sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3 \ - --hash=sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576 \ - --hash=sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c \ - --hash=sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1 \ - --hash=sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8 \ - --hash=sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1 \ - --hash=sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2 \ - --hash=sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44 \ - --hash=sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26 \ - --hash=sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88 \ - --hash=sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016 \ - --hash=sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede \ - --hash=sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf \ - --hash=sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a \ - --hash=sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc \ - --hash=sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0 \ - --hash=sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84 \ - --hash=sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db \ - --hash=sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1 \ - --hash=sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7 \ - --hash=sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed \ - --hash=sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8 \ - --hash=sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133 \ - --hash=sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e \ - --hash=sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef \ - --hash=sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14 \ - --hash=sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2 \ - --hash=sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0 \ - --hash=sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d \ - --hash=sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828 \ - --hash=sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f \ - --hash=sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf \ - --hash=sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6 \ - --hash=sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328 \ - --hash=sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090 \ - --hash=sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa \ - --hash=sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381 \ - --hash=sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c \ - --hash=sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb \ - --hash=sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc \ - --hash=sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec \ - --hash=sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc \ - --hash=sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac \ - --hash=sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e \ - --hash=sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313 \ - --hash=sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569 \ - --hash=sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3 \ - --hash=sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d \ - --hash=sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525 \ - --hash=sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894 \ - --hash=sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3 \ - --hash=sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9 \ - --hash=sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a \ - --hash=sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9 \ - --hash=sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14 \ - --hash=sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25 \ - --hash=sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50 \ - --hash=sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf \ - --hash=sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1 \ - --hash=sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3 \ - --hash=sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac \ - --hash=sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e \ - --hash=sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815 \ - --hash=sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c \ - --hash=sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6 \ - --hash=sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6 \ - --hash=sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e \ - --hash=sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4 \ - --hash=sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84 \ - --hash=sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69 \ - --hash=sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15 \ - --hash=sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191 \ - --hash=sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0 \ - --hash=sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897 \ - --hash=sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd \ - --hash=sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2 \ - --hash=sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794 \ - --hash=sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d \ - --hash=sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074 \ - --hash=sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3 \ - --hash=sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224 \ - --hash=sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838 \ - --hash=sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a \ - --hash=sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d \ - --hash=sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d \ - --hash=sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f \ - --hash=sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8 \ - --hash=sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490 \ - --hash=sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966 \ - --hash=sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9 \ - --hash=sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3 \ - --hash=sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e \ - --hash=sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608 - # via requests -cryptography==50.0.0 \ - --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ - --hash=sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03 \ - --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ - --hash=sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7 \ - --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ - --hash=sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437 \ - --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ - --hash=sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987 \ - --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ - --hash=sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025 \ - --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ - --hash=sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037 \ - --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ - --hash=sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269 \ - --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ - --hash=sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105 \ - --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ - --hash=sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc \ - --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ - --hash=sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95 \ - --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ - --hash=sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b \ - --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ - --hash=sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47 \ - --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ - --hash=sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c \ - --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ - --hash=sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41 \ - --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ - --hash=sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c \ - --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ - --hash=sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d \ - --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ - --hash=sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7 \ - --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ - --hash=sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c \ - --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ - --hash=sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708 \ - --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ - --hash=sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef \ - --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ - --hash=sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f \ - --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ - --hash=sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f \ - --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ - --hash=sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a \ - --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ - --hash=sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f \ - --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ - --hash=sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a \ - --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ - --hash=sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a \ - --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ - --hash=sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e \ - --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ - --hash=sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3 \ - --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ - --hash=sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d \ - --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ - --hash=sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3 \ - --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ - --hash=sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f \ - --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ - --hash=sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae \ - --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ - --hash=sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30 \ - --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ - --hash=sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9 \ - --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ - --hash=sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9 \ - --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ - --hash=sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07 \ - --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ - --hash=sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba \ - --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ - --hash=sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3 \ - --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ - --hash=sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f \ - --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ - --hash=sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533 \ - --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ - --hash=sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5 \ - --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ - --hash=sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11 \ - --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ - --hash=sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9 \ - --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ - --hash=sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f \ - --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ - --hash=sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169 \ - --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 \ - --hash=sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645 - # via secretstorage -docutils==0.22.3 \ - --hash=sha256:bd772e4aca73aff037958d44f2be5229ded4c09927fcf8690c577b66234d6ceb - # via readme-renderer -google-auth==2.43.0 \ - --hash=sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16 - # via keyrings-google-artifactregistry-auth -id==1.5.0 \ - --hash=sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658 - # via twine -idna==3.15 \ - --hash=sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8 - # via requests -jaraco-classes==3.4.0 \ - --hash=sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790 - # via keyring -jaraco-context==6.0.1 \ - --hash=sha256:f797fc481b490edb305122c9181830a3a5b76d84ef6d1aef2fb9b47ab956f9e4 - # via keyring -jaraco-functools==4.3.0 \ - --hash=sha256:227ff8ed6f7b8f62c56deff101545fa7543cf2c8e7b82a7c2116e672f29c26e8 - # via keyring -jeepney==0.9.0 \ - --hash=sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683 - # via - # keyring - # secretstorage -keyring==25.7.0 \ - --hash=sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f - # via - # -r build.in - # keyrings-google-artifactregistry-auth - # twine -keyrings-google-artifactregistry-auth==1.1.2 \ - --hash=sha256:e3f18b50fa945c786593014dc225810d191671d4f5f8e12d9259e39bad3605a3 - # via -r build.in -markdown-it-py==4.0.0 \ - --hash=sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147 - # via rich -mdurl==0.1.2 \ - --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 - # via markdown-it-py -more-itertools==10.8.0 \ - --hash=sha256:52d4362373dcf7c52546bc4af9a86ee7c4579df9a8dc268be0a2f949d376cc9b - # via - # jaraco-classes - # jaraco-functools -nh3==0.3.2 \ - --hash=sha256:019ecbd007536b67fdf76fab411b648fb64e2257ca3262ec80c3425c24028c80 \ - --hash=sha256:03d617e5c8aa7331bd2659c654e021caf9bba704b109e7b2b28b039a00949fe5 \ - --hash=sha256:0dca4365db62b2d71ff1620ee4f800c4729849906c5dd504ee1a7b2389558e31 \ - --hash=sha256:0fe7ee035dd7b2290715baf29cb27167dddd2ff70ea7d052c958dbd80d323c99 \ - --hash=sha256:13398e676a14d6233f372c75f52d5ae74f98210172991f7a3142a736bd92b131 \ - --hash=sha256:169db03df90da63286e0560ea0efa9b6f3b59844a9735514a1d47e6bb2c8c61b \ - --hash=sha256:1710f3901cd6440ca92494ba2eb6dc260f829fa8d9196b659fa10de825610ce0 \ - --hash=sha256:1f9ba555a797dbdcd844b89523f29cdc90973d8bd2e836ea6b962cf567cadd93 \ - --hash=sha256:2ab70e8c6c7d2ce953d2a58102eefa90c2d0a5ed7aa40c7e29a487bc5e613131 \ - --hash=sha256:2c9850041b77a9147d6bbd6dbbf13eeec7009eb60b44e83f07fcb2910075bf9b \ - --hash=sha256:403c11563e50b915d0efdb622866d1d9e4506bce590ef7da57789bf71dd148b5 \ - --hash=sha256:45c953e57028c31d473d6b648552d9cab1efe20a42ad139d78e11d8f42a36130 \ - --hash=sha256:562da3dca7a17f9077593214a9781a94b8d76de4f158f8c895e62f09573945fe \ - --hash=sha256:6d66f41672eb4060cf87c037f760bdbc6847852ca9ef8e9c5a5da18f090abf87 \ - --hash=sha256:7064ccf5ace75825bd7bf57859daaaf16ed28660c1c6b306b649a9eda4b54b1e \ - --hash=sha256:72d67c25a84579f4a432c065e8b4274e53b7cf1df8f792cf846abfe2c3090866 \ - --hash=sha256:7bb18403f02b655a1bbe4e3a4696c2ae1d6ae8f5991f7cacb684b1ae27e6c9f7 \ - --hash=sha256:91e9b001101fb4500a2aafe3e7c92928d85242d38bf5ac0aba0b7480da0a4cd6 \ - --hash=sha256:a40202fd58e49129764f025bbaae77028e420f1d5b3c8e6f6fd3a6490d513868 \ - --hash=sha256:c8745454cdd28bbbc90861b80a0111a195b0e3961b9fa2e672be89eb199fa5d8 \ - --hash=sha256:cf5964d54edd405e68583114a7cba929468bcd7db5e676ae38ee954de1cfc104 \ - --hash=sha256:d18957a90806d943d141cc5e4a0fefa1d77cf0d7a156878bf9a66eed52c9cc7d \ - --hash=sha256:dce4248edc427c9b79261f3e6e2b3ecbdd9b88c267012168b4a7b3fc6fd41d13 \ - --hash=sha256:f2f55c4d2d5a207e74eefe4d828067bbb01300e06e2a7436142f915c5928de07 \ - --hash=sha256:f97f8b25cb2681d25e2338148159447e4d689aafdccfcf19e61ff7db3905768a - # via readme-renderer -packaging==25.0 \ - --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 - # via - # build - # twine - # wheel -pluggy==1.6.0 \ - --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 - # via keyrings-google-artifactregistry-auth -pyasn1==0.6.4 \ - --hash=sha256:deda9277cfd454080ec40b207fb6df82206a3a2688735233cdcd8d3d565f088b - # via - # pyasn1-modules - # rsa -pyasn1-modules==0.4.2 \ - --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a - # via google-auth -pycparser==2.23 \ - --hash=sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934 - # via cffi -pygments==2.19.2 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b - # via - # readme-renderer - # rich -pyproject-hooks==1.2.0 \ - --hash=sha256:9e5c6bfa8dcc30091c74b0cf803c81fdd29d94f01992a7707bc97babb1141913 - # via build -readme-renderer==44.0 \ - --hash=sha256:2fbca89b81a08526aadf1357a8c2ae889ec05fb03f5da67f9769c9a592166151 - # via twine -requests==2.33.0 \ - --hash=sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b - # via - # id - # keyrings-google-artifactregistry-auth - # requests-toolbelt - # twine -requests-toolbelt==1.0.0 \ - --hash=sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06 - # via twine -rfc3986==2.0.0 \ - --hash=sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd - # via twine -rich==14.2.0 \ - --hash=sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd - # via twine -rsa==4.9.1 \ - --hash=sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762 - # via google-auth -secretstorage==3.5.0 \ - --hash=sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137 - # via keyring -twine==6.2.0 \ - --hash=sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8 - # via -r build.in -urllib3==2.7.0 \ - --hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897 - # via - # requests - # twine -wheel==0.46.2 \ - --hash=sha256:33ae60725d69eaa249bc1982e739943c23b34b58d51f1cb6253453773aca6e65 - # via -r build.in diff --git a/.kokoro/samples/lint/common.cfg b/.kokoro/samples/lint/common.cfg new file mode 100644 index 00000000000..b4d26c1f982 --- /dev/null +++ b/.kokoro/samples/lint/common.cfg @@ -0,0 +1,34 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "lint" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/lint/continuous.cfg b/.kokoro/samples/lint/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/lint/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/lint/periodic.cfg b/.kokoro/samples/lint/periodic.cfg new file mode 100644 index 00000000000..50fec964973 --- /dev/null +++ b/.kokoro/samples/lint/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} \ No newline at end of file diff --git a/.kokoro/samples/lint/presubmit.cfg b/.kokoro/samples/lint/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/lint/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.10/common.cfg b/.kokoro/samples/python3.10/common.cfg new file mode 100644 index 00000000000..8f9c66c571b --- /dev/null +++ b/.kokoro/samples/python3.10/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.10" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-310" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.10/continuous.cfg b/.kokoro/samples/python3.10/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.10/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.10/periodic-head.cfg b/.kokoro/samples/python3.10/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.10/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.10/periodic.cfg b/.kokoro/samples/python3.10/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.10/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.10/presubmit.cfg b/.kokoro/samples/python3.10/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.10/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.11/common.cfg b/.kokoro/samples/python3.11/common.cfg new file mode 100644 index 00000000000..1bba39114aa --- /dev/null +++ b/.kokoro/samples/python3.11/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.11" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-311" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.11/continuous.cfg b/.kokoro/samples/python3.11/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.11/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.11/periodic-head.cfg b/.kokoro/samples/python3.11/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.11/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.11/periodic.cfg b/.kokoro/samples/python3.11/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.11/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.11/presubmit.cfg b/.kokoro/samples/python3.11/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.11/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.12/common.cfg b/.kokoro/samples/python3.12/common.cfg new file mode 100644 index 00000000000..abf83e196db --- /dev/null +++ b/.kokoro/samples/python3.12/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.12" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-312" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.12/continuous.cfg b/.kokoro/samples/python3.12/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.12/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.12/periodic-head.cfg b/.kokoro/samples/python3.12/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.12/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.12/periodic.cfg b/.kokoro/samples/python3.12/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.12/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.12/presubmit.cfg b/.kokoro/samples/python3.12/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.12/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.13/common.cfg b/.kokoro/samples/python3.13/common.cfg new file mode 100644 index 00000000000..6a5d9a20800 --- /dev/null +++ b/.kokoro/samples/python3.13/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.13" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-313" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" diff --git a/.kokoro/samples/python3.13/continuous.cfg b/.kokoro/samples/python3.13/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.13/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.13/periodic-head.cfg b/.kokoro/samples/python3.13/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.13/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.13/periodic.cfg b/.kokoro/samples/python3.13/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.13/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.13/presubmit.cfg b/.kokoro/samples/python3.13/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.13/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.7/common.cfg b/.kokoro/samples/python3.7/common.cfg new file mode 100644 index 00000000000..09d7af02ba9 --- /dev/null +++ b/.kokoro/samples/python3.7/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.7" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py37" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.7/continuous.cfg b/.kokoro/samples/python3.7/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.7/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.7/periodic-head.cfg b/.kokoro/samples/python3.7/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.7/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.7/periodic.cfg b/.kokoro/samples/python3.7/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.7/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.7/presubmit.cfg b/.kokoro/samples/python3.7/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.7/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.8/common.cfg b/.kokoro/samples/python3.8/common.cfg new file mode 100644 index 00000000000..976d9ce8c5c --- /dev/null +++ b/.kokoro/samples/python3.8/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.8" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py38" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.8/continuous.cfg b/.kokoro/samples/python3.8/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.8/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.8/periodic-head.cfg b/.kokoro/samples/python3.8/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.8/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.8/periodic.cfg b/.kokoro/samples/python3.8/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.8/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.8/presubmit.cfg b/.kokoro/samples/python3.8/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.8/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.9/common.cfg b/.kokoro/samples/python3.9/common.cfg new file mode 100644 index 00000000000..603cfffa280 --- /dev/null +++ b/.kokoro/samples/python3.9/common.cfg @@ -0,0 +1,40 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "py-3.9" +} + +# Declare build specific Cloud project. +env_vars: { + key: "BUILD_SPECIFIC_GCLOUD_PROJECT" + value: "python-docs-samples-tests-py39" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples.sh" +} + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-samples-testing-docker" +} + +# Download secrets for samples +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/python-docs-samples" + +# 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" \ No newline at end of file diff --git a/.kokoro/samples/python3.9/continuous.cfg b/.kokoro/samples/python3.9/continuous.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.9/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.9/periodic-head.cfg b/.kokoro/samples/python3.9/periodic-head.cfg new file mode 100644 index 00000000000..123a35fbd3d --- /dev/null +++ b/.kokoro/samples/python3.9/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/python-bigquery-dataframes/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.9/periodic.cfg b/.kokoro/samples/python3.9/periodic.cfg new file mode 100644 index 00000000000..71cd1e597e3 --- /dev/null +++ b/.kokoro/samples/python3.9/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.9/presubmit.cfg b/.kokoro/samples/python3.9/presubmit.cfg new file mode 100644 index 00000000000..a1c8d9759c8 --- /dev/null +++ b/.kokoro/samples/python3.9/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/test-samples-against-head.sh b/.kokoro/test-samples-against-head.sh new file mode 100755 index 00000000000..e9d8bd79a64 --- /dev/null +++ b/.kokoro/test-samples-against-head.sh @@ -0,0 +1,26 @@ +#!/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. + +# A customized test runner for samples. +# +# For periodic builds, you can specify this file for testing against head. + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +exec .kokoro/test-samples-impl.sh diff --git a/.kokoro/test-samples-impl.sh b/.kokoro/test-samples-impl.sh new file mode 100755 index 00000000000..53e365bc4e7 --- /dev/null +++ b/.kokoro/test-samples-impl.sh @@ -0,0 +1,103 @@ +#!/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. + + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +# Exit early if samples don't exist +if ! find samples -name 'requirements.txt' | grep -q .; then + echo "No tests run. './samples/**/requirements.txt' not found" + exit 0 +fi + +# Disable buffering, so that the logs stream through. +export PYTHONUNBUFFERED=1 + +# Debug: show build environment +env | grep KOKORO + +# Install nox +# `virtualenv==20.26.6` is added for Python 3.7 compatibility +python3.9 -m pip install --upgrade --quiet nox virtualenv==20.26.6 + +# Use secrets acessor service account to get secrets +if [[ -f "${KOKORO_GFILE_DIR}/secrets_viewer_service_account.json" ]]; then + gcloud auth activate-service-account \ + --key-file="${KOKORO_GFILE_DIR}/secrets_viewer_service_account.json" \ + --project="cloud-devrel-kokoro-resources" +fi + +# This script will create 3 files: +# - testing/test-env.sh +# - testing/service-account.json +# - testing/client-secrets.json +./scripts/decrypt-secrets.sh + +source ./testing/test-env.sh +export GOOGLE_APPLICATION_CREDENTIALS=$(pwd)/testing/service-account.json + +# For cloud-run session, we activate the service account for gcloud sdk. +gcloud auth activate-service-account \ + --key-file "${GOOGLE_APPLICATION_CREDENTIALS}" + +export GOOGLE_CLIENT_SECRETS=$(pwd)/testing/client-secrets.json + +echo -e "\n******************** TESTING PROJECTS ********************" + +# Switch to 'fail at end' to allow all tests to complete before exiting. +set +e +# Use RTN to return a non-zero value if the test fails. +RTN=0 +ROOT=$(pwd) +# Find all requirements.txt in the samples directory (may break on whitespace). +for file in samples/**/requirements.txt; do + cd "$ROOT" + # Navigate to the project folder. + file=$(dirname "$file") + cd "$file" + + echo "------------------------------------------------------------" + echo "- testing $file" + echo "------------------------------------------------------------" + + # Use nox to execute the tests for the project. + python3.9 -m nox -s "$RUN_TESTS_SESSION" + EXIT=$? + + # If this is a periodic build, send the test log to the FlakyBot. + # See https://github.com/googleapis/repo-automation-bots/tree/main/packages/flakybot. + if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then + chmod +x $KOKORO_GFILE_DIR/linux_amd64/flakybot + $KOKORO_GFILE_DIR/linux_amd64/flakybot + fi + + if [[ $EXIT -ne 0 ]]; then + RTN=1 + echo -e "\n Testing failed: Nox returned a non-zero exit code. \n" + else + echo -e "\n Testing completed.\n" + fi + +done +cd "$ROOT" + +# Workaround for Kokoro permissions issue: delete secrets +rm testing/{test-env.sh,client-secrets.json,service-account.json} + +exit "$RTN" diff --git a/.kokoro/test-samples.sh b/.kokoro/test-samples.sh new file mode 100755 index 00000000000..7933d820149 --- /dev/null +++ b/.kokoro/test-samples.sh @@ -0,0 +1,44 @@ +#!/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. + +# The default test runner for samples. +# +# For periodic builds, we rewinds the repo to the latest release, and +# run test-samples-impl.sh. + +# `-e` enables the script to automatically fail when a command fails +# `-o pipefail` sets the exit code to the rightmost comment to exit with a non-zero +set -eo pipefail +# Enables `**` to include files nested inside sub-folders +shopt -s globstar + +# Run periodic samples tests at latest release +if [[ $KOKORO_BUILD_ARTIFACTS_SUBDIR = *"periodic"* ]]; then + # preserving the test runner implementation. + cp .kokoro/test-samples-impl.sh "${TMPDIR}/test-samples-impl.sh" + echo "--- IMPORTANT IMPORTANT IMPORTANT ---" + echo "Now we rewind the repo back to the latest release..." + LATEST_RELEASE=$(git describe --abbrev=0 --tags) + git checkout $LATEST_RELEASE + echo "The current head is: " + echo $(git rev-parse --verify HEAD) + echo "--- IMPORTANT IMPORTANT IMPORTANT ---" + # move back the test runner implementation if there's no file. + if [ ! -f .kokoro/test-samples-impl.sh ]; then + cp "${TMPDIR}/test-samples-impl.sh" .kokoro/test-samples-impl.sh + fi +fi + +exec .kokoro/test-samples-impl.sh diff --git a/.kokoro/trampoline.sh b/.kokoro/trampoline.sh new file mode 100755 index 00000000000..48f79699706 --- /dev/null +++ b/.kokoro/trampoline.sh @@ -0,0 +1,28 @@ +#!/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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 + +# Always run the cleanup script, regardless of the success of bouncing into +# the container. +function cleanup() { + chmod +x ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + ${KOKORO_GFILE_DIR}/trampoline_cleanup.sh + echo "cleanup"; +} +trap cleanup EXIT + +$(dirname $0)/populate-secrets.sh # Secret Manager secrets. +python3 "${KOKORO_GFILE_DIR}/trampoline_v1.py" \ No newline at end of file diff --git a/.kokoro/trampoline_v2.sh b/.kokoro/trampoline_v2.sh new file mode 100755 index 00000000000..35fa529231d --- /dev/null +++ b/.kokoro/trampoline_v2.sh @@ -0,0 +1,487 @@ +#!/usr/bin/env 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 +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# trampoline_v2.sh +# +# This script does 3 things. +# +# 1. Prepare the Docker image for the test +# 2. Run the Docker with appropriate flags to run the test +# 3. Upload the newly built Docker image +# +# in a way that is somewhat compatible with trampoline_v1. +# +# To run this script, first download few files from gcs to /dev/shm. +# (/dev/shm is passed into the container as KOKORO_GFILE_DIR). +# +# gsutil cp gs://cloud-devrel-kokoro-resources/python-docs-samples/secrets_viewer_service_account.json /dev/shm +# gsutil cp gs://cloud-devrel-kokoro-resources/python-docs-samples/automl_secrets.txt /dev/shm +# +# Then run the script. +# .kokoro/trampoline_v2.sh +# +# These environment variables are required: +# TRAMPOLINE_IMAGE: The docker image to use. +# TRAMPOLINE_DOCKERFILE: The location of the Dockerfile. +# +# You can optionally change these environment variables: +# TRAMPOLINE_IMAGE_UPLOAD: +# (true|false): Whether to upload the Docker image after the +# successful builds. +# TRAMPOLINE_BUILD_FILE: The script to run in the docker container. +# TRAMPOLINE_WORKSPACE: The workspace path in the docker container. +# Defaults to /workspace. +# Potentially there are some repo specific envvars in .trampolinerc in +# the project root. + + +set -euo pipefail + +TRAMPOLINE_VERSION="2.0.5" + +if command -v tput >/dev/null && [[ -n "${TERM:-}" ]]; then + readonly IO_COLOR_RED="$(tput setaf 1)" + readonly IO_COLOR_GREEN="$(tput setaf 2)" + readonly IO_COLOR_YELLOW="$(tput setaf 3)" + readonly IO_COLOR_RESET="$(tput sgr0)" +else + readonly IO_COLOR_RED="" + readonly IO_COLOR_GREEN="" + readonly IO_COLOR_YELLOW="" + readonly IO_COLOR_RESET="" +fi + +function function_exists { + [ $(LC_ALL=C type -t $1)"" == "function" ] +} + +# Logs a message using the given color. The first argument must be one +# of the IO_COLOR_* variables defined above, such as +# "${IO_COLOR_YELLOW}". The remaining arguments will be logged in the +# given color. The log message will also have an RFC-3339 timestamp +# prepended (in UTC). You can disable the color output by setting +# TERM=vt100. +function log_impl() { + local color="$1" + shift + local timestamp="$(date -u "+%Y-%m-%dT%H:%M:%SZ")" + echo "================================================================" + echo "${color}${timestamp}:" "$@" "${IO_COLOR_RESET}" + echo "================================================================" +} + +# Logs the given message with normal coloring and a timestamp. +function log() { + log_impl "${IO_COLOR_RESET}" "$@" +} + +# Logs the given message in green with a timestamp. +function log_green() { + log_impl "${IO_COLOR_GREEN}" "$@" +} + +# Logs the given message in yellow with a timestamp. +function log_yellow() { + log_impl "${IO_COLOR_YELLOW}" "$@" +} + +# Logs the given message in red with a timestamp. +function log_red() { + log_impl "${IO_COLOR_RED}" "$@" +} + +readonly tmpdir=$(mktemp -d -t ci-XXXXXXXX) +readonly tmphome="${tmpdir}/h" +mkdir -p "${tmphome}" + +function cleanup() { + rm -rf "${tmpdir}" +} +trap cleanup EXIT + +RUNNING_IN_CI="${RUNNING_IN_CI:-false}" + +# The workspace in the container, defaults to /workspace. +TRAMPOLINE_WORKSPACE="${TRAMPOLINE_WORKSPACE:-/workspace}" + +pass_down_envvars=( + # TRAMPOLINE_V2 variables. + # Tells scripts whether they are running as part of CI or not. + "RUNNING_IN_CI" + # Indicates which CI system we're in. + "TRAMPOLINE_CI" + # Indicates the version of the script. + "TRAMPOLINE_VERSION" +) + +log_yellow "Building with Trampoline ${TRAMPOLINE_VERSION}" + +# Detect which CI systems we're in. If we're in any of the CI systems +# we support, `RUNNING_IN_CI` will be true and `TRAMPOLINE_CI` will be +# the name of the CI system. Both envvars will be passing down to the +# container for telling which CI system we're in. +if [[ -n "${KOKORO_BUILD_ID:-}" ]]; then + # descriptive env var for indicating it's on CI. + RUNNING_IN_CI="true" + TRAMPOLINE_CI="kokoro" + if [[ "${TRAMPOLINE_USE_LEGACY_SERVICE_ACCOUNT:-}" == "true" ]]; then + if [[ ! -f "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" ]]; then + log_red "${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json does not exist. Did you forget to mount cloud-devrel-kokoro-resources/trampoline? Aborting." + exit 1 + fi + # This service account will be activated later. + TRAMPOLINE_SERVICE_ACCOUNT="${KOKORO_GFILE_DIR}/kokoro-trampoline.service-account.json" + else + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + gcloud auth list + fi + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet + fi + pass_down_envvars+=( + # KOKORO dynamic variables. + "KOKORO_BUILD_NUMBER" + "KOKORO_BUILD_ID" + "KOKORO_JOB_NAME" + "KOKORO_GIT_COMMIT" + "KOKORO_GITHUB_COMMIT" + "KOKORO_GITHUB_PULL_REQUEST_NUMBER" + "KOKORO_GITHUB_PULL_REQUEST_COMMIT" + # For FlakyBot + "KOKORO_GITHUB_COMMIT_URL" + "KOKORO_GITHUB_PULL_REQUEST_URL" + ) +elif [[ "${TRAVIS:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="travis" + pass_down_envvars+=( + "TRAVIS_BRANCH" + "TRAVIS_BUILD_ID" + "TRAVIS_BUILD_NUMBER" + "TRAVIS_BUILD_WEB_URL" + "TRAVIS_COMMIT" + "TRAVIS_COMMIT_MESSAGE" + "TRAVIS_COMMIT_RANGE" + "TRAVIS_JOB_NAME" + "TRAVIS_JOB_NUMBER" + "TRAVIS_JOB_WEB_URL" + "TRAVIS_PULL_REQUEST" + "TRAVIS_PULL_REQUEST_BRANCH" + "TRAVIS_PULL_REQUEST_SHA" + "TRAVIS_PULL_REQUEST_SLUG" + "TRAVIS_REPO_SLUG" + "TRAVIS_SECURE_ENV_VARS" + "TRAVIS_TAG" + ) +elif [[ -n "${GITHUB_RUN_ID:-}" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="github-workflow" + pass_down_envvars+=( + "GITHUB_WORKFLOW" + "GITHUB_RUN_ID" + "GITHUB_RUN_NUMBER" + "GITHUB_ACTION" + "GITHUB_ACTIONS" + "GITHUB_ACTOR" + "GITHUB_REPOSITORY" + "GITHUB_EVENT_NAME" + "GITHUB_EVENT_PATH" + "GITHUB_SHA" + "GITHUB_REF" + "GITHUB_HEAD_REF" + "GITHUB_BASE_REF" + ) +elif [[ "${CIRCLECI:-}" == "true" ]]; then + RUNNING_IN_CI="true" + TRAMPOLINE_CI="circleci" + pass_down_envvars+=( + "CIRCLE_BRANCH" + "CIRCLE_BUILD_NUM" + "CIRCLE_BUILD_URL" + "CIRCLE_COMPARE_URL" + "CIRCLE_JOB" + "CIRCLE_NODE_INDEX" + "CIRCLE_NODE_TOTAL" + "CIRCLE_PREVIOUS_BUILD_NUM" + "CIRCLE_PROJECT_REPONAME" + "CIRCLE_PROJECT_USERNAME" + "CIRCLE_REPOSITORY_URL" + "CIRCLE_SHA1" + "CIRCLE_STAGE" + "CIRCLE_USERNAME" + "CIRCLE_WORKFLOW_ID" + "CIRCLE_WORKFLOW_JOB_ID" + "CIRCLE_WORKFLOW_UPSTREAM_JOB_IDS" + "CIRCLE_WORKFLOW_WORKSPACE_ID" + ) +fi + +# Configure the service account for pulling the docker image. +function repo_root() { + local dir="$1" + while [[ ! -d "${dir}/.git" ]]; do + dir="$(dirname "$dir")" + done + echo "${dir}" +} + +# Detect the project root. In CI builds, we assume the script is in +# the git tree and traverse from there, otherwise, traverse from `pwd` +# to find `.git` directory. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + PROGRAM_PATH="$(realpath "$0")" + PROGRAM_DIR="$(dirname "${PROGRAM_PATH}")" + PROJECT_ROOT="$(repo_root "${PROGRAM_DIR}")" +else + PROJECT_ROOT="$(repo_root $(pwd))" +fi + +log_yellow "Changing to the project root: ${PROJECT_ROOT}." +cd "${PROJECT_ROOT}" + +# To support relative path for `TRAMPOLINE_SERVICE_ACCOUNT`, we need +# to use this environment variable in `PROJECT_ROOT`. +if [[ -n "${TRAMPOLINE_SERVICE_ACCOUNT:-}" ]]; then + + mkdir -p "${tmpdir}/gcloud" + gcloud_config_dir="${tmpdir}/gcloud" + + log_yellow "Using isolated gcloud config: ${gcloud_config_dir}." + export CLOUDSDK_CONFIG="${gcloud_config_dir}" + + log_yellow "Using ${TRAMPOLINE_SERVICE_ACCOUNT} for authentication." + gcloud auth activate-service-account \ + --key-file "${TRAMPOLINE_SERVICE_ACCOUNT}" + log_yellow "Configuring Container Registry access" + gcloud auth configure-docker --quiet +fi + +required_envvars=( + # The basic trampoline configurations. + "TRAMPOLINE_IMAGE" + "TRAMPOLINE_BUILD_FILE" +) + +if [[ -f "${PROJECT_ROOT}/.trampolinerc" ]]; then + source "${PROJECT_ROOT}/.trampolinerc" +fi + +log_yellow "Checking environment variables." +for e in "${required_envvars[@]}" +do + if [[ -z "${!e:-}" ]]; then + log "Missing ${e} env var. Aborting." + exit 1 + fi +done + +# We want to support legacy style TRAMPOLINE_BUILD_FILE used with V1 +# script: e.g. "github/repo-name/.kokoro/run_tests.sh" +TRAMPOLINE_BUILD_FILE="${TRAMPOLINE_BUILD_FILE#github/*/}" +log_yellow "Using TRAMPOLINE_BUILD_FILE: ${TRAMPOLINE_BUILD_FILE}" + +# ignore error on docker operations and test execution +set +e + +log_yellow "Preparing Docker image." +# We only download the docker image in CI builds. +if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + # Download the docker image specified by `TRAMPOLINE_IMAGE` + + # We may want to add --max-concurrent-downloads flag. + + log_yellow "Start pulling the Docker image: ${TRAMPOLINE_IMAGE}." + if docker pull "${TRAMPOLINE_IMAGE}"; then + log_green "Finished pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="true" + else + log_red "Failed pulling the Docker image: ${TRAMPOLINE_IMAGE}." + has_image="false" + fi +else + # For local run, check if we have the image. + if docker images "${TRAMPOLINE_IMAGE}:latest" | grep "${TRAMPOLINE_IMAGE}"; then + has_image="true" + else + has_image="false" + fi +fi + + +# The default user for a Docker container has uid 0 (root). To avoid +# creating root-owned files in the build directory we tell docker to +# use the current user ID. +user_uid="$(id -u)" +user_gid="$(id -g)" +user_name="$(id -un)" + +# To allow docker in docker, we add the user to the docker group in +# the host os. +docker_gid=$(cut -d: -f3 < <(getent group docker)) + +update_cache="false" +if [[ "${TRAMPOLINE_DOCKERFILE:-none}" != "none" ]]; then + # Build the Docker image from the source. + context_dir=$(dirname "${TRAMPOLINE_DOCKERFILE}") + docker_build_flags=( + "-f" "${TRAMPOLINE_DOCKERFILE}" + "-t" "${TRAMPOLINE_IMAGE}" + "--build-arg" "UID=${user_uid}" + "--build-arg" "USERNAME=${user_name}" + ) + if [[ "${has_image}" == "true" ]]; then + docker_build_flags+=("--cache-from" "${TRAMPOLINE_IMAGE}") + fi + + log_yellow "Start building the docker image." + if [[ "${TRAMPOLINE_VERBOSE:-false}" == "true" ]]; then + echo "docker build" "${docker_build_flags[@]}" "${context_dir}" + fi + + # ON CI systems, we want to suppress docker build logs, only + # output the logs when it fails. + if [[ "${RUNNING_IN_CI:-}" == "true" ]]; then + if docker build "${docker_build_flags[@]}" "${context_dir}" \ + > "${tmpdir}/docker_build.log" 2>&1; then + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + cat "${tmpdir}/docker_build.log" + fi + + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + log_yellow "Dumping the build logs:" + cat "${tmpdir}/docker_build.log" + exit 1 + fi + else + if docker build "${docker_build_flags[@]}" "${context_dir}"; then + log_green "Finished building the docker image." + update_cache="true" + else + log_red "Failed to build the Docker image, aborting." + exit 1 + fi + fi +else + if [[ "${has_image}" != "true" ]]; then + log_red "We do not have ${TRAMPOLINE_IMAGE} locally, aborting." + exit 1 + fi +fi + +# We use an array for the flags so they are easier to document. +docker_flags=( + # Remove the container after it exists. + "--rm" + + # Use the host network. + "--network=host" + + # Run in priviledged mode. We are not using docker for sandboxing or + # isolation, just for packaging our dev tools. + "--privileged" + + # Run the docker script with the user id. Because the docker image gets to + # write in ${PWD} you typically want this to be your user id. + # To allow docker in docker, we need to use docker gid on the host. + "--user" "${user_uid}:${docker_gid}" + + # Pass down the USER. + "--env" "USER=${user_name}" + + # Mount the project directory inside the Docker container. + "--volume" "${PROJECT_ROOT}:${TRAMPOLINE_WORKSPACE}" + "--workdir" "${TRAMPOLINE_WORKSPACE}" + "--env" "PROJECT_ROOT=${TRAMPOLINE_WORKSPACE}" + + # Mount the temporary home directory. + "--volume" "${tmphome}:/h" + "--env" "HOME=/h" + + # Allow docker in docker. + "--volume" "/var/run/docker.sock:/var/run/docker.sock" + + # Mount the /tmp so that docker in docker can mount the files + # there correctly. + "--volume" "/tmp:/tmp" + # Pass down the KOKORO_GFILE_DIR and KOKORO_KEYSTORE_DIR + # TODO(tmatsuo): This part is not portable. + "--env" "TRAMPOLINE_SECRET_DIR=/secrets" + "--volume" "${KOKORO_GFILE_DIR:-/dev/shm}:/secrets/gfile" + "--env" "KOKORO_GFILE_DIR=/secrets/gfile" + "--volume" "${KOKORO_KEYSTORE_DIR:-/dev/shm}:/secrets/keystore" + "--env" "KOKORO_KEYSTORE_DIR=/secrets/keystore" +) + +# Add an option for nicer output if the build gets a tty. +if [[ -t 0 ]]; then + docker_flags+=("-it") +fi + +# Passing down env vars +for e in "${pass_down_envvars[@]}" +do + if [[ -n "${!e:-}" ]]; then + docker_flags+=("--env" "${e}=${!e}") + fi +done + +# If arguments are given, all arguments will become the commands run +# in the container, otherwise run TRAMPOLINE_BUILD_FILE. +if [[ $# -ge 1 ]]; then + log_yellow "Running the given commands '" "${@:1}" "' in the container." + readonly commands=("${@:1}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" "${commands[@]}" +else + log_yellow "Running the tests in a Docker container." + docker_flags+=("--entrypoint=${TRAMPOLINE_BUILD_FILE}") + if [[ "${TRAMPOLINE_VERBOSE:-}" == "true" ]]; then + echo docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" + fi + docker run "${docker_flags[@]}" "${TRAMPOLINE_IMAGE}" +fi + + +test_retval=$? + +if [[ ${test_retval} -eq 0 ]]; then + log_green "Build finished with ${test_retval}" +else + log_red "Build finished with ${test_retval}" +fi + +# Only upload it when the test passes. +if [[ "${update_cache}" == "true" ]] && \ + [[ $test_retval == 0 ]] && \ + [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]]; then + log_yellow "Uploading the Docker image." + if docker push "${TRAMPOLINE_IMAGE}"; then + log_green "Finished uploading the Docker image." + else + log_red "Failed uploading the Docker image." + fi + # Call trampoline_after_upload_hook if it's defined. + if function_exists trampoline_after_upload_hook; then + trampoline_after_upload_hook + fi + +fi + +exit "${test_retval}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000000..f839c3c0a49 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,49 @@ +# 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. +# +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +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 + hooks: + - id: isort + name: isort (python) +- repo: https://github.com/psf/black + rev: 22.3.0 + hooks: + - id: black +- repo: https://github.com/pycqa/flake8 + rev: 7.1.2 + hooks: + - id: flake8 +- repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.15.0 + hooks: + - id: mypy + 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.0.2 + hooks: + - id: biome-check + files: '\.(js|css)$' diff --git a/.repo-metadata.json b/.repo-metadata.json index b988476c181..0efaa967d2c 100644 --- a/.repo-metadata.json +++ b/.repo-metadata.json @@ -1,9 +1,16 @@ { - "client_documentation": "https://googleapis.dev/python/bigframes/latest", - "distribution_name": "bigframes", + "name": "bigframes", + "name_pretty": "A unified Python API in BigQuery", + "product_documentation": "https://cloud.google.com/bigquery", + "client_documentation": "https://cloud.google.com/python/docs/reference/bigframes/latest", + "issue_tracker": "https://github.com/googleapis/python-bigquery-dataframes/issues", + "release_level": "preview", "language": "python", "library_type": "INTEGRATION", - "name": "bigframes", - "release_level": "stable", - "repo": "googleapis/google-cloud-python" -} \ No newline at end of file + "repo": "googleapis/python-bigquery-dataframes", + "distribution_name": "bigframes", + "api_id": "bigquery.googleapis.com", + "default_version": "", + "codeowner_team": "@googleapis/api-bigquery-dataframe", + "api_shortname": "bigquery" +} diff --git a/.trampolinerc b/.trampolinerc new file mode 100644 index 00000000000..0080152373d --- /dev/null +++ b/.trampolinerc @@ -0,0 +1,61 @@ +# 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. + +# Add required env vars here. +required_envvars+=( +) + +# Add env vars which are passed down into the container here. +pass_down_envvars+=( + "NOX_SESSION" + ############### + # Docs builds + ############### + "STAGING_BUCKET" + "V2_STAGING_BUCKET" + ################## + # Samples builds + ################## + "INSTALL_LIBRARY_FROM_SOURCE" + "RUN_TESTS_SESSION" + "BUILD_SPECIFIC_GCLOUD_PROJECT" + # Target directories. + "RUN_TESTS_DIRS" + # The nox session to run. + "RUN_TESTS_SESSION" +) + +# Prevent unintentional override on the default image. +if [[ "${TRAMPOLINE_IMAGE_UPLOAD:-false}" == "true" ]] && \ + [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then + echo "Please set TRAMPOLINE_IMAGE if you want to upload the Docker image." + exit 1 +fi + +# Define the default value if it makes sense. +if [[ -z "${TRAMPOLINE_IMAGE_UPLOAD:-}" ]]; then + TRAMPOLINE_IMAGE_UPLOAD="" +fi + +if [[ -z "${TRAMPOLINE_IMAGE:-}" ]]; then + TRAMPOLINE_IMAGE="" +fi + +if [[ -z "${TRAMPOLINE_DOCKERFILE:-}" ]]; then + TRAMPOLINE_DOCKERFILE="" +fi + +if [[ -z "${TRAMPOLINE_BUILD_FILE:-}" ]]; then + TRAMPOLINE_BUILD_FILE="" +fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 6584f20bb87..fc4362cc87d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,712 +4,6 @@ [1]: https://pypi.org/project/bigframes/#history -## [2.48.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.47.0...bigframes-v2.48.0) (2026-08-12) - - -### Features - -* **bigframes:** Transpiler supports more string ops ([#17693](https://github.com/googleapis/google-cloud-python/issues/17693)) ([7d2bc21](https://github.com/googleapis/google-cloud-python/commit/7d2bc213caabb23eef04e8242e1a05351013d217)) - - -### Bug Fixes - -* **bigframes:** fix field name typos for ai.generate* functions ([#17983](https://github.com/googleapis/google-cloud-python/issues/17983)) ([1b5c48b](https://github.com/googleapis/google-cloud-python/commit/1b5c48b851d92d77f9fb4ce5beea57a22281445d)) -* **bigframes:** resolve session-scoped API method logging ([#18076](https://github.com/googleapis/google-cloud-python/issues/18076)) ([ace618b](https://github.com/googleapis/google-cloud-python/commit/ace618b66ca74199d7269f95ef94d5e5a1effac8)) -* **bigframes:** update GeminiTextGenerator default model to gemini-2.5-flash ([#18060](https://github.com/googleapis/google-cloud-python/issues/18060)) ([5770ff6](https://github.com/googleapis/google-cloud-python/commit/5770ff6bb4bd5601e8596a1100de85fec891e02a)) -* bump @angular/compiler, @angular/common, @angular/core, @angular/forms, @angular/platform-browser, @angular/router and @angular/compiler-cli in /packages/bigframes/bigframes/display/table_widget_angular ([#17992](https://github.com/googleapis/google-cloud-python/issues/17992)) ([346aaab](https://github.com/googleapis/google-cloud-python/commit/346aaab68b04fe5d280a5b49aee376d67b4233ad)) -* bump fast-uri from 3.1.4 to 3.1.5 in /packages/bigframes/bigframes/display/table_widget_angular ([#17989](https://github.com/googleapis/google-cloud-python/issues/17989)) ([3b3f3f4](https://github.com/googleapis/google-cloud-python/commit/3b3f3f4743b64e9ce483392930aa91efa6f022cb)) -* bump hono from 4.12.31 to 4.13.1 in /packages/bigframes/bigframes/display/table_widget_angular ([#18032](https://github.com/googleapis/google-cloud-python/issues/18032)) ([cddf35b](https://github.com/googleapis/google-cloud-python/commit/cddf35b4df9f4d3466a91a82cd703be727b37b3b)) -* bump ip-address and express-rate-limit in /packages/bigframes/bigframes/display/table_widget_angular ([#17985](https://github.com/googleapis/google-cloud-python/issues/17985)) ([02ed656](https://github.com/googleapis/google-cloud-python/commit/02ed656410a9682d308eaf9d3086fe76cf7e27d0)) -* bump undici and @angular/build in /packages/bigframes/bigframes/display/table_widget_angular ([#17986](https://github.com/googleapis/google-cloud-python/issues/17986)) ([6938061](https://github.com/googleapis/google-cloud-python/commit/69380617aa68f565e0885911c87520abe216c358)) -* bump undici from 7.25.0 to 7.29.0 in /packages/bigframes/tests/js ([#17987](https://github.com/googleapis/google-cloud-python/issues/17987)) ([65a3571](https://github.com/googleapis/google-cloud-python/commit/65a3571325be9497c17d1c640b3cd56d8a7766e2)) - -## [2.47.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.46.0...bigframes-v2.47.0) (2026-08-03) - - -### Features - -* **bigframes:** add ai.embed and ai.similarity to bigquery accessor ([#17927](https://github.com/googleapis/google-cloud-python/issues/17927)) ([2d1372b](https://github.com/googleapis/google-cloud-python/commit/2d1372b25b871aaebba0296f516dbbdd9ef43a37)) - - -### Bug Fixes - -* **bigframes:** fix mypy errors in _magics.py related to get_ipython ([#17979](https://github.com/googleapis/google-cloud-python/issues/17979)) ([24d955e](https://github.com/googleapis/google-cloud-python/commit/24d955e7573acf5e2941f9d10b342b171d9def24)) -* bump brace-expansion from 5.0.6 to 5.0.7 in /packages/bigframes/bigframes/display/table_widget_angular ([#17794](https://github.com/googleapis/google-cloud-python/issues/17794)) ([2df1bb5](https://github.com/googleapis/google-cloud-python/commit/2df1bb5cb53906bfc41de92c10fdb5a8920ce36f)) -* bump fast-uri from 3.1.1 to 3.1.4 in /packages/bigframes/bigframes/display/table_widget_angular ([#17828](https://github.com/googleapis/google-cloud-python/issues/17828)) ([8ce495a](https://github.com/googleapis/google-cloud-python/commit/8ce495ab51d35cc56332f27589ce33b035215abf)) -* bump hono from 4.12.16 to 4.12.31 in /packages/bigframes/bigframes/display/table_widget_angular ([#17829](https://github.com/googleapis/google-cloud-python/issues/17829)) ([6d6fa39](https://github.com/googleapis/google-cloud-python/commit/6d6fa399b1578dd6c0ed7b2284b35f6bb0922da8)) -* bump immutable from 5.1.7 to 5.1.9 in /packages/bigframes/bigframes/display/table_widget_angular ([#17830](https://github.com/googleapis/google-cloud-python/issues/17830)) ([6abb1da](https://github.com/googleapis/google-cloud-python/commit/6abb1da519e7d4acdbdd531982530047fdafe854)) -* bump postcss from 8.5.14 to 8.5.23 in /packages/bigframes/bigframes/display/table_widget_angular ([#17908](https://github.com/googleapis/google-cloud-python/issues/17908)) ([e68eb8f](https://github.com/googleapis/google-cloud-python/commit/e68eb8ff03948b4a694aca465b69543ae0be6c27)) -* bump tar from 7.5.16 to 7.5.20 in /packages/bigframes/bigframes/display/table_widget_angular ([#17793](https://github.com/googleapis/google-cloud-python/issues/17793)) ([3502d41](https://github.com/googleapis/google-cloud-python/commit/3502d4183d9864e4310b3daf063ec90be9969e25)) -* require Protobuf 6.33.5+ ([#17743](https://github.com/googleapis/google-cloud-python/issues/17743)) ([d267342](https://github.com/googleapis/google-cloud-python/commit/d26734293c23f06ccce048f7d9b0fa365e813410)) - -## [2.46.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.45.0...bigframes-v2.46.0) (2026-07-16) - - -### Features - -* **bigframes:** Support groupby.agg/transform with udf transpiler ([#17613](https://github.com/googleapis/google-cloud-python/issues/17613)) ([cae94f9](https://github.com/googleapis/google-cloud-python/commit/cae94f99121d7708671a35acf82616cfe378cccb)) -* **bigframes:** support offset-based column access via iloc ([#17367](https://github.com/googleapis/google-cloud-python/issues/17367)) ([4253fab](https://github.com/googleapis/google-cloud-python/commit/4253fab07ccdb2b94e247f8dade793828754b88b)) - - -### Bug Fixes - -* **bigframes:** Fix sqlglot backend regressions ([#17655](https://github.com/googleapis/google-cloud-python/issues/17655)) ([91f93bc](https://github.com/googleapis/google-cloud-python/commit/91f93bcd7b71b6cea62f506ed684500cec1eb6bb)) -* bump gradio from 6.15.0 to 6.15.1 in /packages/bigframes ([#17712](https://github.com/googleapis/google-cloud-python/issues/17712)) ([a85d59f](https://github.com/googleapis/google-cloud-python/commit/a85d59f39998d94cbac8d5e98547f18b3cc5e5be)) -* bump mistune from 3.2.1 to 3.3.0 in /packages/bigframes ([#17694](https://github.com/googleapis/google-cloud-python/issues/17694)) ([e5f7fef](https://github.com/googleapis/google-cloud-python/commit/e5f7fef31c2bbe5f559f4c79fdaf4ebcf6e1bd3f)) -* bump soupsieve from 2.7 to 2.8.4 in /packages/bigframes ([#17695](https://github.com/googleapis/google-cloud-python/issues/17695)) ([635da34](https://github.com/googleapis/google-cloud-python/commit/635da3453b2ba78b8abea43c554a055257f33aa1)) -* bump transformers from 5.3.0 to 5.5.0 in /packages/bigframes ([#17700](https://github.com/googleapis/google-cloud-python/issues/17700)) ([4b049c4](https://github.com/googleapis/google-cloud-python/commit/4b049c4eb8dc1ec91320b55fe515c339cd448af3)) -* emit bracketed inline array syntax for scalar subquery expressions ([#17716](https://github.com/googleapis/google-cloud-python/issues/17716)) ([ce5fd50](https://github.com/googleapis/google-cloud-python/commit/ce5fd500b68c16f56ea8066d8a6fa4b0b8d92081)) - - -### Documentation - -* make landing page quickstart runnable ([fc423c8](https://github.com/googleapis/google-cloud-python/commit/fc423c809cc80168f45fee795d5db5dc7a571fb1)) -* make landing page quickstart runnable ([#17687](https://github.com/googleapis/google-cloud-python/issues/17687)) ([fc423c8](https://github.com/googleapis/google-cloud-python/commit/fc423c809cc80168f45fee795d5db5dc7a571fb1)) - -## [2.45.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.44.0...bigframes-v2.45.0) (2026-07-08) - - -### Features - -* **bigframes:** add ai.classify, ai.score, ai.if_ to the df bq accessor ([#17569](https://github.com/googleapis/google-cloud-python/issues/17569)) ([4f94be8](https://github.com/googleapis/google-cloud-python/commit/4f94be8f01971380f0fb5b433ab33d7b4cb7176d)) -* **bigframes:** Enable local udf execution ([#17588](https://github.com/googleapis/google-cloud-python/issues/17588)) ([b8ed34c](https://github.com/googleapis/google-cloud-python/commit/b8ed34cc05101c58ef285822d86298cd0f56613c)) -* **bigframes:** UDF transpiler handles some control flow ([#17558](https://github.com/googleapis/google-cloud-python/issues/17558)) ([a8cbde3](https://github.com/googleapis/google-cloud-python/commit/a8cbde39199f838a43ebc8b938ad722595655abd)) -* support gemini-3.x models ([#17615](https://github.com/googleapis/google-cloud-python/issues/17615)) ([5d0efa3](https://github.com/googleapis/google-cloud-python/commit/5d0efa3cb86568a33a5b3097f30733d39fcbef66)) - - -### Bug Fixes - -* bump gdal from 3.13.0 to 3.13.1 in /packages/bigframes ([#17609](https://github.com/googleapis/google-cloud-python/issues/17609)) ([0f4bfed](https://github.com/googleapis/google-cloud-python/commit/0f4bfed4685a362f6487cd4cb02ead3c0dde85c9)) -* bump gradio from 5.39.0 to 6.15.0 in /packages/bigframes ([#17619](https://github.com/googleapis/google-cloud-python/issues/17619)) ([bddda6a](https://github.com/googleapis/google-cloud-python/commit/bddda6a11a9c9bcce2d9e8b665b63d47f49f894f)) -* bump transformers from 4.54.1 to 5.3.0 in /packages/bigframes ([#17610](https://github.com/googleapis/google-cloud-python/issues/17610)) ([10eca3f](https://github.com/googleapis/google-cloud-python/commit/10eca3f4b6578c9451b06cdb2889561563fa8d0d)) - -## [2.44.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.43.0...bigframes-v2.44.0) (2026-06-25) - - -### Features - -* add date functions to `bigframes.bigquery` module ([#17514](https://github.com/googleapis/google-cloud-python/issues/17514)) ([e5d2e35](https://github.com/googleapis/google-cloud-python/commit/e5d2e35db94373ca395976fd755c2bc7e0a060bd)) -* **bigframes:** add AI TVFs to the pandas bq accessor ([#17402](https://github.com/googleapis/google-cloud-python/issues/17402)) ([ee74e31](https://github.com/googleapis/google-cloud-python/commit/ee74e3140a2e11936c36714a27393c3072bed6c7)) -* Experimental transpilation of unannotated python callables ([#17419](https://github.com/googleapis/google-cloud-python/issues/17419)) ([ea9aad9](https://github.com/googleapis/google-cloud-python/commit/ea9aad9a43c306ab109054183b257e6c41a1b2e6)) -* support gemini-3.x models in loader and update default model to gemini-3.5-flash ([#17557](https://github.com/googleapis/google-cloud-python/issues/17557)) ([3619b29](https://github.com/googleapis/google-cloud-python/commit/3619b29e10ae04623d101808cb98be5edbb483b4)) -* support interactive execution of deferred DataFrames in TableWidget ([#17486](https://github.com/googleapis/google-cloud-python/issues/17486)) ([421eebd](https://github.com/googleapis/google-cloud-python/commit/421eebdb31d526a6d5ba27c433cf2803d7619be3)) - - -### Bug Fixes - -* avoid invalid CAST(NULL AS NULL) in SQLGlot compiler ([#17487](https://github.com/googleapis/google-cloud-python/issues/17487)) ([3b79caa](https://github.com/googleapis/google-cloud-python/commit/3b79caa8f40f61ccd7c655542e9f242f34e068e2)) -* **bigframes:** world-readable temp zip in create_cloud_function ([#17522](https://github.com/googleapis/google-cloud-python/issues/17522)) ([e726878](https://github.com/googleapis/google-cloud-python/commit/e7268785c6736c10c1337160b4d8606975062637)) -* bump @angular/common, @angular/forms, @angular/platform-browser and @angular/router in /packages/bigframes/bigframes/display/table_widget_angular ([#17525](https://github.com/googleapis/google-cloud-python/issues/17525)) ([2f893b1](https://github.com/googleapis/google-cloud-python/commit/2f893b1b53e7394655fd204d1f8a138212ad8227)) -* bump langsmith from 0.8.0 to 0.8.18 in /packages/bigframes ([#17518](https://github.com/googleapis/google-cloud-python/issues/17518)) ([f23063f](https://github.com/googleapis/google-cloud-python/commit/f23063f9182cdec868c16afb80304892850fbe88)) -* bump msgpack from 1.1.1 to 1.2.1 in /packages/bigframes ([#17520](https://github.com/googleapis/google-cloud-python/issues/17520)) ([36b5b7e](https://github.com/googleapis/google-cloud-python/commit/36b5b7ebb01030a2d0f10d49fe4827ddc79dde9a)) -* bump undici and @angular/build in /packages/bigframes/bigframes/display/table_widget_angular ([#17519](https://github.com/googleapis/google-cloud-python/issues/17519)) ([6fc45e3](https://github.com/googleapis/google-cloud-python/commit/6fc45e3790c5a248dcec4b74799834c7b9219ef0)) -* handle empty endpoints during cloud function reuse ([#17501](https://github.com/googleapis/google-cloud-python/issues/17501)) ([4f5593a](https://github.com/googleapis/google-cloud-python/commit/4f5593a520b5afdeb02cc28f19a9596dbc35a90f)) - - -### Documentation - -* ensure that PlotAccessor is included in the API reference ([#17513](https://github.com/googleapis/google-cloud-python/issues/17513)) ([6febabf](https://github.com/googleapis/google-cloud-python/commit/6febabf795106a0c336dc905fc23da88d8cc94a0)) - -## [2.43.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.42.0...bigframes-v2.43.0) (2026-06-12) - - -### Documentation - -* add a notebook explaining bqsql magics cell chaining (#17216) ([1a0de4a7701b7fdf4c2593b1960f1194ebc49793](https://github.com/googleapis/google-cloud-python/commit/1a0de4a7701b7fdf4c2593b1960f1194ebc49793)) - - -### Features - -* add `bigframes.bigquery.bit_count` and conversion scalar function (#17433) ([7f29823fadb3cff42dbe666f8c7aa33bab3c7021](https://github.com/googleapis/google-cloud-python/commit/7f29823fadb3cff42dbe666f8c7aa33bab3c7021)) - - -### Bug Fixes - -* preserve aliases on cast columns and fix star selection in sqlglot (#17394) (#17455) ([145034a345eb3e14ea3f23dfcafa3d2409a09067](https://github.com/googleapis/google-cloud-python/commit/145034a345eb3e14ea3f23dfcafa3d2409a09067)) -* bump pyarrow from 15.0.2 to 23.0.1 in /packages/bigframes (#17386) ([f59c2b2aa61316cf04b650933036ef50f6a1f08c](https://github.com/googleapis/google-cloud-python/commit/f59c2b2aa61316cf04b650933036ef50f6a1f08c)) -* improve error message when unescaped `{` are found in SQL cells (#17346) ([3a90cc8e867c8a2d2f8060858fde9eda94f80a54](https://github.com/googleapis/google-cloud-python/commit/3a90cc8e867c8a2d2f8060858fde9eda94f80a54)) - -## [2.42.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.41.0...bigframes-v2.42.0) (2026-06-08) - - -### Features - -* create `Series.bigquery.function_name` accessors for array and AEAD functions (#17279) ([d01a4ba30040cfcb6498d0e9ef3ed3a54d56239d](https://github.com/googleapis/google-cloud-python/commit/d01a4ba30040cfcb6498d0e9ef3ed3a54d56239d)) -* support automatic per-cell execution history filtering and isolated callbacks (#17144) ([7d440111d836b94f0ce22f6b08c7ce0e7bf4a38a](https://github.com/googleapis/google-cloud-python/commit/7d440111d836b94f0ce22f6b08c7ce0e7bf4a38a)) -* Add ai_generate functions to the dataframe bq accessor (#17302) ([6b62cb6fb3de94326b8944ae08a400c12529cad2](https://github.com/googleapis/google-cloud-python/commit/6b62cb6fb3de94326b8944ae08a400c12529cad2)) - - -### Bug Fixes - -* nameless column to_frame bug for pandas 3.0 (#17371) ([b23bfa4ceb819bca8201a7fe8b64a9bed56733f0](https://github.com/googleapis/google-cloud-python/commit/b23bfa4ceb819bca8201a7fe8b64a9bed56733f0)) -* include pyopenssl as a dependency (#17362) ([1f6205ee5a370249ece2c2cc7131a47830ef00ea](https://github.com/googleapis/google-cloud-python/commit/1f6205ee5a370249ece2c2cc7131a47830ef00ea)) -* Fix IsInOp literal bug with sqlglot (#17356) ([a3d93afe74dd2b5ec8a2ae92f91c95962764debe](https://github.com/googleapis/google-cloud-python/commit/a3d93afe74dd2b5ec8a2ae92f91c95962764debe)) - -## [2.41.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.40.0...bigframes-v2.41.0) (2026-05-28) - - -### Documentation - -* modernize multimodal tutorials and migrate legacy blob APIs (#16918) ([05d80c3cccc237480dc5f589b7768b57a147cb0e](https://github.com/googleapis/google-cloud-python/commit/05d80c3cccc237480dc5f589b7768b57a147cb0e)) - - -### Features - -* Defer unnamed @udf deployment until needed (#17217) ([ad3b8fa9693b7d23859c417f2f5954ea946f2bb8](https://github.com/googleapis/google-cloud-python/commit/ad3b8fa9693b7d23859c417f2f5954ea946f2bb8)) -* set up Angular infrastructure for TableWidget (#16934) ([4d20bab8ce15c31e5832789e6a5d306983b8584a](https://github.com/googleapis/google-cloud-python/commit/4d20bab8ce15c31e5832789e6a5d306983b8584a)) -* support pandas inputs in more bigframes.bigquery functions (#17224) ([d4d885547f99c08caabad5e715aecd8c6f1fb4d6](https://github.com/googleapis/google-cloud-python/commit/d4d885547f99c08caabad5e715aecd8c6f1fb4d6)) -* add more scalar array functions to `bigframes.bigquery` (#17213) ([4f8a6c81797204f4334c7251c244bc0b6bd568e2](https://github.com/googleapis/google-cloud-python/commit/4f8a6c81797204f4334c7251c244bc0b6bd568e2)) -* add `bigframes.bigquery.deterministic_decrypt*` and `bigframes.bigquery.deterministic_encrypt` functions (#17212) ([85f36725802f3e7ad16156ca9e957e61d57d3112](https://github.com/googleapis/google-cloud-python/commit/85f36725802f3e7ad16156ca9e957e61d57d3112)) -* add `bigframes.bigquery.aead.*` scalar functions (#17168) ([a7e4d048e254cdb723df0a47ccbd8d09aed00c7a](https://github.com/googleapis/google-cloud-python/commit/a7e4d048e254cdb723df0a47ccbd8d09aed00c7a)) -* complete deprecation and cleanup of multimodal blob APIs (#16618) ([3624f3bb102e7d599097975db5cdaee508c9549a](https://github.com/googleapis/google-cloud-python/commit/3624f3bb102e7d599097975db5cdaee508c9549a)) -* support output_mode for ai.classify (#17097) ([098c35c5a8383d1585848e10806f9914b2ef4f97](https://github.com/googleapis/google-cloud-python/commit/098c35c5a8383d1585848e10806f9914b2ef4f97)) - - -### Bug Fixes - -* cast JSON and nested struct columns to string for anywidget rendering (#17189) ([994a22d64856b436d196743d16c3fd1967b20784](https://github.com/googleapis/google-cloud-python/commit/994a22d64856b436d196743d16c3fd1967b20784)) -* Respect display.progress_bar=None in background threads (#16715) ([07dd3315447d2feb6de8d53e0915798da9c04151](https://github.com/googleapis/google-cloud-python/commit/07dd3315447d2feb6de8d53e0915798da9c04151)) - - -### Dependencies - -* bump mistune from 3.1.3 to 3.2.1 in /packages/bigframes (#17202) ([52f21788f76575036624c9163b63620a1bb92a83](https://github.com/googleapis/google-cloud-python/commit/52f21788f76575036624c9163b63620a1bb92a83)) -* bump langsmith from 0.4.10 to 0.8.0 in /packages/bigframes (#17210) ([9dd0c02c585f7fda34d6e2199ab2bc7c0b5a246a](https://github.com/googleapis/google-cloud-python/commit/9dd0c02c585f7fda34d6e2199ab2bc7c0b5a246a)) -* bump gdal from 3.8.4 to 3.13.0 in /packages/bigframes (#17204) ([900007bab07feb7580cb7e8a36a5d4ee4cce14ab](https://github.com/googleapis/google-cloud-python/commit/900007bab07feb7580cb7e8a36a5d4ee4cce14ab)) - -## [2.40.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.39.0...bigframes-v2.40.0) (2026-05-13) - - -### Documentation - -* Add docs to the to_csv methods of dataframe and series (#16570) ([a8fccefd868e3474d3a2cfbabc03364891e05824](https://github.com/googleapis/google-cloud-python/commit/a8fccefd868e3474d3a2cfbabc03364891e05824)) - - -### Features - -* add more params to ai.classify (#16990) ([e9c52b12c02f8b15e43b62e6f3fb7617ac3bdfd9](https://github.com/googleapis/google-cloud-python/commit/e9c52b12c02f8b15e43b62e6f3fb7617ac3bdfd9)) -* add support for `hparam_range` and `hparam_candidates` to `bigframes.bigquery.create_model` (#16640) ([ca47835ce0e381c0833545ca1cf7734c3c34ceb5](https://github.com/googleapis/google-cloud-python/commit/ca47835ce0e381c0833545ca1cf7734c3c34ceb5)) -* update ai.score to match its SQL version (#16919) ([9f42fe1436df61ca0abad77bb4b51ed983a85a48](https://github.com/googleapis/google-cloud-python/commit/9f42fe1436df61ca0abad77bb4b51ed983a85a48)) -* update ai.if_() params to match the SQL version (#16857) ([f3cb4ad04a15a58a931d4feb43b172805209cf58](https://github.com/googleapis/google-cloud-python/commit/f3cb4ad04a15a58a931d4feb43b172805209cf58)) -* Support unstable sort_values, sort_index (#16665) ([bbdeb70fff766dc51bcac32b5312c13ce16764d4](https://github.com/googleapis/google-cloud-python/commit/bbdeb70fff766dc51bcac32b5312c13ce16764d4)) -* Support Expression objects in create_model options (#16606) ([cf12ffd858bdba0a95dba8fd591ed9adcf8c0e8a](https://github.com/googleapis/google-cloud-python/commit/cf12ffd858bdba0a95dba8fd591ed9adcf8c0e8a)) -* implement ai.similarity (#16771) ([d4afa2c835d53983ecd22e2f9835107791cde65f](https://github.com/googleapis/google-cloud-python/commit/d4afa2c835d53983ecd22e2f9835107791cde65f)) -* implement ai.embed (#16759) ([fcb4579b9e273c3ad43ed150f4ef0fbb7daeef2c](https://github.com/googleapis/google-cloud-python/commit/fcb4579b9e273c3ad43ed150f4ef0fbb7daeef2c)) -* Add bigframes.execution_history API to track BigQuery jobs (#16588) ([fa20a740b15accf2b1ae18a9ac20b75f006dbcad](https://github.com/googleapis/google-cloud-python/commit/fa20a740b15accf2b1ae18a9ac20b75f006dbcad)) -* Support loading avro, orc data (#16555) ([6d46cba3777c1b2adf6f1f86f6d3db3ea30c55d2](https://github.com/googleapis/google-cloud-python/commit/6d46cba3777c1b2adf6f1f86f6d3db3ea30c55d2)) -* Add numpy ufunc support to col expressions (#16554) ([2f792abd5d48ec680305e1e4ec9136360e16c9a5](https://github.com/googleapis/google-cloud-python/commit/2f792abd5d48ec680305e1e4ec9136360e16c9a5)) - - -### Bug Fixes - -* avoid `copy` argument warning in `to_pandas` (#16917) ([fe5245b8f20dd94231e72e2572609e029ee137c7](https://github.com/googleapis/google-cloud-python/commit/fe5245b8f20dd94231e72e2572609e029ee137c7)) -* BigFrames respects bq default region (#16933) ([ef9945a5d6296e6bbf00b6ef980462f5a0b91b20](https://github.com/googleapis/google-cloud-python/commit/ef9945a5d6296e6bbf00b6ef980462f5a0b91b20)) -* Fix bugs compiling ambiguous ids and in subqueries (#16617) ([479e44ddb8ba7515797f062064c4ebf2db5d09f2](https://github.com/googleapis/google-cloud-python/commit/479e44ddb8ba7515797f062064c4ebf2db5d09f2)) -* avoid views when querying BigLake tables from SQL cells (#16562) ([fdd3e0de66377d75ec235e4fc071e4ecc33a35c7](https://github.com/googleapis/google-cloud-python/commit/fdd3e0de66377d75ec235e4fc071e4ecc33a35c7)) - -## [2.39.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.38.0...v2.39.0) (2026-03-31) - - -### Documentation - -* Rename Blob column references to ObjectRef column (#2535) ([44e0ffd947e9db66ab612f92de6e31f1085e7968](https://github.com/googleapis/python-bigquery-dataframes/commit/44e0ffd947e9db66ab612f92de6e31f1085e7968)) -* gemini retouch of the index page for seo (#2514) ([2e5311e2242b039da4c8e37b7b48942fa8ed34c2](https://github.com/googleapis/python-bigquery-dataframes/commit/2e5311e2242b039da4c8e37b7b48942fa8ed34c2)) - - -### Features - -* expose DataFrame.bigquery in both pandas and bigframes DataFrames (#2533) ([69fe317612a69aa92f06f0c418c67aa1f9488bd2](https://github.com/googleapis/python-bigquery-dataframes/commit/69fe317612a69aa92f06f0c418c67aa1f9488bd2)) -* support full round-trip persistence for multimodal reference cols (#2511) ([494a0a113b1ba6dcdc9f9b85a4f750d093f5652f](https://github.com/googleapis/python-bigquery-dataframes/commit/494a0a113b1ba6dcdc9f9b85a4f750d093f5652f)) -* add `df.bigquery.ai.forecast` method to pandas dataframe accessor (#2518) ([1126cec9cdfcc1ec1062c60e5affbe1b60223767](https://github.com/googleapis/python-bigquery-dataframes/commit/1126cec9cdfcc1ec1062c60e5affbe1b60223767)) - - -### Bug Fixes - -* handle aggregate operations on empty selections (#2510) ([34fb5daa93726d0d3ff364912a3c1de0fc535fb2](https://github.com/googleapis/python-bigquery-dataframes/commit/34fb5daa93726d0d3ff364912a3c1de0fc535fb2)) -* Localize BigQuery log suppression for gbq.py (#2541) ([af49ca29399aa2c63753d9045fd382e30334d134](https://github.com/googleapis/python-bigquery-dataframes/commit/af49ca29399aa2c63753d9045fd382e30334d134)) -* to_gbq may swap data columns when replace table (#2532) ([17ecc65e1c0397ef349fca4afcf5a77af72aa798](https://github.com/googleapis/python-bigquery-dataframes/commit/17ecc65e1c0397ef349fca4afcf5a77af72aa798)) -* Respect remote function config changes even if logic unchanged (#2512) ([b9524284ad3b457b15598f546bac04c76b3e27b8](https://github.com/googleapis/python-bigquery-dataframes/commit/b9524284ad3b457b15598f546bac04c76b3e27b8)) -* support melting empty DataFrames without crashing (#2509) ([e8c46032154e186042314d97aa813301413d8a13](https://github.com/googleapis/python-bigquery-dataframes/commit/e8c46032154e186042314d97aa813301413d8a13)) - -## [2.38.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.37.0...v2.38.0) (2026-03-16) - - -### Documentation - -* add notebooks to user guide page (#2505) ([5cf37888bc0b4b1b0993dadd1e0fe5ee08341ef4](https://github.com/googleapis/python-bigquery-dataframes/commit/5cf37888bc0b4b1b0993dadd1e0fe5ee08341ef4)) -* Fix typo in ExperimentOptions class docstring (#2498) ([077cb2ebe515fc5e07bcbb5dc663edd28d3eaf00](https://github.com/googleapis/python-bigquery-dataframes/commit/077cb2ebe515fc5e07bcbb5dc663edd28d3eaf00)) - - -### Features - -* add `df.bigquery` pandas accessor (#2513) ([91b6c245521218bb78b543885e1b9424278ce2ab](https://github.com/googleapis/python-bigquery-dataframes/commit/91b6c245521218bb78b543885e1b9424278ce2ab)) -* use EUC for AI IF, CLASSIFY, and SCORE when connection is not provided (#2507) ([fe94910abff28e244dd79e1540a6c2184a12eb44](https://github.com/googleapis/python-bigquery-dataframes/commit/fe94910abff28e244dd79e1540a6c2184a12eb44)) -* Add `bigframes.bigquery.rand()` function (#2501) ([5c43efb745118f506ecc30196da68e9d6f4346dc](https://github.com/googleapis/python-bigquery-dataframes/commit/5c43efb745118f506ecc30196da68e9d6f4346dc)) -* add bigquery.ml.get_insights function (#2493) ([d29a60953ac989bb2c95e6eec3010620ac776a3c](https://github.com/googleapis/python-bigquery-dataframes/commit/d29a60953ac989bb2c95e6eec3010620ac776a3c)) -* Add str, dt accessors to pd.col Expression objects (#2488) ([ce5de57019449ca77d308946df72f04289343b51](https://github.com/googleapis/python-bigquery-dataframes/commit/ce5de57019449ca77d308946df72f04289343b51)) - - -### Bug Fixes - -* handle unsupported types and empty results in describe (#2506) ([2326ad6aec15c20a66756eff093b50be484b3ba8](https://github.com/googleapis/python-bigquery-dataframes/commit/2326ad6aec15c20a66756eff093b50be484b3ba8)) -* no longer automatically use anywidget in the `%%bqsql` magics (#2504) ([43353e2bc9ffbc38b7383c24ecaac80d3b8bab32](https://github.com/googleapis/python-bigquery-dataframes/commit/43353e2bc9ffbc38b7383c24ecaac80d3b8bab32)) - -## [2.37.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.36.0...v2.37.0) (2026-03-03) - - -### Documentation - -* Fix recall_score doc example (#2477) ([a6f499c1e225a962b53621158f9d4a19ca220ccd](https://github.com/googleapis/python-bigquery-dataframes/commit/a6f499c1e225a962b53621158f9d4a19ca220ccd)) -* add code sample and docstring for bpd.options.experiments.sql_compiler (#2474) ([867951bcabcff12e2fce88143b45d929d3237088](https://github.com/googleapis/python-bigquery-dataframes/commit/867951bcabcff12e2fce88143b45d929d3237088)) -* use direct API for image (#2465) ([8a1a82f7a0fd224f2b075c68ab116d1f580d1d82](https://github.com/googleapis/python-bigquery-dataframes/commit/8a1a82f7a0fd224f2b075c68ab116d1f580d1d82)) -* add bigframes default connection warning (#2471) ([f1bbba23667f01d3b8e7c51b18fe64641a4b135f](https://github.com/googleapis/python-bigquery-dataframes/commit/f1bbba23667f01d3b8e7c51b18fe64641a4b135f)) -* Move readme content to new User Guide section (#2464) ([61a948451baeb1caa323e721ad88b31c7cd0b3cb](https://github.com/googleapis/python-bigquery-dataframes/commit/61a948451baeb1caa323e721ad88b31c7cd0b3cb)) -* Skip inherited methods, use autosummary only for big classes (#2470) ([a9512498ef39b9d5260cad2ca0513c701a6d3592](https://github.com/googleapis/python-bigquery-dataframes/commit/a9512498ef39b9d5260cad2ca0513c701a6d3592)) -* Add code examples to configuration docstrings (#2352) ([3c21993e6fca474c32f3c2371c41ef2be146267e](https://github.com/googleapis/python-bigquery-dataframes/commit/3c21993e6fca474c32f3c2371c41ef2be146267e)) - - -### Features - -* Add cloud_function_cpus option to remote_function (#2475) ([4caf74ccaeb9608d91da864bb80eddf1148a1502](https://github.com/googleapis/python-bigquery-dataframes/commit/4caf74ccaeb9608d91da864bb80eddf1148a1502)) -* Support pd.col simple aggregates (#2480) ([cb00daabce49f067be8e16627166dda00d5d8134](https://github.com/googleapis/python-bigquery-dataframes/commit/cb00daabce49f067be8e16627166dda00d5d8134)) -* add display.render_mode to control DataFrame/Series visualization (#2413) ([7813eaa6fa2ae42943b90583e600c95beaf5d75e](https://github.com/googleapis/python-bigquery-dataframes/commit/7813eaa6fa2ae42943b90583e600c95beaf5d75e)) -* add support for Python 3.14 (#2232) ([c25a6d0151380dde74368a35e13deb7a930b494f](https://github.com/googleapis/python-bigquery-dataframes/commit/c25a6d0151380dde74368a35e13deb7a930b494f)) -* Support pd.col expressions with .loc and getitem (#2473) ([ae5c8b322765aef51eed016bfacaff5a7a917a7b](https://github.com/googleapis/python-bigquery-dataframes/commit/ae5c8b322765aef51eed016bfacaff5a7a917a7b)) -* add dt.tz_localize() (#2469) ([f70f93a1227add1627d522d7e55a37f42fc3549e](https://github.com/googleapis/python-bigquery-dataframes/commit/f70f93a1227add1627d522d7e55a37f42fc3549e)) -* Update bigquery.ai.generate_table output_schema to allow Mapping type (#2463) ([f7fd1895e64a133fe63eddeb90f57a42a35c29b2](https://github.com/googleapis/python-bigquery-dataframes/commit/f7fd1895e64a133fe63eddeb90f57a42a35c29b2)) - - -### Bug Fixes - -* upload local data through write API if nested JSONs detected (#2478) ([01dc5a34e09171351575d5cbdc9f301e505e1567](https://github.com/googleapis/python-bigquery-dataframes/commit/01dc5a34e09171351575d5cbdc9f301e505e1567)) -* allow IsInOp with same dtypes regardless nullable (#2466) ([1d81b414acbc964502ca624eae72cdb8c14e1576](https://github.com/googleapis/python-bigquery-dataframes/commit/1d81b414acbc964502ca624eae72cdb8c14e1576)) - -## [2.36.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.35.0...v2.36.0) (2026-02-17) - - -### Documentation - -* update multimodal dataframe notebook to use public APIs (#2456) ([342fa723c4631d371364a87ae0ddd6fa03360a4b](https://github.com/googleapis/python-bigquery-dataframes/commit/342fa723c4631d371364a87ae0ddd6fa03360a4b)) -* use direct API for pdf chunk and pdf extract (#2452) ([543ce52c18269eab2a89886f226d1478dbabf9ba](https://github.com/googleapis/python-bigquery-dataframes/commit/543ce52c18269eab2a89886f226d1478dbabf9ba)) -* fix generate_text and generate_table input docs (#2455) ([078bd32ebd28af0d2cfba6bb874ba79e904183e2](https://github.com/googleapis/python-bigquery-dataframes/commit/078bd32ebd28af0d2cfba6bb874ba79e904183e2)) -* Update multimodal notebook to use public runtime helpers (#2451) ([e36dd8b492fd7ab433fa4cac732b31774c1e428b](https://github.com/googleapis/python-bigquery-dataframes/commit/e36dd8b492fd7ab433fa4cac732b31774c1e428b)) -* use direct API for audio transcription (#2447) ([59cbc5db66fd178ecce03bf4b8b4a504d7ef3e9f](https://github.com/googleapis/python-bigquery-dataframes/commit/59cbc5db66fd178ecce03bf4b8b4a504d7ef3e9f)) -* Add EXIF metadata extraction example to multimodal notebook (#2429) ([84c6f883aef8048e7013a8b3c03a1bde47e94eea](https://github.com/googleapis/python-bigquery-dataframes/commit/84c6f883aef8048e7013a8b3c03a1bde47e94eea)) - - -### Features - -* Initial support for biglake iceberg tables (#2409) ([ae35a9890a2f9903b12e431488362c091118bbdd](https://github.com/googleapis/python-bigquery-dataframes/commit/ae35a9890a2f9903b12e431488362c091118bbdd)) -* add bigquery.ai.generate_table function (#2453) ([b925aa243dad0e42ad126c9397f42be0aad7152d](https://github.com/googleapis/python-bigquery-dataframes/commit/b925aa243dad0e42ad126c9397f42be0aad7152d)) - -## [2.35.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.34.0...v2.35.0) (2026-02-07) - - -### Documentation - -* fix cast method shown on public docs (#2436) ([ad0f33c65ee01409826c381ae0f70aad65bb6a27](https://github.com/googleapis/python-bigquery-dataframes/commit/ad0f33c65ee01409826c381ae0f70aad65bb6a27)) - - -### Features - -* remove redundant "started." messages from progress output (#2440) ([2017cc2f27f0a432af46f60b3286b231caa4a98b](https://github.com/googleapis/python-bigquery-dataframes/commit/2017cc2f27f0a432af46f60b3286b231caa4a98b)) -* Add bigframes.pandas.col with basic operators (#2405) ([12741677c0391efb5d05281fc756445ccbb1387e](https://github.com/googleapis/python-bigquery-dataframes/commit/12741677c0391efb5d05281fc756445ccbb1387e)) -* Disable progress bars in Anywidget mode (#2444) ([4e2689a1c975c4cabaf36b7d0817dcbedc926853](https://github.com/googleapis/python-bigquery-dataframes/commit/4e2689a1c975c4cabaf36b7d0817dcbedc926853)) -* Disable progress bars in Anywidget mode to reduce notebook clutter (#2437) ([853240daf45301ad534c635c8955cb6ce91d23c2](https://github.com/googleapis/python-bigquery-dataframes/commit/853240daf45301ad534c635c8955cb6ce91d23c2)) -* add bigquery.ai.generate_text function (#2433) ([5bd0029a99e7653843de4ac7d57370c9dffeed4d](https://github.com/googleapis/python-bigquery-dataframes/commit/5bd0029a99e7653843de4ac7d57370c9dffeed4d)) -* Add a bigframes cell magic for ipython (#2395) ([e6de52ded6c5091275a936dec36f01a6cf701233](https://github.com/googleapis/python-bigquery-dataframes/commit/e6de52ded6c5091275a936dec36f01a6cf701233)) -* add `bigframes.bigquery.ai.generate_embedding` (#2343) ([e91536c8a5b2d8d896767510ced80c6fd2a68a97](https://github.com/googleapis/python-bigquery-dataframes/commit/e91536c8a5b2d8d896767510ced80c6fd2a68a97)) -* add bigframe.bigquery.load_data function (#2426) ([4b0f13b2fe10fa5b07d3ca3b7cb1ae1cb95030c7](https://github.com/googleapis/python-bigquery-dataframes/commit/4b0f13b2fe10fa5b07d3ca3b7cb1ae1cb95030c7)) - - -### Bug Fixes - -* suppress JSONDtypeWarning in Anywidget mode and clean up progress output (#2441) ([e0d185ad2c0245b17eac315f71152a46c6da41bb](https://github.com/googleapis/python-bigquery-dataframes/commit/e0d185ad2c0245b17eac315f71152a46c6da41bb)) -* exlcude gcsfs 2026.2.0 (#2445) ([311de31e79227408515f087dafbab7edc54ddf1b](https://github.com/googleapis/python-bigquery-dataframes/commit/311de31e79227408515f087dafbab7edc54ddf1b)) -* always display the results in the `%%bqsql` cell magics output (#2439) ([2d973b54550f30429dbd10894f78db7bb0c57345](https://github.com/googleapis/python-bigquery-dataframes/commit/2d973b54550f30429dbd10894f78db7bb0c57345)) - -## [2.34.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.33.0...v2.34.0) (2026-02-02) - - -### Features - -* add `bigframes.pandas.options.experiments.sql_compiler` for switching the backend compiler (#2417) ([7eba6ee03f07938315d99e2aeaf72368c02074cf](https://github.com/googleapis/python-bigquery-dataframes/commit/7eba6ee03f07938315d99e2aeaf72368c02074cf)) -* add bigquery.ml.generate_embedding function (#2422) ([35f3f5e6f8c64b47e6e7214034f96f047785e647](https://github.com/googleapis/python-bigquery-dataframes/commit/35f3f5e6f8c64b47e6e7214034f96f047785e647)) -* add bigquery.create_external_table method (#2415) ([76db2956e505aec4f1055118ac7ca523facc10ff](https://github.com/googleapis/python-bigquery-dataframes/commit/76db2956e505aec4f1055118ac7ca523facc10ff)) -* add deprecation warnings for .blob accessor and read_gbq_object_table (#2408) ([7261a4ea5cdab6b30f5bc333501648c60e70be59](https://github.com/googleapis/python-bigquery-dataframes/commit/7261a4ea5cdab6b30f5bc333501648c60e70be59)) -* add bigquery.ml.generate_text function (#2403) ([5ac681028624de15e31f0c2ae360b47b2dcf1e8d](https://github.com/googleapis/python-bigquery-dataframes/commit/5ac681028624de15e31f0c2ae360b47b2dcf1e8d)) - - -### Bug Fixes - -* broken job url (#2411) ([fcb5bc1761c656e1aec61dbcf96a36d436833b7a](https://github.com/googleapis/python-bigquery-dataframes/commit/fcb5bc1761c656e1aec61dbcf96a36d436833b7a)) - -## [2.33.0](https://github.com/googleapis/python-bigquery-dataframes/compare/v2.32.0...v2.33.0) (2026-01-22) - - -### Features - -* add bigquery.ml.transform function (#2394) ([1f9ee373c1f1d0cd08b80169c3063b862ea46465](https://github.com/googleapis/python-bigquery-dataframes/commit/1f9ee373c1f1d0cd08b80169c3063b862ea46465)) -* Add BigQuery ObjectRef functions to `bigframes.bigquery.obj` (#2380) ([9c3bbc36983dffb265454f27b37450df8c5fbc71](https://github.com/googleapis/python-bigquery-dataframes/commit/9c3bbc36983dffb265454f27b37450df8c5fbc71)) -* Stabilize interactive table height to prevent notebook layout shifts (#2378) ([a634e976c0f44087ca2a65f68cf2775ae6f04024](https://github.com/googleapis/python-bigquery-dataframes/commit/a634e976c0f44087ca2a65f68cf2775ae6f04024)) -* Add max_columns control for anywidget mode (#2374) ([34b5975f6911c5aa5ffc64a2fe6967a9f3d86f78](https://github.com/googleapis/python-bigquery-dataframes/commit/34b5975f6911c5aa5ffc64a2fe6967a9f3d86f78)) -* Add dark mode to anywidget mode (#2365) ([2763b41d4b86939e389f76789f5b2acd44f18169](https://github.com/googleapis/python-bigquery-dataframes/commit/2763b41d4b86939e389f76789f5b2acd44f18169)) -* Configure Biome for Consistent Code Style (#2364) ([81e27b3d81da9b1684eae0b7f0b9abfd7badcc4f](https://github.com/googleapis/python-bigquery-dataframes/commit/81e27b3d81da9b1684eae0b7f0b9abfd7badcc4f)) - - -### Bug Fixes - -* Throw if write api commit op has stream_errors (#2385) ([7abfef0598d476ef233364a01f72d73291983c30](https://github.com/googleapis/python-bigquery-dataframes/commit/7abfef0598d476ef233364a01f72d73291983c30)) -* implement retry logic for cloud function endpoint fetching (#2369) ([0f593c27bfee89fe1bdfc880504f9ab0ac28a24e](https://github.com/googleapis/python-bigquery-dataframes/commit/0f593c27bfee89fe1bdfc880504f9ab0ac28a24e)) - -## [2.32.0](https://github.com/googleapis/google-cloud-python/compare/bigframes-v2.31.0...bigframes-v2.32.0) (2026-01-05) - - -### Documentation - -* generate sitemap.xml for better search indexing (#2351) ([7d2990f1c48c6d74e2af6bee3af87f90189a3d9b](https://github.com/googleapis/google-cloud-python/commit/7d2990f1c48c6d74e2af6bee3af87f90189a3d9b)) -* update supported pandas APIs documentation links (#2330) ([ea71936ce240b2becf21b552d4e41e8ef4418e2d](https://github.com/googleapis/google-cloud-python/commit/ea71936ce240b2becf21b552d4e41e8ef4418e2d)) -* Add time series analysis notebook (#2328) ([369f1c0aff29d197b577ec79e401b107985fe969](https://github.com/googleapis/google-cloud-python/commit/369f1c0aff29d197b577ec79e401b107985fe969)) - - -### Features - -* Enable multi-column sorting in anywidget mode (#2360) ([1feb956e4762e30276e5b380c0633e6ed7881357](https://github.com/googleapis/google-cloud-python/commit/1feb956e4762e30276e5b380c0633e6ed7881357)) -* display series in anywidget mode (#2346) ([7395d418550058c516ad878e13567256f4300a37](https://github.com/googleapis/google-cloud-python/commit/7395d418550058c516ad878e13567256f4300a37)) -* Refactor TableWidget and to_pandas_batches (#2250) ([b8f09015a7c8e6987dc124e6df925d4f6951b1da](https://github.com/googleapis/google-cloud-python/commit/b8f09015a7c8e6987dc124e6df925d4f6951b1da)) -* Auto-plan complex reduction expressions (#2298) ([4d5de14ccdd05b1ac8f50c3fe71c35ab9e5150c1](https://github.com/googleapis/google-cloud-python/commit/4d5de14ccdd05b1ac8f50c3fe71c35ab9e5150c1)) -* Display custom single index column in anywidget mode (#2311) ([f27196260743883ed8131d5fd33a335e311177e4](https://github.com/googleapis/google-cloud-python/commit/f27196260743883ed8131d5fd33a335e311177e4)) -* add fit_predict method to ml unsupervised models (#2320) ([59df7f70a12ef702224ad61e597bd775208dac45](https://github.com/googleapis/google-cloud-python/commit/59df7f70a12ef702224ad61e597bd775208dac45)) - - -### Bug Fixes - -* vendor sqlglot bigquery dialect and remove package dependency (#2354) ([b321d72d5eb005b6e9295541a002540f05f72209](https://github.com/googleapis/google-cloud-python/commit/b321d72d5eb005b6e9295541a002540f05f72209)) -* bigframes.ml fit with eval data in partial mode avoids join on null index (#2355) ([7171d21b8c8d5a2d61081f41fa1109b5c9c4bc5f](https://github.com/googleapis/google-cloud-python/commit/7171d21b8c8d5a2d61081f41fa1109b5c9c4bc5f)) -* Improve strictness of nan vs None usage (#2326) ([481d938fb0b840e17047bc4b57e61af15b976e54](https://github.com/googleapis/google-cloud-python/commit/481d938fb0b840e17047bc4b57e61af15b976e54)) -* Correct DataFrame widget rendering in Colab (#2319) ([7f1d3df3839ec58f52e48df088057fc0df967da9](https://github.com/googleapis/google-cloud-python/commit/7f1d3df3839ec58f52e48df088057fc0df967da9)) -* Fix pd.timedelta handling in polars comipler with polars 1.36 (#2325) ([252644826289d9db7a8548884de880b3a4fccafd](https://github.com/googleapis/google-cloud-python/commit/252644826289d9db7a8548884de880b3a4fccafd)) - -## [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) @@ -988,7 +282,7 @@ * 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)) +* Replace function now can handle bpd.NA value. ([#1786](https://github.com/googleapis/python-bigquery-dataframes/issues/1786)) ([7269512](https://github.com/googleapis/python-bigquery-dataframes/commit/7269512a28eb42029447d5380c764353278a74e1)) ### Documentation diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..039f4368120 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,95 @@ + +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, gender identity and expression, level of +experience, education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, or to ban temporarily or permanently any +contributor for other behaviors that they deem inappropriate, threatening, +offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +This Code of Conduct also applies outside the project spaces when the Project +Steward has a reasonable belief that an individual's behavior may have a +negative impact on the project or its community. + +## Conflict Resolution + +We do not believe that all conflict is bad; healthy debate and disagreement +often yield positive results. However, it is never okay to be disrespectful or +to engage in behavior that violates the project’s code of conduct. + +If you see someone violating the code of conduct, you are encouraged to address +the behavior directly with those involved. Many issues can be resolved quickly +and easily, and this gives people more control over the outcome of their +dispute. If you are unable to resolve the matter for any reason, or if the +behavior is threatening or harassing, report it. We are dedicated to providing +an environment where participants feel welcome and safe. + + +Reports should be directed to *googleapis-stewards@google.com*, the +Project Steward(s) for *Google Cloud Client Libraries*. It is the Project Steward’s duty to +receive and address reported violations of the code of conduct. They will then +work with a committee consisting of representatives from the Open Source +Programs Office and the Google Open Source Strategy team. If for any reason you +are uncomfortable reaching out to the Project Steward, please email +opensource@google.com. + +We will investigate every complaint, but you may not receive a direct response. +We will use our discretion in determining when and how to follow up on reported +incidents, which may range from not taking action to permanent expulsion from +the project and project-sponsored spaces. We will notify the accused of the +report and provide them an opportunity to discuss it before any action is taken. +The identity of the reporter will be omitted from the details of the report +supplied to the accused. In potentially harmful situations, such as ongoing +harassment or threats to anyone's safety, we may take action without notice. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at +https://www.contributor-covenant.org/version/1/4/code-of-conduct.html \ No newline at end of file diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index ba8400eb866..5374e7e3770 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -22,7 +22,7 @@ In order to add a feature: documentation. - The feature must work fully on the following CPython versions: - 3.10, 3.11, 3.12, 3.13 and 3.14 on both UNIX and Windows. + 3.9, 3.10, 3.11, 3.12 and 3.13 on both UNIX and Windows. - The feature must not add unnecessary dependencies (where "unnecessary" is of course subjective, but new dependencies should @@ -42,14 +42,14 @@ You'll have to create a development environment using a Git checkout: - Clone your fork of ``python-bigquery-dataframes`` from your GitHub account to your local computer, substituting your account username and specifying the destination - as ``hack-on-google-cloud-python``. E.g.:: + as ``hack-on-python-bigquery-dataframes``. E.g.:: $ cd ${HOME} - $ git clone git@github.com:USERNAME/google-cloud-python.git hack-on-google-cloud-python - $ cd hack-on-google-cloud-python - # Configure remotes such that you can pull changes from the googleapis/google-cloud-python + $ git clone git@github.com:USERNAME/python-bigquery-dataframes.git hack-on-python-bigquery-dataframes + $ cd hack-on-python-bigquery-dataframes + # Configure remotes such that you can pull changes from the googleapis/python-bigquery-dataframes # repository into your local repository. - $ git remote add upstream git@github.com:googleapis/google-cloud-python.git + $ git remote add upstream git@github.com:googleapis/python-bigquery-dataframes.git # fetch and merge changes from upstream into main $ git fetch upstream $ git merge upstream/main @@ -60,7 +60,7 @@ repo, from which you can submit a pull request. To work on the codebase and run the tests, we recommend using ``nox``, but you can also use a ``virtualenv`` of your own creation. -.. _repo: https://github.com/googleapis/google-cloud-python/tree/main/packages/bigframes +.. _repo: https://github.com/googleapis/python-bigquery-dataframes Using ``nox`` ============= @@ -72,7 +72,7 @@ We use `nox `__ to instrument our tests. - To run a single unit test:: - $ nox -s unit-3.14 -- -k + $ nox -s unit-3.13 -- -k .. note:: @@ -143,12 +143,12 @@ Running System Tests $ nox -s system # Run a single system test - $ nox -s system-3.14 -- -k + $ nox -s system-3.13 -- -k .. note:: - System tests are only configured to run under Python 3.10, 3.12 and 3.14. + System tests are only configured to run under Python 3.9, 3.11, 3.12 and 3.13. For expediency, we do not run them in older versions of Python 3. This alone will not run the tests. You'll need to change some local @@ -232,11 +232,11 @@ configure them just like the System Tests. # Run all tests in a folder $ cd samples/snippets - $ nox -s py-3.10 + $ nox -s py-3.8 # Run a single sample test $ cd samples/snippets - $ nox -s py-3.10 -- -k + $ nox -s py-3.8 -- -k ******************************************** Note About ``README`` as it pertains to PyPI @@ -246,7 +246,7 @@ The `description on PyPI`_ for the project comes directly from the ``README``. Due to the reStructuredText (``rst``) parser used by PyPI, relative links which will work on GitHub (e.g. ``CONTRIBUTING.rst`` instead of -``https://github.com/googleapis/google-cloud-python/blob/main/packages/bigframes/CONTRIBUTING.rst``) +``https://github.com/googleapis/python-bigquery-dataframes/blob/main/CONTRIBUTING.rst``) may cause problems creating links or rendering the description. .. _description on PyPI: https://pypi.org/project/bigframes @@ -258,25 +258,35 @@ Supported Python Versions We support: +- `Python 3.9`_ - `Python 3.10`_ - `Python 3.11`_ - `Python 3.12`_ - `Python 3.13`_ -- `Python 3.14`_ +.. _Python 3.9: https://docs.python.org/3.9/ .. _Python 3.10: https://docs.python.org/3.10/ .. _Python 3.11: https://docs.python.org/3.11/ .. _Python 3.12: https://docs.python.org/3.12/ .. _Python 3.13: https://docs.python.org/3.13/ -.. _Python 3.14: https://docs.python.org/3.14/ Supported versions can be found in our ``noxfile.py`` `config`_. -.. _config: https://github.com/googleapis/google-cloud-python/blob/main/packages/bigframes/noxfile.py +.. _config: https://github.com/googleapis/python-bigquery-dataframes/blob/main/noxfile.py +We also explicitly decided to support Python 3 beginning with version 3.9. +Reasons for this include: +- Encouraging use of newest versions of Python 3 +- Taking the lead of `prominent`_ open-source `projects`_ +- `Unicode literal support`_ which allows for a cleaner codebase that + works in both Python 2 and Python 3 + +.. _prominent: https://docs.djangoproject.com/en/1.9/faq/install/#what-python-version-can-i-use-with-django +.. _projects: http://flask.pocoo.org/docs/0.10/python3/ +.. _Unicode literal support: https://www.python.org/dev/peps/pep-0414/ ********** Versioning diff --git a/GEMINI.md b/GEMINI.md new file mode 100644 index 00000000000..d26a51ebfc3 --- /dev/null +++ b/GEMINI.md @@ -0,0 +1,147 @@ +# 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 + ``` + +- 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). + +## 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 4f29daf576c..c7807337dcc 100644 --- a/LICENSE +++ b/LICENSE @@ -318,29 +318,3 @@ 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.sqlglot module. - -MIT License - -Copyright (c) 2025 Toby Mao - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.rst b/README.rst index a3aef5380bb..36d3c2ca209 100644 --- a/README.rst +++ b/README.rst @@ -1,29 +1,18 @@ BigQuery DataFrames (BigFrames) =============================== - |GA| |pypi| |versions| 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: +and machine learning (ML) API powered by the BigQuery engine. -* `bigframes.pandas `_ - is a pandas API for analytics. Many workloads can be +* `bigframes.pandas` provides 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. +* ``bigframes.ml`` provides a scikit-learn-like API for ML. -BigQuery DataFrames is an `open-source package `_. +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 -.. |pypi| image:: https://img.shields.io/pypi/v/bigframes.svg - :target: https://pypi.org/project/bigframes/ -.. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg - :target: https://pypi.org/project/bigframes/ +**Version 2.0 introduces breaking changes for improved security and performance. See below for details.** Getting started with BigQuery DataFrames ---------------------------------------- @@ -47,8 +36,7 @@ To use BigFrames in your local development environment, 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. + bpd.options.bigquery.project = your_gcp_project_id df = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") print( df.groupby("name") @@ -58,21 +46,54 @@ To use BigFrames in your local development environment, .to_pandas() ) + Documentation ------------- To learn more about BigQuery DataFrames, visit these pages * `Introduction to BigQuery DataFrames (BigFrames) `_ -* `Sample notebooks `_ -* `API reference `_ -* `Source code (GitHub) `_ +* `Sample notebooks `_ +* `API reference `_ +* `Source code (GitHub) `_ + +⚠️ Warning: Breaking Changes in BigQuery DataFrames v2.0 +-------------------------------------------------------- + +Version 2.0 introduces breaking changes for improved security and performance. Key default behaviors have changed, including + +* **Large Results (>10GB):** The default value for ``allow_large_results`` has changed to ``False``. + Methods like ``to_pandas()`` will now fail if the query result's compressed data size exceeds 10GB, + unless large results are explicitly permitted. +* **Remote Function Security:** The library no longer automatically lets the Compute Engine default service + account become the identity of the Cloud Run functions. If that is desired, it has to be indicated by passing + ``cloud_function_service_account="default"``. And network ingress now defaults to ``"internal-only"``. +* **@remote_function Argument Passing:** Arguments other than ``input_types``, ``output_type``, and ``dataset`` + to ``remote_function`` must now be passed using keyword syntax, as positional arguments are no longer supported. +* **@udf Argument Passing:** Arguments ``dataset`` and ``name`` to ``udf`` are now mandatory. +* **Endpoint Connections:** Automatic fallback to locational endpoints in certain regions is removed. +* **LLM Updates (Gemini Integration):** Integrations now default to the ``gemini-2.0-flash-001`` model. + PaLM2 support has been removed; please migrate any existing PaLM2 usage to Gemini. **Note:** The current default + model will be removed in Version 3.0. + +**Important:** If you are not ready to adapt to these changes, please pin your dependency to a version less than 2.0 +(e.g., ``bigframes==1.42.0``) to avoid disruption. + +To learn about these changes and how to migrate to version 2.0, see the +`updated introduction guide `_. + +.. |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 +.. |pypi| image:: https://img.shields.io/pypi/v/bigframes.svg + :target: https://pypi.org/project/bigframes/ +.. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg + :target: https://pypi.org/project/bigframes/ License ------- BigQuery DataFrames is distributed with the `Apache-2.0 license -`_. +`_. It also contains code derived from the following third-party packages: @@ -81,10 +102,9 @@ It also contains code derived from the following third-party packages: * `Python `_ * `scikit-learn `_ * `XGBoost `_ -* `SQLGlot `_ For details, see the `third_party -`_ +`_ directory. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..8b58ae9c01a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,7 @@ +# Security Policy + +To report a security issue, please use [g.co/vulnz](https://g.co/vulnz). + +The Google Security Team will respond within 5 working days of your report on g.co/vulnz. + +We use g.co/vulnz for our intake, and do coordination and disclosure here using GitHub Security Advisory to privately discuss and fix the issue. diff --git a/bigframes/__init__.py b/bigframes/__init__.py index 533726343a5..240608ebc2d 100644 --- a/bigframes/__init__.py +++ b/bigframes/__init__.py @@ -14,64 +14,19 @@ """BigQuery DataFrames provides a DataFrame API scaled by the BigQuery engine.""" -import warnings - -# Suppress Python version support warnings from google-cloud libraries. -# These are particularly noisy in Colab which still uses Python 3.10. -warnings.filterwarnings( - "ignore", - category=FutureWarning, - message=".*Google will stop supporting.*Python.*", -) - -# import configuration and types. -# This ensures that when the deeper 'core' modules ask for 'dtypes','options', et. al., -# they are already defined and available. -import bigframes.dtypes # noqa: E402 # isort: skip -import bigframes._config # noqa: E402 # isort: skip -from bigframes._config import option_context, options # noqa: E402 # isort: skip - -import bigframes.enums as enums # noqa: E402 -import bigframes.exceptions as exceptions # noqa: E402 - -# We import operations early to resolve a circular dependency between -# bigframes.core.expression and bigframes.operations. -# This ensures the 'Expression' base class is defined before 'Aggregation' -# subclasses attempt to inherit from it. -import bigframes.operations # noqa: E402 # isort: skip - -# Register pandas extensions -import bigframes.extensions.pandas.dataframe_accessor # noqa: F401, E402 -import bigframes.extensions.pandas.series_accessor # noqa: F401, E402 -from bigframes._config.bigquery_options import BigQueryOptions # noqa: E402 -from bigframes.core.global_session import ( # noqa: E402 - close_session, - execution_history, - get_global_session, -) -from bigframes.session import Session, connect # noqa: E402 -from bigframes.version import __version__ # noqa: E402 - -_MAGIC_NAMES = ["bqsql"] - - -def load_ipython_extension(ipython): - """Called by IPython when this module is loaded as an IPython extension.""" - # Requires IPython to be installed for import to succeed - from bigframes._magics import _cell_magic - - for magic_name in _MAGIC_NAMES: - ipython.register_magic_function( - _cell_magic, magic_kind="cell", magic_name=magic_name - ) - +from bigframes._config import option_context, options +from bigframes._config.bigquery_options import BigQueryOptions +from bigframes.core.global_session import close_session, get_global_session +import bigframes.enums as enums +import bigframes.exceptions as exceptions +from bigframes.session import connect, Session +from bigframes.version import __version__ __all__ = [ "options", "BigQueryOptions", "get_global_session", "close_session", - "execution_history", "enums", "exceptions", "connect", diff --git a/bigframes/_config/__init__.py b/bigframes/_config/__init__.py index cbe369ad58c..52b47e3e9ad 100644 --- a/bigframes/_config/__init__.py +++ b/bigframes/_config/__init__.py @@ -17,24 +17,175 @@ DataFrames from this package. """ -import bigframes._config.global_options as global_options -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 Options, option_context -from bigframes._config.sampling_options import SamplingOptions +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.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() +"""Global options for default session.""" + +option_context = pandas_config.option_context -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 deleted file mode 100644 index f1c069b5310..00000000000 --- a/bigframes/_config/auth.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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 os -import threading -from typing import Optional - -import google.auth.credentials -import google.auth.transport.requests -import pydata_google_auth - -import bigframes._config.bigquery_options as bigquery_options - -_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 - - -_GOOGLE_CLOUD_PROJECT = "GOOGLE_CLOUD_PROJECT" - - -def resolve_credentials_and_project( - options: bigquery_options.BigQueryOptions, -) -> tuple[google.auth.credentials.Credentials, str]: - project = options.project - credentials = options.credentials - if project is None: - project = os.getenv(_GOOGLE_CLOUD_PROJECT) - - if credentials is None: - credentials, cred_project = _get_default_credentials_with_project() - # This might conflict with explicit project, which will be ignored, credentials project - # only used if nothing else specified - if project is None: - project = cred_project - - if project is None: - raise ValueError( - "Project must be set to initialize BigQuery client. " - "Try setting `bigframes.options.bigquery.project` first." - ) - return credentials, project - - -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 6c5c424240d..648b69dea7f 100644 --- a/bigframes/_config/bigquery_options.py +++ b/bigframes/_config/bigquery_options.py @@ -16,8 +16,8 @@ from __future__ import annotations -import warnings from typing import Literal, Optional, Sequence, Tuple +import warnings import google.auth.credentials import requests.adapters @@ -127,11 +127,6 @@ def application_name(self) -> Optional[str]: The recommended format is ``"application-name/major.minor.patch_version"`` or ``"(gpn:PartnerName;)"`` for official Google partners. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.application_name = "my-app/1.0.0" # doctest: +SKIP - Returns: None or str: Application name as a string if exists; otherwise None. @@ -150,13 +145,6 @@ def application_name(self, value: Optional[str]): def credentials(self) -> Optional[google.auth.credentials.Credentials]: """The OAuth2 credentials to use for this client. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import google.auth - >>> credentials, project = google.auth.default() # doctest: +SKIP - >>> bpd.options.bigquery.credentials = credentials # doctest: +SKIP - Returns: None or google.auth.credentials.Credentials: google.auth.credentials.Credentials if exists; otherwise None. @@ -175,11 +163,6 @@ def location(self) -> Optional[str]: For more information, see https://cloud.google.com/bigquery/docs/locations BigQuery locations. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.location = "US" # doctest: +SKIP - Returns: None or str: Default location as a string; otherwise None. @@ -196,11 +179,6 @@ def location(self, value: Optional[str]): def project(self) -> Optional[str]: """Google Cloud project ID to use for billing and as the default project. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.project = "my-project" # doctest: +SKIP - Returns: None or str: Google Cloud project ID as a string; otherwise None. @@ -228,11 +206,6 @@ def bq_connection(self) -> Optional[str]: If this option isn't provided, or project or location aren't provided, session will use its default project/location/connection_id as default connection. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.bq_connection = "my-project.us.my-connection" # doctest: +SKIP - Returns: None or str: Name of the BigQuery connection as a string; otherwise None. @@ -255,11 +228,6 @@ def skip_bq_connection_check(self) -> bool: necessary permissions set up to support BigQuery DataFrames operations, then a runtime error will be reported. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.skip_bq_connection_check = True # doctest: +SKIP - Returns: bool: A boolean value, where True indicates a BigQuery connection is @@ -332,12 +300,6 @@ def use_regional_endpoints(self) -> bool: does not promise any guarantee on the request remaining within the location during transit. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.location = "europe-west3" # doctest: +SKIP - >>> bpd.options.bigquery.use_regional_endpoints = True # doctest: +SKIP - Returns: bool: A boolean value, where True indicates that regional endpoints @@ -377,11 +339,6 @@ def kms_key_name(self) -> Optional[str]: For more information, see https://cloud.google.com/bigquery/docs/customer-managed-encryption#assign_role Assign the Encrypter/Decrypter. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.kms_key_name = "projects/my-project/locations/us/keyRings/my-ring/cryptoKeys/my-key" # doctest: +SKIP - Returns: None or str: Name of the customer managed encryption key as a string; otherwise None. @@ -399,11 +356,6 @@ def kms_key_name(self, value: str): def ordering_mode(self) -> Literal["strict", "partial"]: """Controls whether total row order is always maintained for DataFrame/Series. - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.ordering_mode = "partial" # doctest: +SKIP - Returns: Literal: A literal string value of either strict or partial ordering mode. @@ -480,14 +432,7 @@ def requests_transport_adapters( @property def enable_polars_execution(self) -> bool: - """If True, will use polars to execute some simple query plans locally. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.bigquery.enable_polars_execution = True # doctest: +SKIP - - """ + """If True, will use polars to execute some simple query plans locally.""" return self._enable_polars_execution @enable_polars_execution.setter diff --git a/bigframes/_config/compute_options.py b/bigframes/_config/compute_options.py index 2ef1e5b7213..97cd6e99af3 100644 --- a/bigframes/_config/compute_options.py +++ b/bigframes/_config/compute_options.py @@ -28,145 +28,93 @@ class ComputeOptions: >>> import bigframes.pandas as bpd >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") - >>> bpd.options.compute.maximum_bytes_billed = 500 # doctest: +SKIP - >>> df.to_pandas() # this should fail # doctest: +SKIP + >>> bpd.options.compute.maximum_bytes_billed = 500 + >>> # df.to_pandas() # this should fail 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 # doctest: +SKIP + >>> bpd.options.compute.maximum_bytes_billed = None # reset option To add multiple extra labels to a query configuration, use the `assign_extra_query_labels` method with keyword arguments: - >>> bpd.options.compute.assign_extra_query_labels(test1=1, test2="abc") # doctest: +SKIP - >>> bpd.options.compute.extra_query_labels # doctest: +SKIP + >>> bpd.options.compute.assign_extra_query_labels(test1=1, test2="abc") + >>> bpd.options.compute.extra_query_labels {'test1': 1, 'test2': 'abc'} Alternatively, you can add labels individually by directly accessing the `extra_query_labels` dictionary: - >>> bpd.options.compute.extra_query_labels["test3"] = False # doctest: +SKIP - >>> bpd.options.compute.extra_query_labels # doctest: +SKIP + >>> bpd.options.compute.extra_query_labels["test3"] = False + >>> bpd.options.compute.extra_query_labels {'test1': 1, 'test2': 'abc', 'test3': False} To remove a label from the configuration, use the `del` keyword on the desired label key: - >>> del bpd.options.compute.extra_query_labels["test1"] # doctest: +SKIP - >>> bpd.options.compute.extra_query_labels # doctest: +SKIP + >>> del bpd.options.compute.extra_query_labels["test1"] + >>> bpd.options.compute.extra_query_labels {'test2': 'abc', 'test3': False} - """ - ai_ops_confirmation_threshold: Optional[int] = 0 - """ - Guards against unexpected 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.ai_ops_confirmation_threshold = 100 # doctest: +SKIP - - Returns: - Optional[int]: Number of rows. + Attributes: + ai_ops_confirmation_threshold (int | None): + Guards against unexpected 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. + + ai_ops_threshold_autofail (bool): + 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. + + allow_large_results (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. + + enable_multi_query_execution (bool | None): + 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] | None): + Stores additional custom labels for query configuration. + + maximum_bytes_billed (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. + + maximum_result_rows (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. + + semantic_ops_confirmation_threshold (int | None): + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use AI operators instead + + semantic_ops_threshold_autofail (bool): + .. deprecated:: 1.42.0 + Semantic operators are deprecated. Please use AI operators instead """ + ai_ops_confirmation_threshold: Optional[int] = 0 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.ai_ops_threshold_autofail = True # doctest: +SKIP - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.allow_large_results = True # doctest: +SKIP - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.enable_multi_query_execution = True # doctest: +SKIP - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.maximum_bytes_billed = 1000 # doctest: +SKIP - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.compute.maximum_result_rows = 1000 # doctest: +SKIP - - Returns: - int | None: Number of rows, if set. - """ + semantic_ops_confirmation_threshold: Optional[int] = 0 + semantic_ops_threshold_autofail = False def assign_extra_query_labels(self, **kwargs: Any) -> None: """ diff --git a/bigframes/_config/display_options.py b/bigframes/_config/display_options.py index 34c5c77d57d..360292dd801 100644 --- a/bigframes/_config/display_options.py +++ b/bigframes/_config/display_options.py @@ -15,23 +15,43 @@ """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 -DisplayOptions = vendored_pandas_config.DisplayOptions + +@dataclasses.dataclass +class DisplayOptions: + __doc__ = vendored_pandas_config.display_options_doc + + # Options borrowed from pandas. + max_columns: int = 20 + max_rows: int = 10 + precision: int = 6 + + # Options unique to BigQuery DataFrames. + progress_bar: Optional[str] = "auto" + repr_mode: Literal["head", "deferred", "anywidget"] = "head" + + max_info_columns: int = 100 + max_info_rows: Optional[int] = 200000 + memory_usage: bool = True + + blob_display: bool = True + blob_display_width: Optional[int] = None + blob_display_height: Optional[int] = None @contextlib.contextmanager -def pandas_repr(display_options: vendored_pandas_config.DisplayOptions): +def pandas_repr(display_options: 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", diff --git a/bigframes/_config/experiment_options.py b/bigframes/_config/experiment_options.py index 8f70c8952f6..024de392c06 100644 --- a/bigframes/_config/experiment_options.py +++ b/bigframes/_config/experiment_options.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from typing import Optional import warnings -from typing import Literal, Optional import bigframes import bigframes.exceptions as bfe @@ -21,37 +21,39 @@ class ExperimentOptions: """ - Encapsulates the configuration for experiments + Encapsulates the configration for experiments """ def __init__(self): - self._sql_compiler: Literal["legacy", "stable", "experimental"] = "stable" - self._enable_python_transpiler: bool = False + self._semantic_operators: bool = False + self._ai_operators: bool = False @property - def sql_compiler(self) -> Literal["legacy", "stable", "experimental"]: - """Set to 'experimental' to try out the latest in compilation experiments.. + def semantic_operators(self) -> bool: + return self._semantic_operators - **Examples:** + @semantic_operators.setter + def semantic_operators(self, value: bool): + if value is True: + msg = bfe.format_message( + "Semantic operators are deprecated, and will be removed in the future" + ) + warnings.warn(msg, category=FutureWarning) + self._semantic_operators = value - >>> import bigframes.pandas as bpd - >>> bpd.options.experiments.sql_compiler = 'experimental' # doctest: +SKIP - """ - return self._sql_compiler + @property + def ai_operators(self) -> bool: + return self._ai_operators - @sql_compiler.setter - def sql_compiler(self, value: Literal["legacy", "stable", "experimental"]): - if value not in ["legacy", "stable", "experimental"]: - raise ValueError( - "sql_compiler must be one of 'legacy', 'stable', or 'experimental'" - ) - if value == "experimental": + @ai_operators.setter + def ai_operators(self, value: bool): + if value is True: msg = bfe.format_message( - "The experimental SQL compiler is still under experiments, and is subject " + "AI operators are still under experiments, and are subject " "to change in the future." ) - warnings.warn(msg, category=FutureWarning) - self._sql_compiler = value + warnings.warn(msg, category=bfe.PreviewWarning) + self._ai_operators = value @property def blob(self) -> bool: @@ -124,17 +126,3 @@ def blob_display_height(self, value: Optional[int]): warnings.warn(msg, category=bfe.ApiDeprecationWarning) bigframes.options.display.blob_display_height = value - - @property - def enable_python_transpiler(self) -> bool: - return self._enable_python_transpiler - - @enable_python_transpiler.setter - def enable_python_transpiler(self, value: bool): - if value: - msg = bfe.format_message( - "Python transpiler is an unstable, experimental feature, and not yet fully " - "validated, use at your own risk." - ) - warnings.warn(msg, category=bfe.PythonTranspilerPreviewWarning) - self._enable_python_transpiler = value diff --git a/bigframes/_config/global_options.py b/bigframes/_config/global_options.py deleted file mode 100644 index 8f742608292..00000000000 --- a/bigframes/_config/global_options.py +++ /dev/null @@ -1,186 +0,0 @@ -# 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 -import threading -from dataclasses import dataclass, field -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 9746e01f31d..ddb2a497133 100644 --- a/bigframes/_config/sampling_options.py +++ b/bigframes/_config/sampling_options.py @@ -19,66 +19,18 @@ import dataclasses from typing import Literal, Optional +import bigframes_vendored.pandas.core.config_init as vendored_pandas_config + @dataclasses.dataclass class SamplingOptions: - """ - Encapsulates the configuration for data sampling. - """ + __doc__ = vendored_pandas_config.sampling_options_doc max_download_size: Optional[int] = 500 - """ - Download size threshold in MB. Default 500. - - If value set to None, the download size won't be checked. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.sampling.max_download_size = 1000 # doctest: +SKIP - """ - + # Enable downsampling 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.sampling.enable_downsampling = True # doctest: +SKIP - """ - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.sampling.sampling_method = "head" # doctest: +SKIP - """ - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.sampling.random_state = 42 # doctest: +SKIP - """ 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/_magics.py b/bigframes/_magics.py deleted file mode 100644 index f6b69f35ff5..00000000000 --- a/bigframes/_magics.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 IPython.core import magic_arguments # type: ignore -from IPython.core.getipython import get_ipython -from IPython.display import display - -import bigframes.pandas - - -@magic_arguments.magic_arguments() -@magic_arguments.argument( - "destination_var", - nargs="?", - help=("If provided, save the output to this variable instead of displaying it."), -) -@magic_arguments.argument( - "--dry_run", - action="store_true", - default=False, - help=( - "Sets query to be a dry run to estimate costs. " - "Defaults to executing the query instead of dry run if this argument is not used." - "Does not work with engine 'bigframes'. " - ), -) -def _cell_magic(line, cell): - ipython = get_ipython() - if ipython is None: - raise RuntimeError("BigQuery magic must be run in an IPython environment.") - - args = magic_arguments.parse_argstring(_cell_magic, line) - if not cell: - print("Query is missing.") - return - pyformat_args = ipython.user_ns - dataframe = bigframes.pandas._read_gbq_colab( - cell, pyformat_args=pyformat_args, dry_run=args.dry_run - ) - if args.destination_var: - ipython.push({args.destination_var: dataframe}) - - display(dataframe) diff --git a/bigframes/_tools/docs.py b/bigframes/_tools/docs.py deleted file mode 100644 index 9ecfd61b3c9..00000000000 --- a/bigframes/_tools/docs.py +++ /dev/null @@ -1,53 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 inherit_docs(source_class): - """ - A class decorator that copies docstrings from source_class to the - decorated class for any methods or attributes that match names. - """ - - def decorator(target_class): - if not target_class.__doc__ and source_class.__doc__: - target_class.__doc__ = source_class.__doc__ - - for name, source_item in vars(source_class).items(): - if name in vars(target_class): - target_item = getattr(target_class, name) - - if hasattr(target_item, "__doc__") and not target_item.__doc__: - if hasattr(source_item, "__doc__") and source_item.__doc__: - try: - target_item.__doc__ = source_item.__doc__ - except AttributeError: - pass - - underlying = None - if isinstance(target_item, property): - underlying = target_item.fget - elif hasattr(target_item, "__func__"): - underlying = target_item.__func__ - elif hasattr(target_item, "func"): - underlying = getattr(target_item, "func", None) - - if underlying is not None: - try: - underlying.__doc__ = source_item.__doc__ - except AttributeError: - pass - - return target_class - - return decorator diff --git a/bigframes/bigquery/__init__.py b/bigframes/bigquery/__init__.py index ade7535c32b..dbaea570057 100644 --- a/bigframes/bigquery/__init__.py +++ b/bigframes/bigquery/__init__.py @@ -12,44 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -Access BigQuery-specific operations and namespaces within BigQuery DataFrames. +"""This module integrates BigQuery built-in functions for use with DataFrame objects, +such as array functions: +https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions. """ -This module provides specialized functions and sub-modules that expose BigQuery's -advanced capabilities to DataFrames and Series. It acts as a bridge between the -pandas-compatible API and the full power of BigQuery SQL. - -Key sub-modules include: - -* :mod:`bigframes.bigquery.ai`: Generative and predictive AI functions (Gemini, BQML). -* :mod:`bigframes.bigquery.ml`: Direct access to BigQuery ML model operations. -* :mod:`bigframes.bigquery.obj`: Support for BigQuery object tables. - -This module also provides direct access to optimized BigQuery functions for: - -* **JSON Processing:** High-performance functions like ``json_extract``, ``json_value``, - and ``parse_json`` for handling semi-structured data. -* **Geospatial Analysis:** Comprehensive geographic functions such as ``st_area``, - ``st_distance``, and ``st_centroid`` (``ST_`` prefixed functions). -* **Array Operations:** Tools for working with BigQuery arrays, including ``array_agg`` - and ``array_length``. -* **Vector Search:** Integration with BigQuery's vector search and indexing - capabilities for high-dimensional data. -* **Custom SQL:** The ``sql_scalar`` function allows embedding raw SQL snippets for - advanced operations not yet directly mapped in the API. - -By using these functions, you can leverage BigQuery's high-performance engine for -domain-specific tasks while maintaining a Python-centric development experience. - -For the full list of BigQuery standard SQL functions, see: -https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-reference -""" - -import sys - -from bigframes.bigquery import aead, ai, ml, obj from bigframes.bigquery._operations.approx_agg import approx_top_count -from bigframes.bigquery._operations.array import array_agg +from bigframes.bigquery._operations.array import ( + array_agg, + array_length, + array_to_string, +) from bigframes.bigquery._operations.datetime import ( unix_micros, unix_millis, @@ -65,224 +37,29 @@ st_intersection, st_isclosed, st_length, - st_regionstats, - st_simplify, ) -from bigframes.bigquery._operations.io import load_data 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.mathematical import ( - hparam_candidates, - hparam_range, - rand, ) 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.bigquery._operations.table import create_external_table -from bigframes.core.logging import log_adapter -from bigframes.operations.googlesql.global_namespace.aead_encryption import ( - deterministic_decrypt_bytes, - deterministic_decrypt_string, - deterministic_encrypt, -) -from bigframes.operations.googlesql.global_namespace.array import ( - array_concat, - array_first, - array_first_n, - array_includes, - array_includes_all, - array_includes_any, - array_is_distinct, - array_last, - array_length, - array_reverse, - array_slice, - array_to_string, - flatten, - generate_array, -) -from bigframes.operations.googlesql.global_namespace.bit import ( - bit_count, -) -from bigframes.operations.googlesql.global_namespace.conversion import ( - bool_, - double, - float64, - int64, - parse_bignumeric, - parse_numeric, - string, -) -from bigframes.operations.googlesql.global_namespace.date import ( - current_date, - date, - date_add, - date_diff, - date_from_unix_date, - date_sub, - date_trunc, - extract, - format_date, - generate_date_array, - last_day, - parse_date, - unix_date, -) - -_functions = [ - # approximate aggregate ops - approx_top_count, - # array ops - array_agg, - array_concat, - array_first, - array_first_n, - array_includes, - array_includes_all, - array_includes_any, - array_is_distinct, - array_last, - array_length, - array_reverse, - array_slice, - array_to_string, - flatten, - generate_array, - # bit ops - bit_count, - # conversion ops - bool_, - double, - float64, - int64, - parse_bignumeric, - parse_numeric, - string, - # date ops - current_date, - date, - date_add, - date_diff, - date_from_unix_date, - date_sub, - date_trunc, - extract, - format_date, - generate_date_array, - last_day, - parse_date, - unix_date, - # 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, - # deterministic encryption ops - deterministic_decrypt_bytes, - deterministic_decrypt_string, - deterministic_encrypt, - # 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, - # mathematical ops - hparam_candidates, - hparam_range, - rand, - # search ops - create_vector_index, - vector_search, - # sql ops - sql_scalar, - # struct ops - struct, - # table ops - create_external_table, - # io ops - load_data, -] - -_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_agg", - "array_concat", - "array_first", - "array_first_n", - "array_includes", - "array_includes_all", - "array_includes_any", - "array_is_distinct", - "array_last", "array_length", - "array_reverse", - "array_slice", "array_to_string", - "flatten", - "generate_array", - # bit ops - "bit_count", - # conversion ops - "bool_", - "double", - "float64", - "int64", - "parse_bignumeric", - "parse_numeric", - "string", - # date ops - "current_date", - "date", - "date_add", - "date_diff", - "date_from_unix_date", - "date_sub", - "date_trunc", - "extract", - "format_date", - "generate_date_array", - "last_day", - "parse_date", - "unix_date", # datetime ops "unix_micros", "unix_millis", @@ -297,29 +74,16 @@ "st_intersection", "st_isclosed", "st_length", - "st_regionstats", - "st_simplify", - # deterministic encryption ops - "deterministic_decrypt_bytes", - "deterministic_decrypt_string", - "deterministic_encrypt", # json ops "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", - # mathematical ops - "hparam_candidates", - "hparam_range", - "rand", # search ops "create_vector_index", "vector_search", @@ -327,13 +91,4 @@ "sql_scalar", # struct ops "struct", - # table ops - "create_external_table", - # io ops - "load_data", - # Modules / SQL namespaces - "aead", - "ai", - "ml", - "obj", ] diff --git a/bigframes/bigquery/_operations/ai.py b/bigframes/bigquery/_operations/ai.py deleted file mode 100644 index 40d5556de40..00000000000 --- a/bigframes/bigquery/_operations/ai.py +++ /dev/null @@ -1,1254 +0,0 @@ -# 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, Dict, Iterable, List, Literal, Mapping, Optional, Tuple, Union - -import pandas as pd - -from bigframes import dataframe, dtypes, series, session -from bigframes import pandas as bpd -from bigframes.bigquery._operations import obj as bq_obj -from bigframes.bigquery._operations import utils as bq_utils -from bigframes.core import convert -from bigframes.core.compile.sqlglot import sql as sg_sql -from bigframes.core.logging import log_adapter -from bigframes.ml import base as ml_base -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"] | None = None, - 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")) # doctest: +ELLIPSIS - 0 {'result': 'Tokyo', 'full_response': '{"cand... - 1 {'result': 'Ottawa', '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 - 1 Ottawa - 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] - - 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=_upper_optional(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"] | None = None, - 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 - - 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=_upper_optional(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"] | None = None, - 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(["Ostrich", "Rabbit", "Spider"]) - >>> bbq.ai.generate_int(("How many legs does a ", animal, " have?")) # doctest: +ELLIPSIS - 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 - - 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=_upper_optional(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"] | None = None, - 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(["Ostrich", "Rabbit", "Spider"]) - >>> bbq.ai.generate_double(("How many legs does a ", animal, " have?")) # doctest: +ELLIPSIS - 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 - - 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=_upper_optional(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_embedding( - model: Union[ml_base.BaseEstimator, str, pd.Series], - data: Union[dataframe.DataFrame, series.Series, pd.DataFrame, pd.Series], - *, - output_dimensionality: Optional[int] = None, - task_type: Optional[str] = None, - start_second: Optional[float] = None, - end_second: Optional[float] = None, - interval_seconds: Optional[float] = None, - trial_id: Optional[int] = None, -) -> dataframe.DataFrame: - """ - Creates embeddings that describe an entity—for example, a piece of text or an image. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> df = bpd.DataFrame({"content": ["apple", "bear", "pear"]}) - >>> bbq.ai.generate_embedding( # doctest: +SKIP - ... "project.dataset.model_name", - ... df - ... ) - - Args: - model (ml_base.BaseEstimator or str): - The model to use for text embedding. - data (bigframes.pandas.DataFrame or bigframes.pandas.Series): - The data to generate embeddings for. If a Series is provided, it is - treated as the 'content' column. If a DataFrame is provided, it - must contain a 'content' column, or you must rename the column you - wish to embed to 'content'. - output_dimensionality (int, optional): - An INT64 value that specifies the number of dimensions to use when - generating embeddings. For example, if you specify 256 AS - output_dimensionality, then the embedding output column contains a - 256-dimensional embedding for each input value. To find the - supported range of output dimensions, read about the available - `Google text embedding models `_. - task_type (str, optional): - A STRING literal that specifies the intended downstream application to - help the model produce better quality embeddings. For a list of - supported task types and how to choose which one to use, see `Choose an - embeddings task type `_. - start_second (float, optional): - The second in the video at which to start the embedding. The default value is 0. - end_second (float, optional): - The second in the video at which to end the embedding. The default value is 120. - interval_seconds (float, optional): - The interval to use when creating embeddings. The default value is 16. - 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: - A new DataFrame with the generated embeddings. See the `SQL - reference for AI.GENERATE_EMBEDDING - `_ - for details. - """ - data = _to_dataframe(data, series_rename="content") - model_name, session = bq_utils.get_model_name_and_session(model, data) - table_sql = bq_utils.to_sql(data) - - struct_fields: Dict[str, Any] = {} - if output_dimensionality is not None: - struct_fields["OUTPUT_DIMENSIONALITY"] = output_dimensionality - if task_type is not None: - struct_fields["TASK_TYPE"] = task_type - if start_second is not None: - struct_fields["START_SECOND"] = start_second - if end_second is not None: - struct_fields["END_SECOND"] = end_second - if interval_seconds is not None: - struct_fields["INTERVAL_SECONDS"] = interval_seconds - if trial_id is not None: - struct_fields["TRIAL_ID"] = trial_id - - # Construct the TVF query - query = f""" - SELECT * - FROM AI.GENERATE_EMBEDDING( - MODEL `{model_name}`, - ({table_sql}), - {sg_sql.to_sql(sg_sql.literal(struct_fields))} - ) - """ - - if session is None: - return bpd.read_gbq_query(query) - else: - return session.read_gbq_query(query) - - -@log_adapter.method_logger(custom_base_name="bigquery_ai") -def generate_text( - model: Union[ml_base.BaseEstimator, str, pd.Series], - data: Union[dataframe.DataFrame, series.Series, pd.DataFrame, pd.Series], - *, - temperature: Optional[float] = None, - max_output_tokens: Optional[int] = None, - top_k: Optional[int] = None, - top_p: Optional[float] = None, - stop_sequences: Optional[List[str]] = None, - ground_with_google_search: Optional[bool] = None, - request_type: Optional[str] = None, -) -> dataframe.DataFrame: - """ - Generates text using a BigQuery ML model. - - See the `BigQuery ML GENERATE_TEXT function syntax - `_ - for additional reference. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> df = bpd.DataFrame({"prompt": ["write a poem about apples"]}) - >>> bbq.ai.generate_text( # doctest: +SKIP - ... "project.dataset.model_name", - ... df - ... ) - - Args: - model (ml_base.BaseEstimator or str): - The model to use for text generation. - data (bigframes.pandas.DataFrame or bigframes.pandas.Series): - The data to generate text for. If a Series is provided, it is - treated as the 'prompt' column. If a DataFrame is provided, it - must contain a 'prompt' column, or you must rename the column you - wish to generate text to 'prompt'. - temperature (float, optional): - A FLOAT64 value that is used for sampling promiscuity. The value - must be in the range ``[0.0, 1.0]``. A lower temperature works well - for prompts that expect a more deterministic and less open-ended - or creative response, while a higher temperature can lead to more - diverse or creative results. A temperature of ``0`` is - deterministic, meaning that the highest probability response is - always selected. - max_output_tokens (int, optional): - An INT64 value that sets the maximum number of tokens in the - generated text. - top_k (int, optional): - An INT64 value that changes how the model selects tokens for - output. A ``top_k`` of ``1`` means the next selected token is the - most probable among all tokens in the model's vocabulary. A - ``top_k`` of ``3`` means that the next token is selected from - among the three most probable tokens by using temperature. The - default value is ``40``. - top_p (float, optional): - A FLOAT64 value that changes how the model selects tokens for - output. Tokens are selected from most probable to least probable - 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 by using temperature. The - default value is ``0.95``. - stop_sequences (List[str], optional): - An ARRAY value that contains the stop sequences for the model. - ground_with_google_search (bool, optional): - A BOOL value that determines whether to ground the model with Google Search. - request_type (str, optional): - A STRING value that contains the request type for the model. - - Returns: - bigframes.pandas.DataFrame: - The generated text. - """ - data = _to_dataframe(data, series_rename="prompt") - model_name, session = bq_utils.get_model_name_and_session(model, data) - table_sql = bq_utils.to_sql(data) - - struct_fields: Dict[ - str, - Union[str, int, float, bool, Mapping[str, str], List[str], Mapping[str, Any]], - ] = {} - if temperature is not None: - struct_fields["TEMPERATURE"] = temperature - if max_output_tokens is not None: - struct_fields["MAX_OUTPUT_TOKENS"] = max_output_tokens - if top_k is not None: - struct_fields["TOP_K"] = top_k - if top_p is not None: - struct_fields["TOP_P"] = top_p - if stop_sequences is not None: - struct_fields["STEP_SEQUENCES"] = stop_sequences - if ground_with_google_search is not None: - struct_fields["GROUND_WITH_GOOGLE_SEARCH"] = ground_with_google_search - if request_type is not None: - struct_fields["REQUEST_TYPE"] = request_type - - query = f""" - SELECT * - FROM AI.GENERATE_TEXT( - MODEL `{model_name}`, - ({table_sql}), - {sg_sql.to_sql(sg_sql.literal(struct_fields))} - ) - """ - - if session is None: - return bpd.read_gbq_query(query) - else: - return session.read_gbq_query(query) - - -@log_adapter.method_logger(custom_base_name="bigquery_ai") -def generate_table( - model: Union[ml_base.BaseEstimator, str, pd.Series], - data: Union[dataframe.DataFrame, series.Series, pd.DataFrame, pd.Series], - *, - output_schema: Union[str, Mapping[str, str]], - temperature: Optional[float] = None, - top_p: Optional[float] = None, - max_output_tokens: Optional[int] = None, - stop_sequences: Optional[List[str]] = None, - request_type: Optional[str] = None, -) -> dataframe.DataFrame: - """ - Generates a table using a BigQuery ML model. - - See the `AI.GENERATE_TABLE function syntax - `_ - for additional reference. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> # The user is responsible for constructing a DataFrame that contains - >>> # the necessary columns for the model's prompt. For example, a - >>> # DataFrame with a 'prompt' column for text classification. - >>> df = bpd.DataFrame({'prompt': ["some text to classify"]}) - >>> result = bbq.ai.generate_table( # doctest: +SKIP - ... "project.dataset.model_name", - ... data=df, - ... output_schema="category STRING" - ... ) - - Args: - model (ml_base.BaseEstimator or str): - The model to use for table generation. - data (bigframes.pandas.DataFrame or bigframes.pandas.Series): - The data to generate table for. If a Series is provided, it is - treated as the 'prompt' column. If a DataFrame is provided, it - must contain a 'prompt' column, or you must rename the column you - wish to generate table to 'prompt'. - output_schema (str | Mapping[str, str]): - A string defining the output schema (e.g., "col1 STRING, col2 INT64"), - or 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``. - temperature (float, optional): - A FLOAT64 value that is used for sampling promiscuity. The value - must be in the range ``[0.0, 1.0]``. - top_p (float, optional): - A FLOAT64 value that changes how the model selects tokens for - output. - max_output_tokens (int, optional): - An INT64 value that sets the maximum number of tokens in the - generated table. - stop_sequences (List[str], optional): - An ARRAY value that contains the stop sequences for the model. - request_type (str, optional): - A STRING value that contains the request type for the model. - - Returns: - bigframes.pandas.DataFrame: - The generated table. - """ - data = _to_dataframe(data, series_rename="prompt") - model_name, session = bq_utils.get_model_name_and_session(model, data) - table_sql = bq_utils.to_sql(data) - - if isinstance(output_schema, Mapping): - 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) - else: - output_schema_str = output_schema - - struct_fields_bq: Dict[str, Any] = {"output_schema": output_schema_str} - if temperature is not None: - struct_fields_bq["temperature"] = temperature - if top_p is not None: - struct_fields_bq["top_p"] = top_p - if max_output_tokens is not None: - struct_fields_bq["max_output_tokens"] = max_output_tokens - if stop_sequences is not None: - struct_fields_bq["stop_sequences"] = stop_sequences - if request_type is not None: - struct_fields_bq["request_type"] = request_type - - struct_sql = sg_sql.to_sql(sg_sql.literal(struct_fields_bq)) - query = f""" - SELECT * - FROM AI.GENERATE_TABLE( - MODEL `{model_name}`, - ({table_sql}), - {struct_sql} - ) - """ - - if session is None: - return bpd.read_gbq_query(query) - else: - return session.read_gbq_query(query) - - -@log_adapter.method_logger(custom_base_name="bigquery_ai") -def embed( - content: str | series.Series | pd.Series, - *, - endpoint: str | None = None, - model: str | None = None, - task_type: ( - Literal[ - "retrieval_query", - "retrieval_document", - "semantic_similarity", - "classification", - "clustering", - "question_answering", - "fact_verification", - "code_retrieval_query", - ] - | None - ) = None, - title: str | None = None, - model_params: Mapping[Any, Any] | None = None, - connection_id: str | None = None, -) -> series.Series: - """ - Creates embeddings from text or image data in BigQuery. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> bbq.ai.embed("dog", endpoint="text-embedding-005") # doctest: +ELLIPSIS - 0 {'result': array([ 1.78243860e-03, -1.10658340... - dtype: struct, status: string>[pyarrow] - - >>> s = bpd.Series(['dog']) - >>> bbq.ai.embed(s, endpoint='text-embedding-005') # doctest: +ELLIPSIS - 0 {'result': array([ 1.78243860e-03, -1.10658340... - dtype: struct, status: string>[pyarrow] - - Args: - content (str | Series): - A string literal or a Series (either BigFrames series or pandas Series) that provides the text or image to embed. - endpoint (str, optional): - A string value that specifies a supported Vertex AI embedding model endpoint to use. - The endpoint value that you specify must include the model version, for example, - ``"text-embedding-005"``. If you specify this parameter, you can't specify the - ``model`` parameter. - model (str, optional): - A string value that specifies a built-in embedding model. The only supported value is - ``"embeddinggemma-300m"``. If you specify this parameter, you can't specify the ``endpoint``, - ``title``, ``model_params``, or ``connection_id`` parameters. - task_type (str, optional): - A string literal that specifies the intended downstream application to help the model - produce better quality embeddings. Accepts ``"retrieval_query"``, ``"retrieval_document"``, - ``"semantic_similarity"``, ``"classification"``, ``"clustering"``, ``"question_answering"``, - ``"fact_verification"``, ``"code_retrieval_query"``. - title (str, optional): - A string value that specifies the document title, which the model uses to improve - embedding quality. You can only use this parameter if you specify ``"retrieval_document"`` - for the ``task_type`` value. - model_params (Mapping[Any, Any], optional): - A JSON literal that provides additional parameters to the model. For example, - ``{"outputDimensionality": 768}`` lets you specify the number of dimensions to use when - generating embeddings. - connection_id (str, optional): - A STRING value specifying the connection to use to communicate with the model, in the - format ``PROJECT_ID.LOCATION.CONNECTION_ID``. For example, ``myproject.us.myconnection``. - If not provided, the query uses your end-user credential. - - Returns: - bigframes.series.Series: A new struct Series with the result data. The struct contains these fields: - * "result": an ARRAY value containing the generated embeddings. - * "status": a STRING value that contains the API response status for the corresponding row. This value is empty if the operation was successful. - """ - - operator = ai_ops.AIEmbed( - endpoint=endpoint, - model=model, - task_type=_upper_optional(task_type), - title=title, - model_params=json.dumps(model_params) if model_params else None, - connection_id=connection_id, - ) - - if isinstance(content, str): - return series.Series([content])._apply_unary_op(operator) - elif isinstance(content, pd.Series): - return series.Series(content)._apply_unary_op(operator) - elif isinstance(content, series.Series): - return content._apply_unary_op(operator) - else: - raise ValueError(f"Unsupported 'content' parameter type: {type(content)}") - - -@log_adapter.method_logger(custom_base_name="bigquery_ai") -def if_( - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None, - max_error_ratio: float | 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 - - 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 dynamically chooses a model based on your query to have the - best cost to quality tradeoff for the task. - optimization_mode (Literal["minimize_cost", "maximize_quality"]): - Specifies the optimization strategy to use. Supported values are: - * "minimize_cost" (default): uses a local, distilled model to process the majority of rows, reducing latency and cost. - * "maximize_quality": always uses the remote LLM for inference. - max_error_ratio (float): - A float value between 0.0 and 1.0 that contains the maximum acceptable ratio of row-level inference failures to - rows processed on this function. If this value is exceeded, then the query fails. The default value is 1.0. - This argument isn't supported when ``optimization_mode`` is set to "minimize_cost". - - 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=connection_id, - endpoint=endpoint, - optimization_mode=_upper_optional(optimization_mode), - max_error_ratio=max_error_ratio, - ) - - 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], - *, - examples: list[tuple[str, str]] - | list[tuple[str, list[str] | tuple[str, ...]]] - | None = None, - connection_id: str | None = None, - endpoint: str | None = None, - output_mode: Literal["single", "multi"] | None = None, - optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None, - max_error_ratio: float | 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] - - 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. - examples (list[tuple[str, str]] | list[tuple[str, list[str] | tuple[str, ...]]], optional): - An array that contains representative examples of input strings and the output category - that you expect. If ``output_mode`` is ``multi``, each example output must be a list or tuple of strings. - You can provide examples to help the model understand your intended threshold for a condition with nuanced - or subjective logic. We recommend providing at most 5 examples. - 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): - A STRING value that specifies the Vertex AI endpoint to use for the model. 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. - output_mode (Literal["single", "multi"], optional): - A STRING value that indicates whether a single input can be classified into multiple categories. - Supported values are ``single`` and ``multi``. - optimization_mode (Literal["minimize_cost", "maximize_quality"], optional): - A STRING value that specifies the optimization strategy to use. Supported values are ``minimize_cost`` - and ``maximize_quality``. - max_error_ratio (float, optional): - A value between ``0.0`` and ``1.0`` that contains the maximum acceptable ratio of row-level - inference failures to rows processed on this function. The default value is 1.0. - This argument isn't supported when ``optimization_mode`` is set to ``minimize_cost``. - - Returns: - bigframes.series.Series: A new series of strings (or a series of arrays of strings if ``output_mode`` is specified). - """ - - prompt_context, series_list = _separate_context_and_series(input) - assert len(series_list) > 0 - - if examples is not None: - example_tuples: Any = tuple( - (ex[0], tuple(ex[1]) if isinstance(ex[1], (list, tuple)) else ex[1]) - for ex in examples - ) - else: - example_tuples = None - - operator = ai_ops.AIClassify( - prompt_context=tuple(prompt_context), - categories=tuple(categories), - examples=example_tuples, - connection_id=connection_id, - endpoint=endpoint, - output_mode=output_mode, - optimization_mode=_upper_optional(optimization_mode), - max_error_ratio=max_error_ratio, - ) - - 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, - endpoint: str | None = None, - max_error_ratio: float | 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")) - 0 2.0 - 1 1.0 - 2 3.0 - dtype: Float64 - - 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 dynamically chooses a model - based on your query to have the best cost to quality tradeoff for the task. - max_error_ratio (float, optional): - A value between ``0.0`` and ``1.0`` that contains the maximum acceptable ratio of row-level inference failures to - rows processed on this function. If this value is exceeded, then the query fails. - - 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=connection_id, - endpoint=endpoint, - max_error_ratio=max_error_ratio, - ) - - return series_list[0]._apply_nary_op(operator, series_list[1:]) - - -@log_adapter.method_logger(custom_base_name="bigquery_ai") -def similarity( - content1: str | series.Series | pd.Series, - content2: str | series.Series | pd.Series, - *, - endpoint: str | None = None, - model: str | None = None, - model_params: Mapping[Any, Any] | None = None, - connection_id: str | None = None, -) -> series.Series: - """ - Returns a FLOAT64 value that represents the cosine similarity between the two inputs. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> df = bpd.DataFrame({'word': ['happy', 'sad']}) - >>> bbq.ai.similarity(df['word'], 'glad', endpoint='text-embedding-005') - 0 0.916601 - 1 0.660579 - Name: word, dtype: Float64 - - Args: - content1 (str | Series): - A string or series that provides the first value to compare. Both a BigFrames Series or a pandas Series are allowed. - content2 (str | Series): - A string or series that provides the second value to compare. Both a BigFrames Series or a pandas Series are allowed. - endpoint (str, optional): - Specifies the Vertex AI endpoint to use for the text embedding model. - If you specify the model name, such as ``'text-embedding-005'``, rather than a URL, then BigQuery ML automatically identifies the model and uses the model's full endpoint. - model (str, optional): - Specifies a built-in text embedding model. The only supported value is the embeddinggemma-300m model. - If you specify this parameter, you can't specify the ``endpoint``, ``model_params``, or ``connection_id`` parameters. - model_params (Mapping[Any, Any], optional): - Provides additional parameters to the model. You can use any of the parameters object fields. - One of these fields, ``outputDimensionality``, lets you specify the number of dimensions to use when generating embeddings. - connection_id (str, optional): - Specifies the connection to use to communicate with the model. For example, ``myproject.us.myconnection``. - - Returns: - bigframes.series.Series: A new series of FLOAT64 values representing the cosine similarity. - """ - - operator = ai_ops.AISimilarity( - endpoint=endpoint, - model=model, - model_params=json.dumps(model_params) if model_params else None, - connection_id=connection_id, - ) - - # Find a unifying session for the subsequent operations. - bf_session = None - if isinstance(content1, series.Series): - bf_session = content1._session - elif isinstance(content2, series.Series): - bf_session = content2._session - - if isinstance(content1, str) and isinstance(content2, str): - content1 = series.Series([content1], session=bf_session) - return content1._apply_binary_op(content2, operator) - elif isinstance(content1, str): - # content2 must be a series - content2 = convert.to_bf_series( - content2, default_index=None, session=bf_session - ) - return content2._apply_binary_op(content1, operator) - else: - # content1 must be a series. - content1 = convert.to_bf_series( - content1, default_index=None, session=bf_session - ) - return content1._apply_binary_op(content2, operator) - - -@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, - output_historical_time_series: bool = False, - 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. - - **Examples:** - - Forecast using a pandas DataFrame: - - >>> import pandas as pd - >>> import bigframes.pandas as bpd - >>> df = pd.DataFrame({"value": [1, 2, 3], "time": pd.to_datetime(["2020-01-01", "2020-01-02", "2020-01-03"])}) - >>> bpd.options.display.progress_bar = None - >>> forecasted_pandas_df = df.bigquery.ai.forecast(data_col="value", timestamp_col="time", horizon=2) - >>> type(forecasted_pandas_df) # doctest: +ELLIPSIS - - - Forecast using a BigFrames DataFrame: - - >>> bf_df = bpd.DataFrame({"value": [1, 2, 3], "time": pd.to_datetime(["2020-01-01", "2020-01-02", "2020-01-03"])}) - >>> forecasted_bf_df = bf_df.bigquery.ai.forecast(data_col="value", timestamp_col="time", horizon=2) - >>> type(forecasted_bf_df) - - - 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). - output_historical_time_series (bool, default False): - A BOOL value that determines whether the input data is returned - along with the forecasted data. Set this argument to TRUE to return - input data. The default value is FALSE. - - Returning the input data along with the forecasted data lets you - compare the historical value of the data column with the forecasted - value of the data column, or chart the change in the data column - values over time. - 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, - "output_historical_time_series": output_historical_time_series, - "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, pd.Series)): - raise ValueError(f"Unsupported prompt type: {type(prompt)}") - - if isinstance(prompt, str): - return [None], [series.Series([prompt])] - - if isinstance(prompt, pd.Series): - return [None], [bpd.read_pandas(prompt)] - - if isinstance(prompt, series.Series): - if prompt.dtype == dtypes.OBJ_REF_DTYPE: - # Multi-model support - return [None], [bq_obj.get_access_url(prompt, mode="R")] - 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 multimodal - return bq_obj.get_access_url(result, mode="R") - return result - - -def _to_dataframe( - data: Union[dataframe.DataFrame, series.Series, pd.DataFrame, pd.Series], - series_rename: str, -) -> dataframe.DataFrame: - if isinstance(data, (pd.DataFrame, pd.Series)): - data = bpd.read_pandas(data) - - if isinstance(data, series.Series): - data = data.copy() - data.name = series_rename - return data.to_frame() - elif isinstance(data, dataframe.DataFrame): - return data - - raise ValueError(f"Unsupported data type: {type(data)}") - - -def _upper_optional(value: str | None) -> str | None: - if value is None: - return None - return value.upper() diff --git a/bigframes/bigquery/_operations/approx_agg.py b/bigframes/bigquery/_operations/approx_agg.py index 73b6fdbb73b..696f8f5a66f 100644 --- a/bigframes/bigquery/_operations/approx_agg.py +++ b/bigframes/bigquery/_operations/approx_agg.py @@ -40,6 +40,7 @@ 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 0a3c5d66217..4af14161274 100644 --- a/bigframes/bigquery/_operations/array.py +++ b/bigframes/bigquery/_operations/array.py @@ -17,6 +17,7 @@ https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions """ + from __future__ import annotations import typing @@ -24,6 +25,7 @@ import bigframes_vendored.constants as constants import bigframes.core.groupby as groupby +import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops import bigframes.series as series @@ -31,6 +33,41 @@ import bigframes.dataframe as dataframe +def array_length(series: series.Series) -> series.Series: + """Compute the length of each array element in the Series. + + **Examples:** + + >>> 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) + 0 4 + 1 0 + 2 2 + dtype: Int64 + + You can also apply this function directly to Series. + + >>> s.apply(bbq.array_length, by_row=False) + 0 4 + 1 0 + 2 2 + dtype: Int64 + + Args: + series (bigframes.series.Series): A Series with array columns. + + Returns: + bigframes.series.Series: A Series of integer values indicating + the length of each element in the Series. + + """ + return series._apply_unary_op(ops.len_op) + + def array_agg( obj: groupby.SeriesGroupBy | groupby.DataFrameGroupBy, ) -> series.Series | dataframe.DataFrame: @@ -41,6 +78,8 @@ 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: @@ -80,3 +119,33 @@ def array_agg( raise ValueError( f"Unsupported type {type(obj)} to apply `array_agg` function. {constants.FEEDBACK_LINK}" ) + + +def array_to_string(series: series.Series, delimiter: str) -> series.Series: + """Converts array elements within a Series into delimited strings. + + **Examples:** + + >>> 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=", ") + 0 H, i, ! + 1 Hello, World + 2 + 3 + 4 Hi + dtype: string + + Args: + series (bigframes.series.Series): A Series containing arrays. + delimiter (str): The string used to separate array elements. + + Returns: + bigframes.series.Series: A Series containing delimited strings. + + """ + return series._apply_unary_op(ops.ArrayToStringOp(delimiter=delimiter)) diff --git a/bigframes/bigquery/_operations/datetime.py b/bigframes/bigquery/_operations/datetime.py index 99467beb066..f8767336dda 100644 --- a/bigframes/bigquery/_operations/datetime.py +++ b/bigframes/bigquery/_operations/datetime.py @@ -21,8 +21,10 @@ 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) @@ -46,8 +48,10 @@ 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) @@ -71,8 +75,10 @@ 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 e9ea711c969..9a92a8960d5 100644 --- a/bigframes/bigquery/_operations/geo.py +++ b/bigframes/bigquery/_operations/geo.py @@ -14,15 +14,13 @@ from __future__ import annotations -import json -from typing import Mapping, Optional, Union +from typing import Union import shapely # type: ignore -import bigframes.dataframe +from bigframes import operations as ops import bigframes.geopandas import bigframes.series -from bigframes import operations as ops """ Search functions defined from @@ -55,6 +53,7 @@ def st_area( >>> 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( ... [ @@ -99,7 +98,7 @@ def st_area( bigframes.pandas.Series: Series of float representing the areas. """ - series = series._apply_nary_op(ops.googlesql.ST_AREA, []) + series = series._apply_unary_op(ops.geo_area_op) series.name = None return series @@ -126,6 +125,7 @@ def st_buffer( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Point + >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -195,6 +195,7 @@ def st_centroid( >>> 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( ... [ @@ -223,7 +224,7 @@ def st_centroid( bigframes.pandas.Series: A series of geography objects representing the centroids. """ - series = series._apply_nary_op(ops.googlesql.ST_CENTROID, []) + series = series._apply_unary_op(ops.geo_st_centroid_op) series.name = None return series @@ -249,6 +250,7 @@ def st_convexhull( >>> 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( ... [ @@ -310,6 +312,7 @@ def st_difference( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point + >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row: @@ -404,6 +407,7 @@ def st_distance( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point + >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. @@ -485,6 +489,7 @@ def st_intersection( >>> import bigframes.bigquery as bbq >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point + >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. @@ -578,6 +583,7 @@ def st_isclosed( >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Point, LineString, Polygon + >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -644,6 +650,7 @@ def st_length( >>> import bigframes.bigquery as bbq >>> from shapely.geometry import Polygon, LineString, Point, GeometryCollection + >>> bpd.options.display.progress_bar = None >>> series = bigframes.geopandas.GeoSeries( ... [ @@ -677,80 +684,3 @@ def st_length( 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_nary_op(ops.googlesql.ST_SIMPLIFY, [tolerance_meters]) diff --git a/bigframes/bigquery/_operations/io.py b/bigframes/bigquery/_operations/io.py deleted file mode 100644 index bf9eae95660..00000000000 --- a/bigframes/bigquery/_operations/io.py +++ /dev/null @@ -1,95 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Mapping, Optional, Union - -import pandas as pd - -import bigframes.core.compile.sqlglot.sql as sql -import bigframes.core.logging.log_adapter as log_adapter -import bigframes.session -from bigframes.bigquery._operations.table import _get_table_metadata - - -@log_adapter.method_logger(custom_base_name="bigquery_io") -def load_data( - table_name: str, - *, - write_disposition: str = "INTO", - columns: Optional[Mapping[str, str]] = None, - partition_by: Optional[list[str]] = None, - cluster_by: Optional[list[str]] = None, - table_options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, - from_files_options: Mapping[str, Union[str, int, float, bool, list]], - with_partition_columns: Optional[Mapping[str, str]] = None, - connection_name: Optional[str] = None, - session: Optional[bigframes.session.Session] = None, -) -> pd.Series: - """ - Loads data into a BigQuery table. - See the `BigQuery LOAD DATA DDL syntax - `_ - for additional reference. - Args: - table_name (str): - The name of the table in BigQuery. - write_disposition (str, default "INTO"): - Whether to replace the table if it already exists ("OVERWRITE") or append to it ("INTO"). - columns (Mapping[str, str], optional): - The table's schema. - partition_by (list[str], optional): - A list of partition expressions to partition the table by. See https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/load-statements#partition_expression. - cluster_by (list[str], optional): - A list of columns to cluster the table by. - table_options (Mapping[str, Union[str, int, float, bool, list]], optional): - The table options. - from_files_options (Mapping[str, Union[str, int, float, bool, list]]): - The options for loading data from files. - with_partition_columns (Mapping[str, str], optional): - The table's partition columns. - connection_name (str, optional): - The connection to use for the table. - 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 table metadata. Reference - the `BigQuery Table REST API reference - `_ - for available fields. - """ - import bigframes.pandas as bpd - - load_data_expr = sql.load_data( - table_name=table_name, - write_disposition=write_disposition, - columns=columns, - partition_by=partition_by, - cluster_by=cluster_by, - table_options=table_options, - from_files_options=from_files_options, - with_partition_columns=with_partition_columns, - connection_name=connection_name, - ) - sql_text = sql.to_sql(load_data_expr) - - if session is None: - bpd.read_gbq_query(sql_text) - session = bpd.get_global_session() - else: - session.read_gbq_query(sql_text) - - return _get_table_metadata(bqclient=session.bqclient, table_name=table_name) diff --git a/bigframes/bigquery/_operations/json.py b/bigframes/bigquery/_operations/json.py index 8afb234b719..7ad7855dbae 100644 --- a/bigframes/bigquery/_operations/json.py +++ b/bigframes/bigquery/_operations/json.py @@ -18,10 +18,11 @@ https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions """ + from __future__ import annotations +from typing import Any, cast, Optional, Sequence, Tuple, Union import warnings -from typing import Any, Optional, Sequence, Tuple, Union, cast import bigframes.core.utils as utils import bigframes.dtypes @@ -48,6 +49,8 @@ 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")]) @@ -98,6 +101,7 @@ def json_extract( >>> 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") @@ -137,6 +141,7 @@ def json_extract_array( >>> 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) @@ -199,6 +204,7 @@ def json_extract_string_array( >>> 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) @@ -266,6 +272,7 @@ def json_query( >>> 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_query(s, json_path="$.class") @@ -296,6 +303,7 @@ def json_query_array( >>> 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_query_array(s) @@ -347,6 +355,7 @@ def json_value( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['{"name": "Jakob", "age": "6"}', '{"name": "Jakob", "age": []}']) >>> bbq.json_value(s, json_path="$.age") @@ -383,6 +392,7 @@ def json_value_array( >>> 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_value_array(s) @@ -420,101 +430,6 @@ def json_value_array( 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, @@ -529,6 +444,7 @@ 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 diff --git a/bigframes/bigquery/_operations/mathematical.py b/bigframes/bigquery/_operations/mathematical.py deleted file mode 100644 index 5e6a299f83f..00000000000 --- a/bigframes/bigquery/_operations/mathematical.py +++ /dev/null @@ -1,121 +0,0 @@ -# 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 Sequence - -import bigframes.core.col -import bigframes.core.expression -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.operations import googlesql - - -def rand() -> bigframes.core.col.Expression: - """ - Generates a pseudo-random value of type FLOAT64 in the range of [0, 1), - inclusive of 0 and exclusive of 1. - - .. warning:: - This method introduces non-determinism to the expression. Reading the - same column twice may result in different results. The value might - change. Do not use this value or any value derived from it as a join - key. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> df = bpd.DataFrame({"a": [1, 2, 3]}) - >>> df['random'] = bbq.rand() - >>> # Resulting column 'random' will contain random floats between 0 and 1. - - Returns: - bigframes.pandas.api.typing.Expression: - An expression that can be used in - :func:`~bigframes.pandas.DataFrame.assign` and other methods. See - :func:`bigframes.pandas.col`. - """ - return bigframes.core.col.Expression( - bigframes.core.expression.OpExpression(googlesql.RAND, ()) - ) - - -def hparam_range(min: float, max: float) -> bigframes.core.col.Expression: - """ - Defines the minimum and maximum bounds of the search space of continuous - values for a hyperparameter. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> # Specify a range of values for a hyperparameter. - >>> learn_rate = bbq.hparam_range(0.0001, 1.0) - - Args: - min (float or int): - The minimum bound of the search space. - max (float or int): - The maximum bound of the search space. - - Returns: - bigframes.pandas.api.typing.Expression: - An expression that can be used in model options. - """ - min_expr = bigframes.core.expression.const(min) - max_expr = bigframes.core.expression.const(max) - - op = ops.SqlScalarOp( - _output_type=dtypes.FLOAT_DTYPE, - sql_template="HPARAM_RANGE({0}, {1})", - is_deterministic=True, - ) - return bigframes.core.col.Expression( - bigframes.core.expression.OpExpression(op, (min_expr, max_expr)) - ) - - -def hparam_candidates( - candidates: Sequence[float | str], -) -> bigframes.core.col.Expression: - """ - Specifies the set of discrete values for the hyperparameter. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - >>> # Specify a set of values for a hyperparameter. - >>> optimizer = bbq.hparam_candidates(['ADAGRAD', 'SGD', 'FTRL']) - - Args: - candidates (Sequence[float | str]): - The set of discrete values for the hyperparameter. - - Returns: - bigframes.pandas.api.typing.Expression: - An expression that can be used in model options. - """ - candidates_expr = bigframes.core.expression.const(tuple(candidates)) - - op = ops.SqlScalarOp( - _output_type=dtypes.STRING_DTYPE, - sql_template="HPARAM_CANDIDATES({0})", - is_deterministic=True, - ) - return bigframes.core.col.Expression( - bigframes.core.expression.OpExpression(op, (candidates_expr,)) - ) diff --git a/bigframes/bigquery/_operations/ml.py b/bigframes/bigquery/_operations/ml.py deleted file mode 100644 index c6ef1f8bb7a..00000000000 --- a/bigframes/bigquery/_operations/ml.py +++ /dev/null @@ -1,576 +0,0 @@ -# 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 List, Mapping, Optional, Union - -import bigframes_vendored.constants -import google.cloud.bigquery -import pandas as pd - -import bigframes.core.col as col -import bigframes.core.logging.log_adapter as log_adapter -import bigframes.core.sql.ml -import bigframes.dataframe as dataframe -import bigframes.ml.base -import bigframes.session -from bigframes.bigquery._operations import utils - - -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, "col.Expression"]] - ] = 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, bigframes.core.col.Expression]], 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 = ( - utils.to_sql(training_data) if training_data is not None else None - ) - custom_holiday_sql = ( - utils.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 = utils.get_model_name_and_session(model, input_) - table_sql = utils.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 = utils.get_model_name_and_session(model, input_) - table_sql = utils.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 = utils.get_model_name_and_session(model, input_) - table_sql = utils.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 = utils.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) - - -@log_adapter.method_logger(custom_base_name="bigquery_ml") -def transform( - model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], - input_: Union[pd.DataFrame, dataframe.DataFrame, str], -) -> dataframe.DataFrame: - """ - Transforms input data using a BigQuery ML model. - - See the `BigQuery ML TRANSFORM function syntax - `_ - for additional reference. - - Args: - model (bigframes.ml.base.BaseEstimator or str): - The model to use for transformation. - input_ (Union[bigframes.pandas.DataFrame, str]): - The DataFrame or query to use for transformation. - - Returns: - bigframes.pandas.DataFrame: - The transformed data. - """ - import bigframes.pandas as bpd - - model_name, session = utils.get_model_name_and_session(model, input_) - table_sql = utils.to_sql(input_) - - sql = bigframes.core.sql.ml.transform( - model_name=model_name, - table=table_sql, - ) - - 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 generate_text( - model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], - input_: Union[pd.DataFrame, dataframe.DataFrame, str], - *, - temperature: Optional[float] = None, - max_output_tokens: Optional[int] = None, - top_k: Optional[int] = None, - top_p: Optional[float] = None, - flatten_json_output: Optional[bool] = None, - stop_sequences: Optional[List[str]] = None, - ground_with_google_search: Optional[bool] = None, - request_type: Optional[str] = None, -) -> dataframe.DataFrame: - """ - Generates text using a BigQuery ML model. - - See the `BigQuery ML GENERATE_TEXT function syntax - `_ - for additional reference. - - Args: - model (bigframes.ml.base.BaseEstimator or str): - The model to use for text generation. - input_ (Union[bigframes.pandas.DataFrame, str]): - The DataFrame or query to use for text generation. - temperature (float, optional): - A FLOAT64 value that is used for sampling promiscuity. The value - must be in the range ``[0.0, 1.0]``. A lower temperature works well - for prompts that expect a more deterministic and less open-ended - or creative response, while a higher temperature can lead to more - diverse or creative results. A temperature of ``0`` is - deterministic, meaning that the highest probability response is - always selected. - max_output_tokens (int, optional): - An INT64 value that sets the maximum number of tokens in the - generated text. - top_k (int, optional): - An INT64 value that changes how the model selects tokens for - output. A ``top_k`` of ``1`` means the next selected token is the - most probable among all tokens in the model's vocabulary. A - ``top_k`` of ``3`` means that the next token is selected from - among the three most probable tokens by using temperature. The - default value is ``40``. - top_p (float, optional): - A FLOAT64 value that changes how the model selects tokens for - output. Tokens are selected from most probable to least probable - 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 by using temperature. The - default value is ``0.95``. - flatten_json_output (bool, optional): - A BOOL value that determines the content of the generated JSON column. - stop_sequences (List[str], optional): - An ARRAY value that contains the stop sequences for the model. - ground_with_google_search (bool, optional): - A BOOL value that determines whether to ground the model with Google Search. - request_type (str, optional): - A STRING value that contains the request type for the model. - - Returns: - bigframes.pandas.DataFrame: - The generated text. - """ - import bigframes.pandas as bpd - - model_name, session = utils.get_model_name_and_session(model, input_) - table_sql = utils.to_sql(input_) - - sql = bigframes.core.sql.ml.generate_text( - model_name=model_name, - table=table_sql, - temperature=temperature, - max_output_tokens=max_output_tokens, - top_k=top_k, - top_p=top_p, - flatten_json_output=flatten_json_output, - stop_sequences=stop_sequences, - ground_with_google_search=ground_with_google_search, - request_type=request_type, - ) - - 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 get_insights( - model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], -) -> dataframe.DataFrame: - """ - Gets insights from a BigQuery ML model. - - See the `BigQuery ML GET_INSIGHTS function syntax - `_ - for additional reference. - - Args: - model (bigframes.ml.base.BaseEstimator, str, or pd.Series): - The model to get insights from. - - Returns: - bigframes.pandas.DataFrame: - The insights. - """ - import bigframes.pandas as bpd - - model_name, session = utils.get_model_name_and_session(model) - - sql = bigframes.core.sql.ml.get_insights( - model_name=model_name, - ) - - 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 generate_embedding( - model: Union[bigframes.ml.base.BaseEstimator, str, pd.Series], - input_: Union[pd.DataFrame, dataframe.DataFrame, str], - *, - flatten_json_output: Optional[bool] = None, - task_type: Optional[str] = None, - output_dimensionality: Optional[int] = None, -) -> dataframe.DataFrame: - """ - Generates text embedding using a BigQuery ML model. - - See the `BigQuery ML GENERATE_EMBEDDING function syntax - `_ - for additional reference. - - Args: - model (bigframes.ml.base.BaseEstimator or str): - The model to use for text embedding. - input_ (Union[bigframes.pandas.DataFrame, str]): - The DataFrame or query to use for text embedding. - flatten_json_output (bool, optional): - A BOOL value that determines the content of the generated JSON column. - task_type (str, optional): - A STRING value that specifies the intended downstream application task. - Supported values are: - - `RETRIEVAL_QUERY` - - `RETRIEVAL_DOCUMENT` - - `SEMANTIC_SIMILARITY` - - `CLASSIFICATION` - - `CLUSTERING` - - `QUESTION_ANSWERING` - - `FACT_VERIFICATION` - - `CODE_RETRIEVAL_QUERY` - output_dimensionality (int, optional): - An INT64 value that specifies the size of the output embedding. - - Returns: - bigframes.pandas.DataFrame: - The generated text embedding. - """ - import bigframes.pandas as bpd - - model_name, session = utils.get_model_name_and_session(model, input_) - table_sql = utils.to_sql(input_) - - sql = bigframes.core.sql.ml.generate_embedding( - model_name=model_name, - table=table_sql, - flatten_json_output=flatten_json_output, - task_type=task_type, - output_dimensionality=output_dimensionality, - ) - - if session is None: - return bpd.read_gbq_query(sql) - else: - return session.read_gbq_query(sql) diff --git a/bigframes/bigquery/_operations/obj.py b/bigframes/bigquery/_operations/obj.py deleted file mode 100644 index ca09d7ab1ce..00000000000 --- a/bigframes/bigquery/_operations/obj.py +++ /dev/null @@ -1,114 +0,0 @@ -# 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 ObjectRef functions. - -See bigframes.bigquery.obj for public docs. -""" - -from __future__ import annotations - -import datetime -from typing import Optional, Sequence, Union - -import numpy as np -import pandas as pd - -import bigframes.core.utils as utils -import bigframes.operations as ops -import bigframes.series as series -from bigframes.core import convert -from bigframes.core.logging import log_adapter - - -@log_adapter.method_logger(custom_base_name="bigquery_obj") -def fetch_metadata( - objectref: series.Series, -) -> series.Series: - """[Preview] The OBJ.FETCH_METADATA function returns Cloud Storage metadata for a partially populated ObjectRef value. - - Args: - objectref (bigframes.pandas.Series): - A partially populated ObjectRef value, in which the uri and authorizer fields are populated and the details field isn't. - - Returns: - bigframes.pandas.Series: A fully populated ObjectRef value. The metadata is provided in the details field of the returned ObjectRef value. - """ - objectref = convert.to_bf_series(objectref, default_index=None) - return objectref._apply_unary_op(ops.obj_fetch_metadata_op) - - -@log_adapter.method_logger(custom_base_name="bigquery_obj") -def get_access_url( - objectref: series.Series, - mode: str, - duration: Optional[Union[datetime.timedelta, pd.Timedelta, np.timedelta64]] = None, -) -> series.Series: - """[Preview] The OBJ.GET_ACCESS_URL function returns JSON that contains reference information for the input ObjectRef value, and also access URLs that you can use to read or modify the Cloud Storage object. - - Args: - objectref (bigframes.pandas.Series): - An ObjectRef value that represents a Cloud Storage object. - mode (str): - A STRING value that identifies the type of URL that you want to be returned. The following values are supported: - 'r': Returns a URL that lets you read the object. - 'rw': Returns two URLs, one that lets you read the object, and one that lets you modify the object. - duration (Union[datetime.timedelta, pandas.Timedelta, numpy.timedelta64], optional): - An optional INTERVAL value that specifies how long the generated access URLs remain valid. You can specify a value between 30 minutes and 6 hours. For example, you could specify INTERVAL 2 HOUR to generate URLs that expire after 2 hours. The default value is 6 hours. - - Returns: - bigframes.pandas.Series: A JSON value that contains the Cloud Storage object reference information from the input ObjectRef value, and also one or more URLs that you can use to access the Cloud Storage object. - """ - objectref = convert.to_bf_series(objectref, default_index=None) - - duration_micros = None - if duration is not None: - duration_micros = utils.timedelta_to_micros(duration) - - return objectref._apply_unary_op( - ops.ObjGetAccessUrl(mode=mode, duration=duration_micros) - ) - - -@log_adapter.method_logger(custom_base_name="bigquery_obj") -def make_ref( - uri_or_json: Union[series.Series, Sequence[str]], - authorizer: Union[series.Series, str, None] = None, -) -> series.Series: - """[Preview] Use the OBJ.MAKE_REF function to create an ObjectRef value that contains reference information for a Cloud Storage object. - - Args: - uri_or_json (bigframes.pandas.Series or str): - A series of STRING values that contains the URI for the Cloud Storage object, for example, gs://mybucket/flowers/12345.jpg. - OR - A series of JSON value that represents a Cloud Storage object. - authorizer (bigframes.pandas.Series or str, optional): - A STRING value that contains the Cloud Resource connection used to access the Cloud Storage object. - Required if ``uri_or_json`` is a URI string. - - Returns: - bigframes.pandas.Series: An ObjectRef value. - """ - uri_or_json = convert.to_bf_series(uri_or_json, default_index=None) - - if authorizer is not None: - # Avoid join problems encountered if we try to convert a literal into Series. - if not isinstance(authorizer, str): - authorizer = convert.to_bf_series(authorizer, default_index=None) - - return uri_or_json._apply_binary_op(authorizer, ops.obj_make_ref_op) - - # If authorizer is not provided, we assume uri_or_json is a JSON objectref - return uri_or_json._apply_unary_op(ops.obj_make_ref_json_op) diff --git a/bigframes/bigquery/_operations/search.py b/bigframes/bigquery/_operations/search.py index b65eed24753..9a1e4b5ac90 100644 --- a/bigframes/bigquery/_operations/search.py +++ b/bigframes/bigquery/_operations/search.py @@ -20,6 +20,7 @@ import google.cloud.bigquery as bigquery +import bigframes.core.sql import bigframes.ml.utils as utils if typing.TYPE_CHECKING: @@ -98,7 +99,6 @@ 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,6 +111,7 @@ 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: @@ -162,12 +163,12 @@ def vector_search( ... query=search_query, ... distance_type="cosine", ... query_column_to_search="another_embedding", - ... top_k=2).sort_values("id") + ... top_k=2) query_id embedding another_embedding id my_embedding distance - 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 + 1 cat [3. 5.2] [3.3 5.2] 1 [1. 2.] 0.005181 + 0 dog [1. 2.] [0.7 2.2] 3 [1.5 7. ] 0.004697 [4 rows x 6 columns] @@ -198,10 +199,6 @@ 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. @@ -239,11 +236,9 @@ def vector_search( options=options, ) if index_col_ids is not None: - df = query._session.read_gbq_query( - sql, index_col=index_col_ids, allow_large_results=allow_large_results - ) + df = query._session.read_gbq(sql, index_col=index_col_ids) df.index.names = index_labels else: - df = query._session.read_gbq_query(sql, allow_large_results=allow_large_results) + df = query._session.read_gbq(sql) return df diff --git a/bigframes/bigquery/_operations/sql.py b/bigframes/bigquery/_operations/sql.py index 332d558866b..a84c074e012 100644 --- a/bigframes/bigquery/_operations/sql.py +++ b/bigframes/bigquery/_operations/sql.py @@ -16,31 +16,21 @@ from __future__ import annotations -from typing import Optional, Sequence, Union, cast +from typing import Sequence import google.cloud.bigquery +import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir +import bigframes.core.sql import bigframes.dataframe import bigframes.dtypes import bigframes.operations import bigframes.series -from bigframes.core.compile.sqlglot import sql - - -def _format_names(sql_template: str, dataframe: bigframes.dataframe.DataFrame): - """Turn sql_template from a template that uses names to one that uses - numbers. - """ - names_to_numbers = {name: f"{{{i}}}" for i, name in enumerate(dataframe.columns)} - numbers = [f"{{{i}}}" for i in range(len(dataframe.columns))] - return sql_template.format(*numbers, **names_to_numbers) def sql_scalar( sql_template: str, - columns: Union[bigframes.dataframe.DataFrame, Sequence[bigframes.series.Series]], - *, - output_dtype: Optional[bigframes.dtypes.Dtype] = None, + columns: Sequence[bigframes.series.Series], ) -> bigframes.series.Series: """Create a Series from a SQL template. @@ -48,9 +38,9 @@ def sql_scalar( >>> import bigframes.pandas as bpd >>> import bigframes.bigquery as bbq - - Either pass in a sequence of series, in which case use integers in the - format strings. + >>> 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))) @@ -60,48 +50,13 @@ def sql_scalar( 2 4.000000000 dtype: decimal128(38, 9)[pyarrow] - Or pass in a DataFrame, in which case use the column names in the format - strings. - - >>> df = bpd.DataFrame({"a": ["1.5", "2.5", "3.5"]}) - >>> df = df.astype({"a": pd.ArrowDtype(pa.decimal128(38, 9))}) - >>> bbq.sql_scalar("ROUND({a}, 0, 'ROUND_HALF_EVEN')", df) - 0 2.000000000 - 1 2.000000000 - 2 4.000000000 - dtype: decimal128(38, 9)[pyarrow] - - You can also use the `.bigquery` DataFrame accessor to apply a SQL scalar function. - - Compute SQL scalar using a pandas DataFrame: - - >>> import pandas as pd - >>> df = pd.DataFrame({"x": [1, 2, 3]}) - >>> bpd.options.display.progress_bar = None # doctest: +SKIP - >>> pandas_s = df.bigquery.sql_scalar("POW({0}, 2)") # doctest: +SKIP - >>> type(pandas_s) # doctest: +SKIP - - - Compute SQL scalar using a BigFrames DataFrame: - - >>> bf_df = bpd.DataFrame({"x": [1, 2, 3]}) - >>> bf_s = bf_df.bigquery.sql_scalar("POW({0}, 2)") # doctest: +SKIP - >>> type(bf_s) # doctest: +SKIP - - - Args: sql_template (str): A SQL format string with Python-style {0} placeholders for each of the Series objects in ``columns``. - columns ( - Sequence[bigframes.pandas.Series] | bigframes.pandas.DataFrame - ): + columns (Sequence[bigframes.pandas.Series]): Series objects representing the column inputs to the ``sql_template``. Must contain at least one Series. - output_dtype (a BigQuery DataFrames compatible dtype, optional): - If provided, BigQuery DataFrames uses this to determine the output - of the returned Series. This avoids a dry run query. Returns: bigframes.pandas.Series: @@ -110,38 +65,31 @@ def sql_scalar( Raises: ValueError: If ``columns`` is empty. """ - if isinstance(columns, bigframes.dataframe.DataFrame): - sql_template = _format_names(sql_template, columns) - columns = [ - cast(bigframes.series.Series, columns[column]) for column in columns.columns - ] - if len(columns) == 0: raise ValueError("Must provide at least one column in columns") - base_series = columns[0] - # To integrate this into our expression trees, we need to get the output # type, so we do some manual compilation and a dry run query to get that. # 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. - if output_dtype is None: - literals_sql = [ - sql.to_sql(sql.literal(None, column.dtype)) for column in columns - ] - 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. - bqclient = base_series._session.bqclient - job = bqclient.query( - dry_run_sql, job_config=google.cloud.bigquery.QueryJobConfig(dry_run=True) - ) - _, output_dtype = bigframes.dtypes.convert_schema_field(job.schema[0]) + literals_sql = [ + sqlglot_ir._literal(None, column.dtype).sql(dialect="bigquery") + for column in columns + ] + 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. + base_series = columns[0] + bqclient = base_series._session.bqclient + job = bqclient.query( + dry_run_sql, job_config=google.cloud.bigquery.QueryJobConfig(dry_run=True) + ) + _, output_type = bigframes.dtypes.convert_schema_field(job.schema[0]) op = bigframes.operations.SqlScalarOp( - _output_type=output_dtype, sql_template=sql_template + _output_type=output_type, sql_template=sql_template ) return base_series._apply_nary_op(op, columns[1:]) diff --git a/bigframes/bigquery/_operations/struct.py b/bigframes/bigquery/_operations/struct.py index 2ee760fb8e5..7cb826351c1 100644 --- a/bigframes/bigquery/_operations/struct.py +++ b/bigframes/bigquery/_operations/struct.py @@ -15,7 +15,8 @@ """This module integrates BigQuery built-in functions for use with DataFrame objects, such as array functions: -https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions.""" +https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions. """ + from __future__ import annotations @@ -38,13 +39,14 @@ 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() >>> bbq.struct(df) - 0 {'version': 1, 'project': 'pandas'} - 1 {'version': 2, 'project': 'numpy'} - dtype: struct[pyarrow] + 0 {'project': 'pandas', 'version': 1} + 1 {'project': 'numpy', 'version': 2} + dtype: struct[pyarrow] Args: value (bigframes.dataframe.DataFrame): @@ -57,5 +59,5 @@ def struct(value: dataframe.DataFrame) -> series.Series: block, result_id = block.apply_nary_op( block.value_columns, ops.StructOp(column_names=tuple(block.column_labels)) ) - block = block.select_column(result_id).with_column_labels([None]) + block = block.select_column(result_id) return series.Series(block) diff --git a/bigframes/bigquery/_operations/table.py b/bigframes/bigquery/_operations/table.py deleted file mode 100644 index cad025412d5..00000000000 --- a/bigframes/bigquery/_operations/table.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Mapping, Optional, Union - -import google.cloud.bigquery -import pandas as pd - -import bigframes.core.compile.sqlglot.sql as sg_sql -import bigframes.core.logging.log_adapter as log_adapter -import bigframes.session - - -def _get_table_metadata( - *, - bqclient: google.cloud.bigquery.Client, - table_name: str, -) -> pd.Series: - table_metadata = bqclient.get_table(table_name) - table_dict = table_metadata.to_api_repr() - return pd.Series(table_dict) - - -@log_adapter.method_logger(custom_base_name="bigquery_table") -def create_external_table( - table_name: str, - *, - replace: bool = False, - if_not_exists: bool = False, - columns: Optional[Mapping[str, str]] = None, - partition_columns: Optional[Mapping[str, str]] = None, - connection_name: Optional[str] = None, - options: Mapping[str, Union[str, int, float, bool, list]], - session: Optional[bigframes.session.Session] = None, -) -> pd.Series: - """ - Creates a BigQuery external table. - - See the `BigQuery CREATE EXTERNAL TABLE DDL syntax - `_ - for additional reference. - - Args: - table_name (str): - The name of the table in BigQuery. - replace (bool, default False): - Whether to replace the table if it already exists. - if_not_exists (bool, default False): - Whether to ignore the error if the table already exists. - columns (Mapping[str, str], optional): - The table's schema. - partition_columns (Mapping[str, str], optional): - The table's partition columns. - connection_name (str, optional): - The connection to use for the table. - options (Mapping[str, Union[str, int, float, bool, list]]): - The OPTIONS clause, which specifies the table options. - 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 table metadata. Reference - the `BigQuery Table REST API reference - `_ - for available fields. - """ - import bigframes.pandas as bpd - - sql = sg_sql.to_sql( - sg_sql.create_external_table( - table_name=table_name, - replace=replace, - if_not_exists=if_not_exists, - columns=columns, - partition_columns=partition_columns, - connection_name=connection_name, - options=options, - ) - ) - - if session is None: - bpd.read_gbq_query(sql) - session = bpd.get_global_session() - else: - session.read_gbq_query(sql) - - return _get_table_metadata(bqclient=session.bqclient, table_name=table_name) diff --git a/bigframes/bigquery/_operations/utils.py b/bigframes/bigquery/_operations/utils.py deleted file mode 100644 index 0bae8f47c7a..00000000000 --- a/bigframes/bigquery/_operations/utils.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Optional, Union, cast - -import pandas as pd - -import bigframes -from bigframes import dataframe -from bigframes.ml import base as ml_base - - -def get_model_name_and_session( - model: Union[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 to_sql(df_or_sql: Union[pd.DataFrame, dataframe.DataFrame, str]) -> str: - """ - Helper to convert DataFrame to SQL string - """ - 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 diff --git a/bigframes/bigquery/aead.py b/bigframes/bigquery/aead.py deleted file mode 100644 index c4243a5c010..00000000000 --- a/bigframes/bigquery/aead.py +++ /dev/null @@ -1,25 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""AEAD encryption functions""" - -from __future__ import annotations - -from bigframes.operations.googlesql.aead import decrypt_bytes, decrypt_string, encrypt - -__all__ = [ - "decrypt_bytes", - "decrypt_string", - "encrypt", -] diff --git a/bigframes/bigquery/ai.py b/bigframes/bigquery/ai.py deleted file mode 100644 index 6dd3d116635..00000000000 --- a/bigframes/bigquery/ai.py +++ /dev/null @@ -1,89 +0,0 @@ -# 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. - -""" -Integrate BigQuery built-in AI functions into your BigQuery DataFrames workflow. - -The ``bigframes.bigquery.ai`` module provides a Pythonic interface to leverage BigQuery ML's -generative AI and predictive functions directly on BigQuery DataFrames and Series objects. -These functions enable you to perform advanced AI tasks at scale without moving data -out of BigQuery. - -Key capabilities include: - -* **Generative AI:** Use :func:`bigframes.bigquery.ai.generate` (Gemini) to - perform text analysis, translation, or - content generation. Specialized versions like - :func:`~bigframes.bigquery.ai.generate_bool`, - :func:`~bigframes.bigquery.ai.generate_int`, and - :func:`~bigframes.bigquery.ai.generate_double` are available for structured - outputs. -* **Embeddings:** Generate vector embeddings for text using - :func:`~bigframes.bigquery.ai.generate_embedding`, which are essential for - semantic search and retrieval-augmented generation (RAG) workflows. -* **Classification and Scoring:** Apply machine learning models to your data for - predictive tasks with :func:`~bigframes.bigquery.ai.classify` and - :func:`~bigframes.bigquery.ai.score`. -* **Forecasting:** Predict future values in time-series data using - :func:`~bigframes.bigquery.ai.forecast`. - -**Example usage:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> df = bpd.DataFrame({ - ... "text_input": [ - ... "Is this a positive review? The food was terrible.", - ... ], - ... }) # doctest: +SKIP - - >>> # Assuming a Gemini model has been created in BigQuery as 'my_gemini_model' - >>> result = bq.ai.generate_text("my_gemini_model", df["text_input"]) # doctest: +SKIP - -For more information on the underlying BigQuery ML syntax, see: -https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-ai-generate-bool -""" - -from bigframes.bigquery._operations.ai import ( - classify, - embed, - forecast, - generate, - generate_bool, - generate_double, - generate_embedding, - generate_int, - generate_table, - generate_text, - if_, - score, - similarity, -) - -__all__ = [ - "classify", - "embed", - "forecast", - "generate", - "generate_bool", - "generate_double", - "generate_embedding", - "generate_int", - "generate_table", - "generate_text", - "if_", - "score", - "similarity", -] diff --git a/bigframes/bigquery/ml.py b/bigframes/bigquery/ml.py deleted file mode 100644 index 9b0d77d5b89..00000000000 --- a/bigframes/bigquery/ml.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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, - generate_embedding, - generate_text, - get_insights, - global_explain, - predict, - transform, -) - -__all__ = [ - "create_model", - "evaluate", - "predict", - "explain_predict", - "global_explain", - "transform", - "generate_text", - "generate_embedding", - "get_insights", -] diff --git a/bigframes/bigquery/obj.py b/bigframes/bigquery/obj.py deleted file mode 100644 index dc2c29e1f3d..00000000000 --- a/bigframes/bigquery/obj.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 'ObjectRef' functions for use with Series/DataFrame objects, -such as OBJ.FETCH_METADATA: -https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/objectref_functions - - -.. warning:: - - This product or feature is subject to the "Pre-GA Offerings Terms" in the - General Service Terms section of the `Service Specific Terms - `_. Pre-GA products and - features are available "as is" and might have limited support. For more - information, see the `launch stage descriptions - `_. - -.. note:: - - To provide feedback or request support for this feature, send an email to - bq-objectref-feedback@google.com. -""" - -from bigframes.bigquery._operations.obj import fetch_metadata, get_access_url, make_ref - -__all__ = [ - "fetch_metadata", - "get_access_url", - "make_ref", -] diff --git a/bigframes/blob/_functions.py b/bigframes/blob/_functions.py new file mode 100644 index 00000000000..8d1ca38e622 --- /dev/null +++ b/bigframes/blob/_functions.py @@ -0,0 +1,534 @@ +# 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 dataclasses import dataclass +import inspect +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"} + + +@dataclass(frozen=True) +class FunctionDef: + """Definition of a Python UDF.""" + + func: Callable # function body + requirements: Iterable[str] # required packages + + +# TODO(garrettwu): migrate to bigframes UDF when it is available +class TransformFunction: + """Simple transform function class to deal with Python UDF.""" + + def __init__( + self, + func_def: FunctionDef, + 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 = ( + 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) + inputs = [] + for k, v in sig.parameters.items(): + inputs.append(f"{k} {_PYTHON_TO_BQ_TYPES[v.annotation]}") + return ", ".join(inputs) + + def _output_bq_type(self): + sig = inspect.signature(self._func) + 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._anon_dataset_manager.generate_unique_resource_id() + ) + + func_body = inspect.getsource(self._func) + func_name = self._func.__name__ + packages = str(list(self._requirements)) + + sql = f""" +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}, container_cpu={self._container_cpu}, container_memory='{self._container_memory}') +AS r\"\"\" + + +{func_body} + + +\"\"\" + """ + + bf_io_bigquery.start_query_with_client( + self._session.bqclient, + sql, + job_config=bigquery.QueryJobConfig(), + metrics=self._session._metrics, + location=None, + project=None, + timeout=None, + query_with_job=True, + ) + + return udf_name + + 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) -> str: + 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) + 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) + exif_dict[tag_name] = value + + return json.dumps(exif_dict) + + +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, ext: str +) -> str: + 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) + bts = response.content + + 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(ext, img_blurred)[1].tobytes() + + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext + + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) + + return dst_obj_ref_rt + + +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 +) -> bytes: + 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) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + bts = response.content + + 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(ext, img_blurred)[1].tobytes() + + return bts + + +image_blur_to_bytes_def = FunctionDef( + image_blur_to_bytes_func, ["opencv-python", "numpy", "requests"] +) + + +def image_resize_func( + src_obj_ref_rt: str, + dst_obj_ref_rt: str, + dsize_x: int, + dsize_y: int, + fx: float, + fy: float, + ext: str, +) -> str: + 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) + 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(ext, img_resized)[1].tobytes() + + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext + + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) + + return dst_obj_ref_rt + + +image_resize_def = FunctionDef( + image_resize_func, ["opencv-python", "numpy", "requests"] +) + + +def image_resize_to_bytes_func( + src_obj_ref_rt: str, + dsize_x: int, + dsize_y: int, + fx: float, + fy: float, + ext: str, +) -> bytes: + 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) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + 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 + + +image_resize_to_bytes_def = FunctionDef( + image_resize_to_bytes_func, ["opencv-python", "numpy", "requests"] +) + + +def image_normalize_func( + src_obj_ref_rt: str, + dst_obj_ref_rt: str, + alpha: float, + beta: float, + norm_type: str, + ext: str, +) -> str: + 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) + bts = response.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(ext, img_normalized)[1].tobytes() + + ext = ext.replace(".", "") + ext_mappings = {"jpg": "jpeg", "tif": "tiff"} + ext = ext_mappings.get(ext, ext) + content_type = "image/" + ext + + session.put( + url=dst_url, + data=bts, + headers={ + "Content-Type": content_type, + }, + timeout=30, + ) + + return dst_obj_ref_rt + + +image_normalize_def = FunctionDef( + image_normalize_func, ["opencv-python", "numpy", "requests"] +) + + +def image_normalize_to_bytes_func( + src_obj_ref_rt: str, alpha: float, beta: float, norm_type: str, ext: str +) -> bytes: + 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) + src_url = src_obj_ref_rt_json["access_urls"]["read_url"] + + response = session.get(src_url, timeout=30) + bts = response.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() + + return bts + + +image_normalize_to_bytes_def = FunctionDef( + image_normalize_to_bytes_func, ["opencv-python", "numpy", "requests"] +) + + +# Extracts all text from a PDF url +def pdf_extract_func(src_obj_ref_rt: str) -> 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) + + all_text = "" + for page in reader.pages: + page_extract_text = page.extract_text() + if page_extract_text: + all_text += page_extract_text + + result_dict = {"status": "", "content": all_text} + + except Exception as e: + result_dict = {"status": str(e), "content": ""} + + result_json = json.dumps(result_dict) + return result_json + + +pdf_extract_def = FunctionDef( + pdf_extract_func, ["pypdf>=5.3.1,<6.0.0", "requests", "cryptography==43.0.3"] +) + + +# 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: + 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": []} + + result_json = json.dumps(result_dict) + return result_json + + +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 b724843c133..e6ddd5c6cbe 100644 --- a/bigframes/clients.py +++ b/bigframes/clients.py @@ -19,7 +19,7 @@ import logging import textwrap import time -from typing import Optional, cast +from typing import cast, Optional import google.api_core.exceptions import google.api_core.retry diff --git a/bigframes/core/agg_expressions.py b/bigframes/core/agg_expressions.py deleted file mode 100644 index 6d126c92420..00000000000 --- a/bigframes/core/agg_expressions.py +++ /dev/null @@ -1,231 +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 abc -import dataclasses -import functools -import itertools -import typing -from typing import Callable, Hashable, Mapping, Tuple, TypeVar - -import bigframes.core.identifiers as ids -import bigframes.operations.aggregations as agg_ops -from bigframes import dtypes -from bigframes.core import expression, window_spec - -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[Hashable, ...]: - 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[Hashable, 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[Hashable, ...]: - 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[Hashable, 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 d7fb186ae91..b47637cb591 100644 --- a/bigframes/core/array_value.py +++ b/bigframes/core/array_value.py @@ -13,34 +13,32 @@ # limitations under the License. from __future__ import annotations +from dataclasses import dataclass import datetime import functools import typing -from dataclasses import dataclass -from typing import Iterable, List, Mapping, Optional, Sequence, Tuple, Union +from typing import Iterable, List, Mapping, Optional, Sequence, Tuple +import warnings +import google.cloud.bigquery import pandas import pyarrow as pa 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 +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 -from bigframes.core import ( - agg_expressions, - bq_data, - expression_factoring, - join_def, - local_data, -) -from bigframes.core.ordering import OrderingExpression -from bigframes.core.window_spec import WindowSpec if typing.TYPE_CHECKING: from bigframes.session import Session @@ -66,7 +64,7 @@ def from_pyarrow(cls, arrow_table: pa.Table, session: Session): def from_managed(cls, source: local_data.ManagedArrowTable, session: Session): scan_list = nodes.ScanList( tuple( - nodes.ScanItem(ids.ColumnId(item.column), item.column) + nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) for item in source.schema.items ) ) @@ -90,10 +88,10 @@ def from_range(cls, start, end, step): @classmethod def from_table( cls, - table: Union[bq_data.BiglakeIcebergTable, bq_data.GbqNativeTable], + 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] = (), @@ -102,6 +100,14 @@ def from_table( ): 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 = bfe.format_message( + "JSON column interpretation as a custom PyArrow extention in `db_dtypes` " + "is a preview feature and subject to change." + ) + 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) # create ordering from info ordering = None @@ -112,19 +118,15 @@ def from_table( [ids.ColumnId(key_part) for key_part in primary_key] ) - bf_schema = schemata.ArraySchema.from_bq_schema( - table.physical_schema, 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.column) - for item in bf_schema.items + nodes.ScanItem(ids.ColumnId(item.column), item.dtype, item.column) + for item in schema.items ) ) - source_def = bq_data.BigqueryDataSource( - table=table, - schema=bf_schema, + source_def = nodes.BigqueryDataSource( + table=table_def, at_time=at_time, sql_predicate=predicate, ordering=ordering, @@ -135,7 +137,7 @@ def from_table( @classmethod def from_bq_data_source( cls, - source: bq_data.BigqueryDataSource, + source: nodes.BigqueryDataSource, scan_list: nodes.ScanList, session: Session, ): @@ -188,7 +190,7 @@ def row_count(self) -> ArrayValue: child=self.node, aggregations=( ( - agg_expressions.NullaryAggregation(agg_ops.size_op), + ex.NullaryAggregation(agg_ops.size_op), ids.ColumnId(bigframes.core.guid.generate_guid()), ), ), @@ -204,25 +206,14 @@ def filter_by_id(self, predicate_id: str, keep_null: bool = False) -> ArrayValue return self.filter(predicate) def filter(self, predicate: ex.Expression): - if predicate.is_scalar_expr: - return ArrayValue(nodes.FilterNode(child=self.node, predicate=predicate)) - else: - arr, filter_ids = self.compute_general_expression([predicate]) - arr = arr.filter_by_id(filter_ids[0]) - return arr.drop_columns(filter_ids) + return ArrayValue(nodes.FilterNode(child=self.node, predicate=predicate)) def order_by( - self, - by: Sequence[OrderingExpression], - is_total_order: bool = False, - stable: bool = True, + self, by: Sequence[OrderingExpression], is_total_order: bool = False ) -> ArrayValue: return ArrayValue( nodes.OrderByNode( - child=self.node, - by=tuple(by), - is_total_order=is_total_order, - stable=stable, + child=self.node, by=tuple(by), is_total_order=is_total_order ) ) @@ -232,6 +223,11 @@ 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 = bfe.format_message( + "Window ordering may be ambiguous, this can cause unstable results." + ) + warnings.warn(msg, bfe.AmbiguousWindowWarning) return ArrayValue( nodes.SliceNode( self.node, @@ -246,6 +242,17 @@ 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 = bfe.format_message( + "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)) @@ -275,97 +282,6 @@ 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], @@ -463,7 +379,7 @@ def drop_columns(self, columns: Iterable[str]) -> ArrayValue: def aggregate( self, - aggregations: typing.Sequence[typing.Tuple[agg_expressions.Aggregation, str]], + aggregations: typing.Sequence[typing.Tuple[ex.Aggregation, str]], by_column_ids: typing.Sequence[str] = (), dropna: bool = True, ) -> ArrayValue: @@ -484,38 +400,74 @@ def aggregate( ) ) + def project_window_op( + 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 + """ + + return self.project_window_expr( + ex.UnaryAggregation(op, ex.deref(column_name)), + window_spec, + never_skip_nulls, + skip_reproject_unsafe, + ) + def project_window_expr( self, - expressions: Sequence[agg_expressions.Aggregation], + expression: ex.Aggregation, window: WindowSpec, + never_skip_nulls=False, + skip_reproject_unsafe: bool = False, ): - 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) - ) - + # TODO: Support non-deterministic windowing + if window.is_row_bounded or not expression.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 = bfe.format_message( + "Window ordering may be ambiguous, this can cause unstable results." + ) + warnings.warn(msg, category=bfe.AmbiguousWindowWarning) + output_name = self._gen_namespaced_uid() return ( ArrayValue( nodes.WindowOpNode( child=self.node, - agg_exprs=agg_exprs, + expression=expression, window_spec=window, + output_name=ids.ColumnId(output_name), + never_skip_nulls=never_skip_nulls, + skip_reproject_unsafe=skip_reproject_unsafe, ) ), - id_strings, + output_name, ) def isin( - self, - other: ArrayValue, - lcol: str, + self, other: ArrayValue, lcol: str, rcol: 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 @@ -527,14 +479,6 @@ def relational_join( 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 } @@ -547,7 +491,6 @@ def relational_join( for l_col, r_col in conditions ), type=type, - nulls_equal=True, # pandas semantics 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 deleted file mode 100644 index 09ba09731c2..00000000000 --- a/bigframes/core/backports.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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 c48605dc248..0c6f56f35a2 100644 --- a/bigframes/core/bigframe_node.py +++ b/bigframes/core/bigframe_node.py @@ -20,11 +20,14 @@ import functools import itertools import typing -from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple +from typing import Callable, Dict, Generator, Iterable, Mapping, Sequence, Tuple, Union +from bigframes.core import expression, field, identifiers import bigframes.core.schema as schemata import bigframes.dtypes -from bigframes.core import expression, field, identifiers + +if typing.TYPE_CHECKING: + import bigframes.session COLUMN_SET = frozenset[identifiers.ColumnId] @@ -142,7 +145,8 @@ def roots(self) -> typing.Set[BigFrameNode]: # TODO: Store some local data lazily for select, aggregate nodes. @property @abc.abstractmethod - def fields(self) -> Sequence[field.Field]: ... + def fields(self) -> Sequence[field.Field]: + ... @property def ids(self) -> Iterable[identifiers.ColumnId]: @@ -277,8 +281,8 @@ def field_by_id(self) -> Mapping[identifiers.ColumnId, field.Field]: @property def _node_expressions( self, - ) -> Sequence[expression.Expression]: - """List of expressions. Intended for checking engine compatibility with used ops.""" + ) -> Sequence[Union[expression.Expression, expression.Aggregation]]: + """List of scalar expressions. Intended for checking engine compatibility with used ops.""" return () # Plan algorithms @@ -299,9 +303,9 @@ def iter_nodes_topo( self: BigFrameNode, ) -> Generator[BigFrameNode, None, None]: """Returns nodes in reverse topological order, using Kahn's algorithm.""" - child_to_parents: Dict[BigFrameNode, list[BigFrameNode]] = ( - collections.defaultdict(list) - ) + child_to_parents: Dict[ + BigFrameNode, list[BigFrameNode] + ] = collections.defaultdict(list) out_degree: Dict[BigFrameNode, int] = collections.defaultdict(int) queue: collections.deque["BigFrameNode"] = collections.deque() @@ -329,30 +333,20 @@ def top_down( """ Perform a top-down transformation of the BigFrameNode tree. """ + to_process = [self] results: Dict[BigFrameNode, BigFrameNode] = {} - # Each stack entry is (node, t_node). t_node is None until transform(node) is called. - stack: list[tuple[BigFrameNode, typing.Optional[BigFrameNode]]] = [(self, None)] - while stack: - node, t_node = stack[-1] - - if t_node is None: - if node in results: - stack.pop() - continue - t_node = transform(node) - stack[-1] = (node, t_node) - - all_done = True - for child in reversed(t_node.child_nodes): - if child not in results: - stack.append((child, None)) - all_done = False - break - - if all_done: - results[node] = t_node.transform_children(lambda x: results[x]) - stack.pop() + while to_process: + item = to_process.pop() + if item not in results.keys(): + item_result = transform(item) + results[item] = item_result + to_process.extend(item_result.child_nodes) + + to_process = [self] + # for each processed item, replace its children + for item in reversed(list(results.keys())): + results[item] = results[item].transform_children(lambda x: results[x]) return results[self] diff --git a/bigframes/core/block_transforms.py b/bigframes/core/block_transforms.py index c919b88614d..465728b0ef6 100644 --- a/bigframes/core/block_transforms.py +++ b/bigframes/core/block_transforms.py @@ -14,9 +14,8 @@ from __future__ import annotations import functools -import inspect import typing -from typing import Callable, Hashable, Optional, Sequence +from typing import Optional, Sequence import bigframes_vendored.constants as constants import pandas as pd @@ -24,112 +23,13 @@ import bigframes.constants import bigframes.core as core import bigframes.core.blocks as blocks -import bigframes.core.bytecode as bytecode import bigframes.core.expression as ex import bigframes.core.ordering as ordering -import bigframes.core.window_spec as window_specs +import bigframes.core.window_spec as windows +import bigframes.dtypes import bigframes.dtypes as dtypes -import bigframes.functions import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -from bigframes._config import options -from bigframes.core import agg_expressions, py_expressions - - -def compile_udf( - block: blocks.Block, - func: Callable, - args: tuple = (), - kwargs: dict | None = None, - col_series_args: typing.Mapping[str, str] | None = None, - window_spec: Optional[window_specs.WindowSpec] = None, -) -> ex.Expression: - """Compile a python function to a BigFrames expression in the context of a block.""" - if kwargs is None: - kwargs = {} - expr = bytecode._compile_bytecode_to_py_expr(func) - sig = inspect.signature(func) - - bindings: dict[Hashable, ex.Expression] = {} - - bound_args = sig.bind(*(None, *args), **kwargs) - bound_args.apply_defaults() - bound_params = bound_args.arguments - for name, value in bound_params.items(): - bindings[name] = ex.const(value) - - series_arg = next(iter(sig.parameters.keys())) - - if col_series_args is not None: - expr = py_expressions.resolve_py_exprs( - expr, - series_arg=series_arg, - col_series_args=col_series_args, - window_spec=window_spec, - ) - else: - series_attrs: dict = {} - for i, (col_id, label) in enumerate( - zip(block.value_columns, block.column_labels) - ): - series_attrs[i] = col_id - if label is not None: - series_attrs[label] = col_id - - expr = py_expressions.resolve_py_exprs( - expr, - series_arg=series_arg, - series_attrs=series_attrs, - window_spec=window_spec, - ) - - expr = expr.bind_variables(bindings) - return expr - - -def is_transpiler_eligible(func: typing.Any) -> bool: - """Return True if func is eligible for Python transpilation.""" - return ( - options.experiments.enable_python_transpiler - and callable(func) - and not isinstance(func, bigframes.functions.Udf) - ) - - -def compile_column_udf( - block: blocks.Block, - func: Callable, - column_id: str, - args: tuple = (), - kwargs: dict | None = None, - window_spec: Optional[window_specs.WindowSpec] = None, -) -> tuple[ex.Expression, str]: - """Compile a column-wise python UDF in block context and return (expr, name).""" - sig = inspect.signature(func) - series_arg = next(iter(sig.parameters.keys())) - expr = compile_udf( - block, - func, - args=args, - kwargs=kwargs, - col_series_args={series_arg: column_id}, - window_spec=window_spec, - ) - name = getattr(func, "__name__", "") - return expr, name - - -def apply_to_block_rows( - func: Callable, block: blocks.Block, *args, **kwargs -) -> blocks.Block: - """ - Apply the given function to each row of the block. - - The function is applied to each row of the block, and the result is returned - as a new block with the same index. - """ - expr = compile_udf(block, func, args, kwargs) - return block.project_exprs([expr], labels=[None], drop=True) def equals(block1: blocks.Block, block2: blocks.Block) -> bool: @@ -167,39 +67,40 @@ 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=window_specs.unbound(grouping_keys=tuple(columns)), - ) - count = agg_expressions.WindowExpression( - agg_expressions.NullaryAggregation( - agg_ops.SizeOp(), - ), - window=window_specs.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 - predicate = ops.gt_op.as_expr(rownums, ex.const(0)) + window_spec = windows.cumulative_rows( + grouping_keys=tuple(columns), + ) elif keep == "last": # Count how many copies occur up to current copy of values # Discard this value if there are copies AFTER - predicate = ops.lt_op.as_expr(rownums, ops.sub_op.as_expr(count, ex.const(1))) + window_spec = windows.inverse_cumulative_rows( + grouping_keys=tuple(columns), + ) else: # keep == False # Count how many copies of the value occur in entire series. # Discard this value if there are copies ANYWHERE - predicate = ops.gt_op.as_expr(count, ex.const(1)) - - block = block.project_block_exprs( - [predicate], - labels=[None], + 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)) ) return ( - block, - block.value_columns[-1], + block.drop_columns( + ( + dummy, + val_count_col_id, + ) + ), + duplicate_indicator, ) @@ -211,7 +112,7 @@ def quantile( dropna: bool = False, ) -> blocks.Block: # TODO: handle windowing and more interpolation methods - window = window_specs.unbound( + window = windows.unbound( grouping_keys=tuple(grouping_column_ids), ) quantile_cols = [] @@ -229,12 +130,12 @@ def quantile( window_spec=window, ) quantile_cols.append(quantile_col) - block = block.aggregate( + block, _ = block.aggregate( + grouping_column_ids, tuple( - agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) + ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col)) for col in quantile_cols ), - grouping_column_ids, column_labels=pd.Index(labels), dropna=dropna, ) @@ -312,8 +213,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 = window_specs.rows(end=0, ordering=window_ordering) - forwards_window = window_specs.rows(start=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) @@ -331,11 +232,13 @@ 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": @@ -458,14 +361,14 @@ def value_counts( 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)], + block, agg_ids = block.aggregate( by_column_ids=(*grouping_keys, *columns), + aggregations=[ex.NullaryAggregation(agg_ops.size_op)], dropna=drop_na and not grouping_keys, ) - count_id = block.value_columns[0] + count_id = agg_ids[0] if normalize: - unbound_window = window_specs.unbound(grouping_keys=tuple(grouping_keys)) + 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 ) @@ -493,21 +396,18 @@ def pct_change(block: blocks.Block, periods: int = 1) -> blocks.Block: column_labels = block.column_labels # Window framing clause is not allowed for analytic function lag. - window_spec = window_specs.unbound() + 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 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) + 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) exprs.append(pct_change_expr) - return block.project_block_exprs(exprs, labels=column_labels, drop=True) + return block.project_exprs(exprs, labels=column_labels, drop=True) def rank( @@ -517,7 +417,6 @@ def rank( ascending: bool = True, grouping_cols: tuple[str, ...] = (), columns: tuple[str, ...] = (), - pct: bool = False, ): if method not in ["average", "min", "max", "first", "dense"]: raise ValueError( @@ -528,11 +427,16 @@ def rank( columns = columns or tuple(col for col in block.value_columns) labels = [block.col_id_to_label[id] for id in columns] - - result_exprs = [] + # 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 = [] for col in columns: - # Step 1: Calculate row numbers for each row - # Identify null values to be treated according to na_option param + block, nullity_col_id = block.apply_unary_op( + col, + ops.isnull_op, + ) + nullity_col_ids.append(nullity_col_id) window_ordering = ( ordering.OrderingExpression( ex.deref(col), @@ -543,66 +447,81 @@ 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 - target_expr = ( - ex.deref(col) if na_option == "keep" else ops.isnull_op.as_expr(col) - ) - window_op = agg_ops.dense_rank_op if method == "dense" else agg_ops.count_op - window_spec = ( - window_specs.unbound(grouping_keys=grouping_cols, ordering=window_ordering) + 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( + grouping_keys=grouping_cols, ordering=window_ordering + ) if method == "dense" - else window_specs.rows( + else windows.rows( end=0, ordering=window_ordering, grouping_keys=grouping_cols + ), + skip_reproject_unsafe=(col != columns[-1]), + ) + 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], *grouping_cols)), + skip_reproject_unsafe=(i < (len(columns) - 1)), ) + post_agg_rownum_col_ids.append(result_id) + rownum_col_ids = post_agg_rownum_col_ids + + # 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: ex.Expression = agg_expressions.WindowExpression( - agg_expressions.UnaryAggregation(window_op, target_expr), window_spec + block = block.project_exprs( + [ + ops.where_op.as_expr( + ex.deref(col), + predicate, + ex.const(None), + ) + for col in rownum_col_ids + ], + labels=labels, ) - if pct: - result_expr = ops.div_op.as_expr( - result_expr, - agg_expressions.WindowExpression( - agg_expressions.UnaryAggregation(agg_ops.max_op, result_expr), - window_specs.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), - window_specs.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), + rownum_col_ids = list(block.value_columns[-len(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], + ) ) + return block.project_exprs(exprs, labels=labels, drop=True) - # 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) + return block.select_columns(rownum_col_ids).with_column_labels(labels) def dropna( @@ -676,7 +595,7 @@ def nsmallest( block, counter = block.apply_window_op( column_ids[0], agg_ops.rank_op, - window_spec=window_specs.unbound(ordering=tuple(order_refs)), + window_spec=windows.unbound(ordering=tuple(order_refs)), ) block, condition = block.project_expr(ops.le_op.as_expr(counter, ex.const(n))) block = block.filter_by_id(condition) @@ -706,7 +625,7 @@ def nlargest( block, counter = block.apply_window_op( column_ids[0], agg_ops.rank_op, - window_spec=window_specs.unbound(ordering=tuple(order_refs)), + window_spec=windows.unbound(ordering=tuple(order_refs)), ) block, condition = block.project_expr(ops.le_op.as_expr(counter, ex.const(n))) block = block.filter_by_id(condition) @@ -718,17 +637,44 @@ def skew( skew_column_ids: typing.Sequence[str], grouping_column_ids: typing.Sequence[str] = (), ) -> blocks.Block: + 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 col in original_columns: - aggregations.append(skew_expr(ex.deref(col))) + 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]) - block = block.aggregate( - aggregations, grouping_column_ids, column_labels=column_labels + block, agg_ids = block.aggregate( + by_column_ids=grouping_column_ids, aggregations=aggregations ) + + 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 @@ -745,14 +691,32 @@ def kurt( ) -> blocks.Block: original_columns = skew_column_ids column_labels = block.select_columns(original_columns).column_labels - # counts, moment4 for each column - kurt_exprs = [] - for col in original_columns: - kurt_exprs.append(kurt_expr(ex.deref(col))) - block = block.aggregate( - kurt_exprs, grouping_column_ids, column_labels=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) + + block = block.select_columns(kurt_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 @@ -762,56 +726,39 @@ 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, - col_expr: ex.Expression, -) -> ex.Expression: + column_ids: typing.Sequence[str], + grouping_column_ids: typing.Sequence[str], +) -> typing.Tuple[blocks.Block, typing.Sequence[str]]: """Calculate (x-mean(x))^n. Useful for calculating moment statistics such as skew and kurtosis.""" - 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)) + 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 def _skew_from_moments_and_count( - count: ex.Expression, moment3: ex.Expression, moment2: ex.Expression -) -> ex.Expression: + block: blocks.Block, count_id: str, moment3_id: str, moment2_id: str +) -> typing.Tuple[blocks.Block, str]: # 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, ops.pow_op.as_expr(moment2, ex.const(3 / 2)) + moment3_id, ops.pow_op.as_expr(moment2_id, ex.const(3 / 2)) ) - countminus1 = ops.sub_op.as_expr(count, ex.const(1)) - countminus2 = ops.sub_op.as_expr(count, ex.const(2)) + countminus1 = ops.sub_op.as_expr(count_id, ex.const(1)) + countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) adjustment = ops.div_op.as_expr( ops.unsafe_pow_op.as_expr( - ops.mul_op.as_expr(count, countminus1), ex.const(1 / 2) + ops.mul_op.as_expr(count_id, countminus1), ex.const(1 / 2) ), countminus2, ) @@ -820,14 +767,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, ex.const(3)), ex.const(None) + skew, ops.ge_op.as_expr(count_id, ex.const(3)), ex.const(None) ) - return cleaned_skew + return block.project_expr(cleaned_skew) def _kurt_from_moments_and_count( - count: ex.Expression, moment4: ex.Expression, moment2: ex.Expression -) -> ex.Expression: + block: blocks.Block, count_id: str, moment4_id: str, moment2_id: str +) -> typing.Tuple[blocks.Block, str]: # 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 @@ -836,26 +783,28 @@ def _kurt_from_moments_and_count( # kurtosis = (numerator / denominator) - adjustment numerator = ops.mul_op.as_expr( - moment4, + moment4_id, ops.mul_op.as_expr( - ops.sub_op.as_expr(count, ex.const(1)), - ops.add_op.as_expr(count, ex.const(1)), + ops.sub_op.as_expr(count_id, ex.const(1)), + ops.add_op.as_expr(count_id, ex.const(1)), ), ) # Denominator - countminus2 = ops.sub_op.as_expr(count, ex.const(2)) - countminus3 = ops.sub_op.as_expr(count, ex.const(3)) + countminus2 = ops.sub_op.as_expr(count_id, ex.const(2)) + countminus3 = ops.sub_op.as_expr(count_id, ex.const(3)) # Denominator denominator = ops.mul_op.as_expr( - ops.unsafe_pow_op.as_expr(moment2, ex.const(2)), + ops.unsafe_pow_op.as_expr(moment2_id, 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, ex.const(1)), ex.const(2)), + ops.unsafe_pow_op.as_expr( + ops.sub_op.as_expr(count_id, ex.const(1)), ex.const(2) + ), ex.const(3), ) adj_denom = ops.mul_op.as_expr(countminus2, countminus3) @@ -866,9 +815,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, ex.const(4)), ex.const(None) + kurt, ops.ge_op.as_expr(count_id, ex.const(4)), ex.const(None) ) - return cleaned_kurt + return block.project_expr(cleaned_kurt) def align( @@ -977,7 +926,7 @@ def _idx_extrema( for idx_col in original_block.index_columns ], ] - window_spec = window_specs.unbound(ordering=tuple(order_refs)) + window_spec = windows.unbound(ordering=tuple(order_refs)) idx_col = original_block.index_columns[0] block, result_col = block.apply_window_op( idx_col, agg_ops.first_op, window_spec diff --git a/bigframes/core/blocks.py b/bigframes/core/blocks.py index 8522a4d97be..283f56fd394 100644 --- a/bigframes/core/blocks.py +++ b/bigframes/core/blocks.py @@ -27,8 +27,8 @@ import functools import itertools import random +import textwrap import typing -import warnings from typing import ( Iterable, Iterator, @@ -40,6 +40,7 @@ Tuple, Union, ) +import warnings import bigframes_vendored.constants as constants import google.cloud.bigquery as bigquery @@ -47,9 +48,12 @@ import pandas as pd import pyarrow as pa +from bigframes import session +from bigframes._config import sampling_options import bigframes.constants +from bigframes.core import local_data import bigframes.core as core -import bigframes.core.agg_expressions as ex_types +import bigframes.core.compile.googlesql as googlesql import bigframes.core.expression as ex import bigframes.core.expression as scalars import bigframes.core.guid as guid @@ -57,18 +61,16 @@ import bigframes.core.join_def as join_defs import bigframes.core.ordering as ordering import bigframes.core.pyarrow_utils as pyarrow_utils +import bigframes.core.schema as bf_schema +import bigframes.core.sql as sql import bigframes.core.utils as utils import bigframes.core.window_spec as windows import bigframes.dtypes import bigframes.exceptions as bfe import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -from bigframes import session -from bigframes._config import sampling_options -from bigframes.core import agg_expressions, local_data -from bigframes.session import dry_runs, execution_spec +from bigframes.session import dry_runs 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 @@ -95,28 +97,20 @@ LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] +@dataclasses.dataclass class PandasBatches(Iterator[pd.DataFrame]): """Interface for mutable objects with state represented by a block value object.""" def __init__( - self, - pandas_batches: Iterator[pd.DataFrame], - total_rows: Optional[int] = 0, - *, - total_bytes_processed: Optional[int] = 0, + self, pandas_batches: Iterator[pd.DataFrame], total_rows: 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 __next__(self) -> pd.DataFrame: return next(self._dataframes) @@ -140,7 +134,6 @@ def __init__( column_labels: typing.Union[pd.Index, typing.Iterable[Label]], index_labels: typing.Union[pd.Index, typing.Iterable[Label], None] = None, *, - value_columns: Optional[Iterable[str]] = None, transpose_cache: Optional[Block] = None, ): """Construct a block object, will create default index if no index columns specified.""" @@ -159,13 +152,7 @@ def __init__( if index_labels else tuple([None for _ in index_columns]) ) - if value_columns is None: - value_columns = [ - col_id for col_id in expr.column_ids if col_id not in index_columns - ] - self._expr = self._normalize_expression( - expr, self._index_columns, value_columns - ) + self._expr = self._normalize_expression(expr, self._index_columns) # Use pandas index to more easily replicate column indexing, especially for hierarchical column index self._column_labels = ( column_labels.copy() @@ -255,10 +242,6 @@ 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.""" @@ -274,14 +257,7 @@ def shape(self) -> typing.Tuple[int, int]: except Exception: pass - row_count = ( - self.session._executor.execute( - self.expr.row_count(), - execution_spec.ExecutionSpec(promise_under_10gb=True, ordered=False), - ) - .batches() - .to_py_scalar() - ) + row_count = self.session._executor.execute(self.expr.row_count()).to_py_scalar() return (row_count, len(self.value_columns)) @property @@ -395,10 +371,9 @@ def cols_matching_label(self, partial_label: Label) -> typing.Sequence[str]: def order_by( self, by: typing.Sequence[ordering.OrderingExpression], - stable: bool = True, ) -> Block: return Block( - self._expr.order_by(by, stable=stable), + self._expr.order_by(by), index_columns=self.index_columns, column_labels=self.column_labels, index_labels=self.index.names, @@ -420,21 +395,19 @@ def reset_index( 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 + allow_duplicates: Returns: A new Block because dropping index columns can break references from Index classes that point to this block. """ - if level is not None: + if level: # 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) @@ -443,19 +416,23 @@ def reset_index( level_ids = self.index_columns expr = self._expr - 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: + elif ( + self.session._default_index_type + == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 + ): expr, new_index_col_id = expr.promote_offsets() new_index_cols = [new_index_col_id] new_index_labels = [None] - elif replacement_idx_type == bigframes.enums.DefaultIndexKind.NULL: + elif self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL: new_index_cols = [] new_index_labels = [] else: - raise ValueError(f"Unrecognized default index kind: {replacement_idx_type}") + raise ValueError( + f"Unrecognized default index kind: {self.session._default_index_type}" + ) if drop: # Even though the index might be part of the ordering, keep that @@ -580,19 +557,10 @@ def to_arrow( allow_large_results: Optional[bool] = None, ) -> Tuple[pa.Table, Optional[bigquery.QueryJob]]: """Run query and download results as a pyarrow 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, - ), + self.expr, ordered=ordered, use_explicit_destination=allow_large_results ) - pa_table = execute_result.batches().to_arrow_table() + pa_table = execute_result.to_arrow_table() pa_index_labels = [] for index_level, index_label in enumerate(self._index_labels): @@ -644,13 +612,15 @@ def to_pandas( max_download_size, sampling_method, random_state ) - return self._materialize_local( + df, query_job = self._materialize_local( materialize_options=MaterializationOptions( downsampling=sampling, allow_large_results=allow_large_results, ordered=ordered, ) ) + df.set_axis(self.column_labels, axis=1, copy=False) + return df, query_job def _get_sampling_option( self, @@ -658,6 +628,7 @@ def _get_sampling_option( 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, " @@ -676,17 +647,10 @@ def try_peek( self, n: int = 20, force: bool = False, allow_large_results=None ) -> typing.Optional[pd.DataFrame]: if force or self.expr.supports_fast_peek: - # 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.peek( + self.expr, n, use_explicit_destination=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() + df = result.to_pandas() return self._copy_index_to_pandas(df) else: return None @@ -696,60 +660,41 @@ def to_pandas_batches( page_size: Optional[int] = None, max_results: Optional[int] = None, allow_large_results: Optional[bool] = None, - cell_execution_count: Optional[int] = None, - ) -> PandasBatches: + ) -> Iterator[pd.DataFrame]: """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 - """ - - 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( + 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( self.expr, - execution_spec.ExecutionSpec( - promise_under_10gb=under_10gb, - ordered=True, - cell_execution_count=cell_execution_count, - ), + ordered=True, + use_explicit_destination=allow_large_results, ) - 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) + empty_val = pd.DataFrame( + { + col: pd.Series([], dtype=self.expr.get_column_type(col)) + for col in itertools.chain(self.value_columns, self.index_columns) + } + ) dfs = map( lambda a: a[0], itertools.zip_longest( - result_batches.to_pandas_batches(page_size, max_results), + execute_result.to_pandas_batches(page_size, max_results), [0], fillvalue=empty_val, ), ) dfs = iter(map(self._copy_index_to_pandas, dfs)) - total_rows = result_batches.approx_total_rows + total_rows = execute_result.total_rows if (total_rows is not None) and (max_results is not None): total_rows = min(total_rows, max_results) - return PandasBatches( - dfs, - total_rows, - total_bytes_processed=execution_result.total_bytes_processed, - ) + return PandasBatches(dfs, total_rows) def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: """Set the index on pandas DataFrame to match this block.""" @@ -766,26 +711,17 @@ def _copy_index_to_pandas(self, df: pd.DataFrame) -> pd.DataFrame: 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, - execution_spec.ExecutionSpec( - promise_under_10gb=under_10gb, - ordered=materialize_options.ordered, - ), + ordered=materialize_options.ordered, + use_explicit_destination=materialize_options.allow_large_results, ) - result_batches = execute_result.batches() - sample_config = materialize_options.downsampling - if result_batches.approx_total_bytes is not None: - table_mb = result_batches.approx_total_bytes / _BYTES_TO_MEGABYTES + if execute_result.total_bytes is not None: + table_mb = execute_result.total_bytes / _BYTES_TO_MEGABYTES max_download_size = sample_config.max_download_size fraction = ( max_download_size / table_mb @@ -806,7 +742,7 @@ def _materialize_local( # TODO: Maybe materialize before downsampling # Some downsampling methods - if fraction < 1 and (result_batches.approx_total_rows is not None): + if fraction < 1 and (execute_result.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 " @@ -825,33 +761,50 @@ def _materialize_local( "the downloading limit." ) warnings.warn(msg, category=UserWarning) - total_rows = result_batches.approx_total_rows + total_rows = execute_result.total_rows # Remove downsampling config from subsequent invocations, as otherwise could result in many # iterations if downsampling undershoots - if sample_config.sampling_method == "head": - # Just truncates the result iterator without a follow-up query - raw_df = result_batches.to_pandas(limit=int(total_rows * fraction)) - elif ( - sample_config.sampling_method == "uniform" - and sample_config.random_state is None - ): - # Pushes sample into result without new query - sampled_batches = execute_result.batches(sample_rate=fraction) - raw_df = sampled_batches.to_pandas() - else: # uniform sample with random state requires a full follow-up query - down_sampled_block = self.split( - fracs=(fraction,), - random_state=sample_config.random_state, - sort=False, - )[0] - return down_sampled_block._materialize_local( - MaterializationOptions(ordered=materialize_options.ordered) - ) + return self._downsample( + total_rows=total_rows, + sampling_method=sample_config.sampling_method, + fraction=fraction, + random_state=sample_config.random_state, + )._materialize_local( + MaterializationOptions(ordered=materialize_options.ordered) + ) else: - raw_df = result_batches.to_pandas() - df = self._copy_index_to_pandas(raw_df) - df.columns = self.column_labels - return df, execute_result.query_job + df = execute_result.to_pandas() + return self._copy_index_to_pandas(df), execute_result.query_job + + def _downsample( + self, total_rows: int, sampling_method: str, fraction: float, random_state + ) -> Block: + # either selecting fraction or number of rows + if sampling_method == _HEAD: + filtered_block = self.slice(stop=int(total_rows * fraction)) + return filtered_block + elif (sampling_method == _UNIFORM) and (random_state is None): + filtered_expr = self.expr._uniform_sampling(fraction) + block = Block( + filtered_expr, + index_columns=self.index_columns, + column_labels=self.column_labels, + index_labels=self.index.names, + ) + return block + elif sampling_method == _UNIFORM: + block = self.split( + fracs=(fraction,), + random_state=random_state, + sort=False, + )[0] + return block + else: + # This part should never be called, just in case. + raise NotImplementedError( + f"The downsampling method {sampling_method} is not implemented, " + f"please choose from {','.join(_SAMPLING_METHODS)}." + ) def split( self, @@ -965,7 +918,7 @@ def _compute_dry_run( } dry_run_stats = dry_runs.get_query_stats_with_dtypes( - query_job, column_dtypes, self.index.dtypes, self.expr.node + query_job, column_dtypes, self.index.dtypes ) return dry_run_stats, query_job @@ -1081,26 +1034,34 @@ def multi_apply_window_op( window_spec: windows.WindowSpec, *, skip_null_groups: bool = False, + never_skip_nulls: bool = False, ) -> typing.Tuple[Block, typing.Sequence[str]]: - 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, - ) + 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 def multi_apply_unary_op( self, - op: Union[ops.UnaryOp, ops.NaryOp, ex.Expression], + op: Union[ops.UnaryOp, ex.Expression], ) -> Block: - if isinstance(op, (ops.UnaryOp, ops.NaryOp)): + if isinstance(op, ops.UnaryOp): input_varname = guid.generate_guid() expr = op.as_expr(ex.free_var(input_varname)) else: input_varnames = op.free_variables - assert len(set(input_varnames)) == 1 + assert len(input_varnames) == 1 expr = op input_varname = input_varnames[0] @@ -1124,35 +1085,10 @@ def project_exprs( labels: Union[Sequence[Label], pd.Index], drop=False, ) -> Block: - new_array, new_cols = self.expr.compute_values(exprs) - if drop: - new_array = new_array.drop_columns(self.value_columns) - - new_val_cols = new_cols if drop else (*self.value_columns, *new_cols) - return Block( - new_array, - index_columns=self.index_columns, - value_columns=new_val_cols, - column_labels=labels - if drop - else self.column_labels.append(pd.Index(labels)), - 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) + new_array, _ = self.expr.compute_values(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, @@ -1162,55 +1098,6 @@ def project_block_exprs( 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, @@ -1219,39 +1106,48 @@ def apply_window_op( *, 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], + agg_expr = ex.UnaryAggregation(op, ex.deref(column)) + return self.apply_analytic( + agg_expr, window_spec, - [result_label], + result_label, + skip_reproject_unsafe=skip_reproject_unsafe, + never_skip_nulls=never_skip_nulls, skip_null_groups=skip_null_groups, ) - return block, ids[0] def apply_analytic( self, - agg_exprs: Iterable[agg_expressions.Aggregation], + agg_expr: ex.Aggregation, window: windows.WindowSpec, - result_labels: Iterable[Label], + result_label: Label, *, + skip_reproject_unsafe: bool = False, + never_skip_nulls: bool = False, skip_null_groups: bool = False, - ) -> typing.Tuple[Block, Sequence[str]]: + ) -> typing.Tuple[Block, str]: block = self if skip_null_groups: 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), + block = block.filter(ops.notnull_op.as_expr(key.id.name)) + expr, result_id = block._expr.project_window_expr( + agg_expr, window, + skip_reproject_unsafe=skip_reproject_unsafe, + never_skip_nulls=never_skip_nulls, ) block = Block( expr, index_columns=self.index_columns, - column_labels=self.column_labels.append(pd.Index(result_labels)), + column_labels=self.column_labels.insert( + len(self.column_labels), result_label + ), index_labels=self._index_labels, ) - return (block, result_ids) + return (block, result_id) def copy_values(self, source_column_id: str, destination_column_id: str) -> Block: expr = self.expr.assign(source_column_id, destination_column_id) @@ -1318,9 +1214,9 @@ def aggregate_all_and_stack( if axis_n == 0: aggregations = [ ( - agg_expressions.UnaryAggregation(operation, ex.deref(col_id)) + ex.UnaryAggregation(operation, ex.deref(col_id)) if isinstance(operation, agg_ops.UnaryAggregateOp) - else agg_expressions.NullaryAggregation(operation), + else ex.NullaryAggregation(operation), col_id, ) for col_id in self.value_columns @@ -1339,7 +1235,35 @@ def aggregate_all_and_stack( 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])) + return block.select_column(id) + + 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, + ) def select_column(self, id: str) -> Block: return self.select_columns([id]) @@ -1389,6 +1313,57 @@ 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, @@ -1410,9 +1385,9 @@ def get_stat( aggregations = [ ( - agg_expressions.UnaryAggregation(stat, ex.deref(column_id)) + ex.UnaryAggregation(stat, ex.deref(column_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else agg_expressions.NullaryAggregation(stat), + else ex.NullaryAggregation(stat), stat.name, ) for stat in stats_to_fetch @@ -1438,7 +1413,7 @@ def get_binary_stat( # TODO(kemppeterson): Add a cache here. aggregations = [ ( - agg_expressions.BinaryAggregation( + ex.BinaryAggregation( stat, ex.deref(column_id_left), ex.deref(column_id_right) ), f"{stat.name}_{column_id_left}{column_id_right}", @@ -1465,9 +1440,9 @@ def summarize( labels = pd.Index([stat.name for stat in stats]) aggregations = [ ( - agg_expressions.UnaryAggregation(stat, ex.deref(col_id)) + ex.UnaryAggregation(stat, ex.deref(col_id)) if isinstance(stat, agg_ops.UnaryAggregateOp) - else agg_expressions.NullaryAggregation(stat), + else ex.NullaryAggregation(stat), f"{col_id}-{stat.name}", ) for stat in stats @@ -1554,13 +1529,17 @@ def _get_labels_for_columns(self, column_ids: typing.Sequence[str]) -> pd.Index: def _normalize_expression( self, expr: core.ArrayValue, - index_columns: Iterable[str], - value_columns: Iterable[str], + index_columns: typing.Sequence[str], + assert_value_size: typing.Optional[int] = None, ): """Normalizes expression by moving index columns to left.""" - normalized_ids = (*index_columns, *value_columns) - if tuple(expr.column_ids) == normalized_ids: - return expr + value_columns = [ + col_id for col_id in expr.column_ids if col_id not in index_columns + ] + if (assert_value_size is not None) and ( + len(value_columns) != assert_value_size + ): + raise ValueError("Unexpected number of value columns.") return expr.select_columns([*index_columns, *value_columns]) def grouped_head( @@ -1619,25 +1598,11 @@ def retrieve_repr_request_results( 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() + self.expr.slice(start=None, stop=max_results, step=None) ) + row_count = self.session._executor.execute(self.expr.row_count()).to_py_scalar() - head_df = head_result.batches().to_pandas() + head_df = head_result.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]: @@ -1741,10 +1706,10 @@ def pivot( block = block.select_columns(column_ids) aggregations = [ - agg_expressions.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(col_id)) + ex.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, @@ -1757,9 +1722,7 @@ def pivot( else: return result_block.with_column_labels(columns_values) - def stack( - self, how="left", levels: int = 1, *, override_labels: Optional[pd.Index] = None - ): + def stack(self, how="left", levels: int = 1): """Unpivot last column axis level into row axis""" if levels == 0: return self @@ -1767,9 +1730,7 @@ def stack( # 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() if override_labels is None else override_labels - ) + row_labels = row_labels.drop_duplicates() if col_labels is None: result_index: pd.Index = pd.Index([None]) @@ -1825,9 +1786,9 @@ def melt( Arguments correspond to pandas.melt arguments. """ # TODO: Implement col_level and ignore_index - value_labels: pd.Index = self.column_labels[ - [self.value_columns.index(col_id) for col_id in value_vars] - ] + value_labels: pd.Index = pd.Index( + [self.col_id_to_label[col_id] for col_id in value_vars] + ) id_labels = [self.col_id_to_label[col_id] for col_id in id_vars] unpivot_expr, (var_col_ids, unpivot_out, passthrough_cols) = unpivot( @@ -1952,31 +1913,6 @@ 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: @@ -1991,10 +1927,6 @@ def _generate_resample_label( ) level = level or 0 col_id = self.index.resolve_level(level)[0] - if isinstance(level, int): - resample_label = self.index.names[level] - else: - resample_label = level # Reset index to make the resampling level a column, then drop all other index columns. # This simplifies processing by focusing solely on the column required for resampling. block = self.reset_index(drop=False) @@ -2013,7 +1945,6 @@ def _generate_resample_label( raise KeyError(f"The grouper name {on} is not found") col_id = matches[0] - resample_label = on block = self if level is None: dtype = self._column_type(col_id) @@ -2043,7 +1974,7 @@ def _generate_resample_label( agg_specs = [ ( - agg_expressions.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), + ex.UnaryAggregation(agg_ops.min_op, ex.deref(col_id)), guid.generate_guid(), ), ] @@ -2072,13 +2003,13 @@ def _generate_resample_label( # Generate integer label sequence. min_agg_specs = [ ( - ex_types.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), + ex.UnaryAggregation(agg_ops.min_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] max_agg_specs = [ ( - ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), + ex.UnaryAggregation(agg_ops.max_op, ex.deref(label_col_id)), guid.generate_guid(), ), ] @@ -2106,7 +2037,6 @@ def _generate_resample_label( block.value_columns[0], block.value_columns[1], op=ops.IntegerLabelToDatetimeOp(freq=freq, label=label, origin=origin), - result_label=resample_label, ) # After multiple merges, the columns: @@ -2199,17 +2129,9 @@ def _get_unique_values( import bigframes.core.block_transforms as block_tf import bigframes.dataframe as df - 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) - + unique_value_block = block_tf.drop_duplicates( + self.select_columns(columns), columns + ) pd_values = ( df.DataFrame(unique_value_block).head(max_unique_values + 1).to_pandas() ) @@ -2267,7 +2189,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) + expr, matches = self._expr.isin(unique_values, col, unique_values.column_ids[0]) new_value_cols = tuple( val_col if val_col != col else matches for val_col in self.value_columns @@ -2294,8 +2216,6 @@ 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) @@ -2303,52 +2223,34 @@ 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) - 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 = [] + joined_expr, coalesced_ids = coalesce_columns( + joined_expr, left_post_join_ids, right_post_join_ids, how=how, drop=False + ) - # 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 ( - right_index - or self.col_id_to_label[col_id] + 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(resolved_join_ids[key_part]) + result_columns.append(coalesced_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[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: @@ -2359,22 +2261,11 @@ def merge( joined_expr = joined_expr.order_by( [ ordering.OrderingExpression(ex.deref(col_id)) - for col_id in resolved_join_ids + for col_id in coalesced_ids ], ) - 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) + joined_expr = joined_expr.select_columns(result_columns) labels = utils.merge_column_labels( self.column_labels, other.column_labels, @@ -2393,13 +2284,13 @@ def merge( or other.index.is_null or self.session._default_index_type == bigframes.enums.DefaultIndexKind.NULL ): - return Block(joined_expr, index_columns=[], column_labels=labels) - elif index_cols: - return Block(joined_expr, index_columns=index_cols, column_labels=labels) + expr = joined_expr + index_columns = [] 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 @@ -2421,13 +2312,13 @@ def _align_both_axes( rcol_indexer if (rcol_indexer is not None) else range(len(columns)) ) - left_input_lookup = lambda index: ( - ex.deref(get_column_left[self.value_columns[index]]) + left_input_lookup = ( + lambda index: ex.deref(get_column_left[self.value_columns[index]]) if index != -1 else ex.const(None) ) - righ_input_lookup = lambda index: ( - ex.deref(get_column_right[other.value_columns[index]]) + righ_input_lookup = ( + lambda index: ex.deref(get_column_right[other.value_columns[index]]) if index != -1 else ex.const(None) ) @@ -2480,13 +2371,15 @@ def _align_series_block_axis_1( rcol_indexer if (rcol_indexer is not None) else range(len(columns)) ) - left_input_lookup = lambda index: ( - ex.deref(get_column_left[self.value_columns[index]]) + left_input_lookup = ( + lambda index: ex.deref(get_column_left[self.value_columns[index]]) if index != -1 else ex.const(None) ) - righ_input_lookup = lambda index: ( - ex.deref(get_column_right[other.transpose().value_columns[index]]) + righ_input_lookup = ( + lambda index: ex.deref( + get_column_right[other.transpose().value_columns[index]] + ) if index != -1 else ex.const(None) ) @@ -2513,11 +2406,13 @@ def _align_pd_series_axis_1( rcol_indexer if (rcol_indexer is not None) else range(len(columns)) ) - left_input_lookup = lambda index: ( - ex.deref(self.value_columns[index]) if index != -1 else ex.const(None) + left_input_lookup = ( + lambda index: ex.deref(self.value_columns[index]) + if index != -1 + else ex.const(None) ) - righ_input_lookup = lambda index: ( - ex.const(other.iloc[index]) if index != -1 else ex.const(None) + righ_input_lookup = ( + lambda index: ex.const(other.iloc[index]) if index != -1 else ex.const(None) ) left_inputs = [left_input_lookup(i) for i in lcol_indexer] @@ -2550,10 +2445,7 @@ def join( sort: bool = False, block_identity_join: bool = False, always_order: bool = False, - ) -> Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], - ]: + ) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: """ Join two blocks objects together, and provide mappings between source columns and output columns. @@ -2801,11 +2693,8 @@ def _is_monotonic( ) block = block.drop_columns([equal_monotonic_id, strict_monotonic_id]) - assert last_result_id is not None block, monotonic_result_id = block.apply_binary_op( - last_result_id, - last_notna_id, - ops.and_op, # type: ignore + last_result_id, last_notna_id, ops.and_op # type: ignore ) if last_result_id is not None: block = block.drop_columns([last_result_id, last_notna_id]) @@ -2820,6 +2709,14 @@ 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. @@ -2835,44 +2732,93 @@ 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 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) - ) - ) + if isinstance(type_, pd.ArrowDtype) and pa.types.is_binary( + type_.pyarrow_dtype + ): + column_references.append(sql.to_json_string(col)) else: - column_references.append( - ops.AsTypeOp(bigframes.dtypes.STRING_DTYPE).as_expr(col) - ) + 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] + ) # 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 = str(pandas_row_dtype) - - struct_op = ops.StructOp( - column_names=("names", "types", "values", "indexlength", "dtype") + 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 + destination, query_job = self.session._loader._query_to_destination( + json_sql, cluster_candidates=[ordering_column_name] + ) + 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) + ) ) - 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 + + dest_table = self.session.bqclient.get_table(destination) + expr = core.ArrayValue.from_table( + dest_table, + schema=new_schema, + session=self.session, + offsets_col=ordering_column_name, + n_rows=dest_table.num_rows, + ).drop_columns([ordering_column_name]) + block = Block( + expr, + index_columns=self.index_columns, + column_labels=[row_json_column_name], + index_labels=self._index_labels, ) - block, col_id = self.project_expr(ops.ToJSONString().as_expr(struct_expr)) - return block.select_column(col_id) + return block class BlockIndexProperties: @@ -2974,12 +2920,7 @@ def is_uniquely_named(self: BlockIndexProperties): def try_new_row_join( left: Block, right: Block -) -> Optional[ - Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], - ] -]: +) -> Optional[Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]]: join_keys = tuple( (left_id, right_id) for left_id, right_id in zip(left.index_columns, right.index_columns) @@ -3010,12 +2951,7 @@ def try_legacy_row_join( right: Block, *, how="left", -) -> Optional[ - Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], - ] -]: +) -> Optional[Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]]: """Joins two blocks that have a common root expression by merging the projections.""" left_expr = left.expr right_expr = right.expr @@ -3069,10 +3005,7 @@ def try_legacy_row_join( def join_with_single_row( left: Block, single_row_block: Block, -) -> Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], -]: +) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: """ Special join case where other is a single row block. This property is not validated, caller responsible for not passing multi-row block. @@ -3107,10 +3040,7 @@ def join_mono_indexed( how="left", sort: bool = False, propogate_order: bool = False, -) -> Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], -]: +) -> Tuple[Block, Tuple[Mapping[str, str], Mapping[str, str]],]: left_expr = left.expr right_expr = right.expr @@ -3126,7 +3056,7 @@ def join_mono_indexed( 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 = resolve_col_join_ids( + combined_expr, coalesced_join_cols = coalesce_columns( combined_expr, [left_index], [right_index], how=how ) if sort: @@ -3157,10 +3087,7 @@ def join_multi_indexed( how="left", sort: bool = False, propogate_order: bool = False, -) -> Tuple[ - Block, - Tuple[Mapping[str, str], Mapping[str, str]], -]: +) -> 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") @@ -3194,7 +3121,7 @@ def join_multi_indexed( 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 = resolve_col_join_ids( + combined_expr, coalesced_join_cols = coalesce_columns( combined_expr, left_ids_post_join, right_ids_post_join, how=how ) if sort: @@ -3237,17 +3164,13 @@ def resolve_label_id(label: Label) -> str: # TODO: Rewrite just to return expressions -def resolve_col_join_ids( +def coalesce_columns( 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": @@ -3259,6 +3182,7 @@ def resolve_col_join_ids( 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) ) @@ -3270,21 +3194,6 @@ def resolve_col_join_ids( 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 = [] @@ -3447,7 +3356,6 @@ def unpivot( joined_array, (labels_mapping, column_mapping) = labels_array.relational_join( array_value, type="cross" ) - new_passthrough_cols = [column_mapping[col] for col in passthrough_columns] # Last column is offsets index_col_ids = [labels_mapping[col] for col in labels_array.column_ids[:-1]] @@ -3457,24 +3365,20 @@ def unpivot( unpivot_exprs: List[ex.Expression] = [] # Supports producing multiple stacked ouput columns for stacking only part of hierarchical index for input_ids in unpivot_columns: - col_expr: ex.Expression - if not input_ids: - col_expr = ex.const(None, dtype=bigframes.dtypes.INT_DTYPE) - else: - # row explode offset used to choose the input column - # we use offset instead of label as labels are not necessarily unique - cases = itertools.chain( - *( - ( - ops.eq_op.as_expr(explode_offsets_id, ex.const(i)), - ex.deref(column_mapping[id_or_null]) - if (id_or_null is not None) - else ex.const(None), - ) - for i, id_or_null in enumerate(input_ids) + # row explode offset used to choose the input column + # we use offset instead of label as labels are not necessarily unique + cases = itertools.chain( + *( + ( + ops.eq_op.as_expr(explode_offsets_id, ex.const(i)), + ex.deref(column_mapping[id_or_null]) + if (id_or_null is not None) + else ex.const(None), ) + for i, id_or_null in enumerate(input_ids) ) - col_expr = ops.case_when_op.as_expr(*cases) + ) + col_expr = ops.case_when_op.as_expr(*cases) unpivot_exprs.append(col_expr) joined_array, unpivot_col_ids = joined_array.compute_values(unpivot_exprs) @@ -3492,72 +3396,16 @@ def _pd_index_to_array_value( Create an ArrayValue from a list of label tuples. The last column will be row offsets. """ - id_gen = bigframes.core.identifiers.standard_id_strings() - col_ids = [next(id_gen) for _ in range(index.nlevels)] - offset_id = next(id_gen) - rows = [] labels_as_tuples = utils.index_as_tuples(index) for row_offset in range(len(index)): + id_gen = bigframes.core.identifiers.standard_id_strings() row_label = labels_as_tuples[row_offset] row_label = (row_label,) if not isinstance(row_label, tuple) else row_label row = {} - for label_part, col_id in zip(row_label, col_ids): - row[col_id] = label_part if pd.notnull(label_part) else None - row[offset_id] = row_offset + for label_part, id in zip(row_label, id_gen): + row[id] = label_part if pd.notnull(label_part) else None + row[next(id_gen)] = row_offset rows.append(row) - if not rows: - dtypes_list = getattr(index, "dtypes", None) - if dtypes_list is None: - dtypes_list = ( - [index.dtype] if hasattr(index, "dtype") else [pd.Float64Dtype()] - ) - - fields = [] - for col_id, dtype in zip(col_ids, dtypes_list): - try: - pa_type = bigframes.dtypes.bigframes_dtype_to_arrow_dtype(dtype) - except Exception: - pa_type = pa.string() - fields.append(pa.field(col_id, pa_type)) - fields.append(pa.field(offset_id, pa.int64())) - schema = pa.schema(fields) - pt = pa.Table.from_pylist([], schema=schema) - else: - pt = pa.Table.from_pylist(rows) - pt = pt.rename_columns([*col_ids, offset_id]) - - return core.ArrayValue.from_pyarrow(pt, 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 [] + return core.ArrayValue.from_pyarrow(pa.Table.from_pylist(rows), session=session) diff --git a/bigframes/core/bq_data.py b/bigframes/core/bq_data.py deleted file mode 100644 index 55ac1270b6c..00000000000 --- a/bigframes/core/bq_data.py +++ /dev/null @@ -1,410 +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 concurrent.futures -import dataclasses -import datetime -import functools -import os -import queue -import threading -import typing -from typing import Any, Iterator, List, Literal, Optional, Sequence, Tuple, Union - -import google.cloud.bigquery as bq -import google.cloud.bigquery_storage_v1.types as bq_storage_types -import pyarrow as pa -from google.cloud import bigquery_storage_v1 -from google.protobuf import timestamp_pb2 - -import bigframes.constants -import bigframes.core.schema -from bigframes.core import pyarrow_utils - -if typing.TYPE_CHECKING: - import bigframes.core.ordering as orderings - - -def _resolve_standard_gcp_region(bq_region: str): - """ - Resolve bq regions to standardized - """ - if bq_region.casefold() == "US": - return "us-central1" - elif bq_region.casefold() == "EU": - return "europe-west4" - return bq_region - - -def is_irc_table(table_id: str): - """ - Determines if a table id should be resolved through the iceberg rest catalog. - """ - return len(table_id.split(".")) == 4 - - -def is_compatible( - data_region: Union[GcsRegion, BigQueryRegion], session_location: str -) -> bool: - # based on https://docs.cloud.google.com/bigquery/docs/locations#storage-location-considerations - if isinstance(data_region, BigQueryRegion): - return data_region.name == session_location - else: - assert isinstance(data_region, GcsRegion) - # TODO(b/463675088): Multi-regions don't yet support rest catalog tables - if session_location in bigframes.constants.BIGQUERY_MULTIREGIONS: - return False - return _resolve_standard_gcp_region(session_location) in data_region.included - - -def get_default_bq_region(data_region: Union[GcsRegion, BigQueryRegion]) -> str: - if isinstance(data_region, BigQueryRegion): - return data_region.name - elif isinstance(data_region, GcsRegion): - # should maybe try to track and prefer primary replica? - return data_region.included[0] - - -@dataclasses.dataclass(frozen=True) -class BigQueryRegion: - name: str - - -@dataclasses.dataclass(frozen=True) -class GcsRegion: - # this is the name of gcs regions, which may be names for multi-regions, so shouldn't be compared with non-gcs locations - storage_regions: tuple[str, ...] - # this tracks all the included standard, specific regions (eg us-east1), and should be comparable to bq regions (except non-standard US, EU, omni regions) - included: tuple[str, ...] - - -# what is the line between metadata and core fields? Mostly metadata fields are optional or unreliable, but its fuzzy -@dataclasses.dataclass(frozen=True) -class TableMetadata: - # this size metadata might be stale, don't use where strict correctness is needed - location: Union[BigQueryRegion, GcsRegion] - type: Literal["TABLE", "EXTERNAL", "VIEW", "MATERIALIZE_VIEW", "SNAPSHOT"] - numBytes: Optional[int] = None - numRows: Optional[int] = None - created_time: Optional[datetime.datetime] = None - modified_time: Optional[datetime.datetime] = None - - -@dataclasses.dataclass(frozen=True) -class GbqNativeTable: - project_id: str = dataclasses.field() - dataset_id: str = dataclasses.field() - table_id: str = dataclasses.field() - physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() - metadata: TableMetadata = dataclasses.field() - partition_col: Optional[str] = None - cluster_cols: typing.Optional[Tuple[str, ...]] = None - primary_key: Optional[Tuple[str, ...]] = None - - @staticmethod - def from_table(table: bq.Table, columns: Sequence[str] = ()) -> GbqNativeTable: - # 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) - - metadata = TableMetadata( - numBytes=table.num_bytes, - numRows=table.num_rows, - location=BigQueryRegion(table.location), # type: ignore - type=table.table_type or "TABLE", # type: ignore - created_time=table.created, - modified_time=table.modified, - ) - partition_col = None - if table.range_partitioning: - partition_col = table.range_partitioning.field - elif table.time_partitioning: - partition_col = table.time_partitioning.field - - return GbqNativeTable( - project_id=table.project, - dataset_id=table.dataset_id, - table_id=table.table_id, - physical_schema=schema, - partition_col=partition_col, - cluster_cols=None - if (table.clustering_fields is None) - else tuple(table.clustering_fields), - primary_key=tuple(_get_primary_keys(table)), - metadata=metadata, - ) - - @staticmethod - def from_ref_and_schema( - table_ref: bq.TableReference, - schema: Sequence[bq.SchemaField], - location: str, - table_type: Literal["TABLE"] = "TABLE", - cluster_cols: Optional[Sequence[str]] = None, - ) -> GbqNativeTable: - return GbqNativeTable( - project_id=table_ref.project, - dataset_id=table_ref.dataset_id, - table_id=table_ref.table_id, - metadata=TableMetadata(location=BigQueryRegion(location), type=table_type), - physical_schema=tuple(schema), - cluster_cols=tuple(cluster_cols) if cluster_cols else None, - ) - - @property - def is_physically_stored(self) -> bool: - return self.metadata.type in ["TABLE", "MATERIALIZED_VIEW"] - - def get_table_ref(self) -> bq.TableReference: - return bq.TableReference( - bq.DatasetReference(self.project_id, self.dataset_id), self.table_id - ) - - def get_full_id(self, quoted: bool = False) -> str: - if quoted: - return f"`{self.project_id}`.`{self.dataset_id}`.`{self.table_id}`" - return f"{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 BiglakeIcebergTable: - project_id: str = dataclasses.field() - catalog_id: str = dataclasses.field() - namespace_id: str = dataclasses.field() - table_id: str = dataclasses.field() - physical_schema: Tuple[bq.SchemaField, ...] = dataclasses.field() - cluster_cols: typing.Optional[Tuple[str, ...]] - metadata: TableMetadata - - def get_full_id(self, quoted: bool = False) -> str: - if quoted: - return f"`{self.project_id}`.`{self.catalog_id}`.`{self.namespace_id}`.`{self.table_id}`" - return ( - f"{self.project_id}.{self.catalog_id}.{self.namespace_id}.{self.table_id}" - ) - - @property - @functools.cache - def schema_by_id(self): - return {col.name: col for col in self.physical_schema} - - @property - def partition_col(self) -> Optional[str]: - # TODO: Use iceberg partition metadata - return None - - @property - def dataset_id(self) -> str: - """ - Not a true dataset, but serves as the dataset component of the identifer in sql queries - """ - return f"{self.catalog_id}.{self.namespace_id}" - - @property - def primary_key(self) -> Optional[Tuple[str, ...]]: - return None - - def get_table_ref(self) -> bq.TableReference: - return bq.TableReference( - bq.DatasetReference(self.project_id, self.dataset_id), self.table_id - ) - - -@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: Union[GbqNativeTable, BiglakeIcebergTable] - 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, must be correct if set, don't put maybe-stale number here - n_rows: Optional[int] = None - - def with_ordering(self, ordering: orderings.RowOrdering) -> BigqueryDataSource: - return dataclasses.replace(self, ordering=ordering) - - -_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, - sample_rate: Optional[float] = None, -) -> ReadResult: - assert isinstance(data.table, GbqNativeTable) - - table_mod_options = {} - read_options_dict: dict[str, Any] = {"selected_fields": list(columns)} - - predicates = [] - if data.sql_predicate: - predicates.append(data.sql_predicate) - if sample_rate is not None: - assert isinstance(sample_rate, float) - predicates.append(f"RAND() < {sample_rate}") - - if predicates: - full_predicates = " AND ".join(f"( {pred} )" for pred in predicates) - read_options_dict["row_restriction"] = full_predicates - - 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 - ) - - -def _get_primary_keys( - table: bq.Table, -) -> List[str]: - """Get primary keys from table if they are set.""" - - primary_keys: List[str] = [] - if ( - (table_constraints := getattr(table, "table_constraints", None)) is not None - and (primary_key := table_constraints.primary_key) is not None - # This will be False for either None or empty list. - # We want primary_keys = None if no primary keys are set. - and (columns := primary_key.columns) - ): - primary_keys = columns if columns is not None else [] - - return primary_keys diff --git a/bigframes/core/bytecode.py b/bigframes/core/bytecode.py deleted file mode 100644 index f657ce707ea..00000000000 --- a/bigframes/core/bytecode.py +++ /dev/null @@ -1,913 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 dis -import operator -import sys -from types import ModuleType -from typing import Callable - -import bigframes.core.py_expressions as py_exprs -from bigframes.core import expression -from bigframes.operations import generic_ops - -_BINARY_OP_MAP = { - "+": operator.add, - "-": operator.sub, - "*": operator.mul, - "/": operator.truediv, - "//": operator.floordiv, - "%": operator.mod, - "**": operator.pow, - "[]": operator.getitem, -} - -_COMPARE_OP_MAP = { - "==": operator.eq, - "!=": operator.ne, - "<": operator.lt, - "<=": operator.le, - ">": operator.gt, - ">=": operator.ge, -} - -_OLD_BINARY_OP_MAP = { - "BINARY_ADD": operator.add, - "INPLACE_ADD": operator.add, - "BINARY_SUBTRACT": operator.sub, - "INPLACE_SUBTRACT": operator.sub, - "BINARY_MULTIPLY": operator.mul, - "INPLACE_MULTIPLY": operator.mul, - "BINARY_TRUE_DIVIDE": operator.truediv, - "INPLACE_TRUE_DIVIDE": operator.truediv, - "BINARY_FLOOR_DIVIDE": operator.floordiv, - "INPLACE_FLOOR_DIVIDE": operator.floordiv, - "BINARY_MODULO": operator.mod, - "INPLACE_MODULO": operator.mod, - "BINARY_POWER": operator.pow, - "INPLACE_POWER": operator.pow, -} - - -_NULL = py_exprs.PyObject(None) - - -_RETURN_OPNAMES = {"RETURN_VALUE", "RETURN_CONST"} - -_UNCONDITIONAL_JUMP_OPNAMES = { - "JUMP_FORWARD", - "JUMP_ABSOLUTE", - "JUMP_BACKWARD", - "JUMP_BACKWARD_NO_INTERRUPT", - "JUMP", - "JUMP_NO_INTERRUPT", -} - -_JUMP_IF_FALSE_OPNAMES = { - "POP_JUMP_IF_FALSE", - "POP_JUMP_FORWARD_IF_FALSE", - "POP_JUMP_BACKWARD_IF_FALSE", -} - -_JUMP_IF_TRUE_OPNAMES = { - "POP_JUMP_IF_TRUE", - "POP_JUMP_FORWARD_IF_TRUE", - "POP_JUMP_BACKWARD_IF_TRUE", -} - -_JUMP_IF_NONE_OPNAMES = { - "POP_JUMP_IF_NONE", - "POP_JUMP_FORWARD_IF_NONE", - "POP_JUMP_BACKWARD_IF_NONE", -} - -_JUMP_IF_NOT_NONE_OPNAMES = { - "POP_JUMP_IF_NOT_NONE", - "POP_JUMP_FORWARD_IF_NOT_NONE", - "POP_JUMP_BACKWARD_IF_NOT_NONE", -} - -_CONDITIONAL_JUMP_OPNAMES = ( - _JUMP_IF_FALSE_OPNAMES - | _JUMP_IF_TRUE_OPNAMES - | _JUMP_IF_NONE_OPNAMES - | _JUMP_IF_NOT_NONE_OPNAMES - | { - "JUMP_IF_FALSE_OR_POP", - "JUMP_IF_TRUE_OR_POP", - } -) - -_ALL_JUMP_OPNAMES = _UNCONDITIONAL_JUMP_OPNAMES | _CONDITIONAL_JUMP_OPNAMES - - -@dataclasses.dataclass -class BasicBlock: - start_offset: int - instructions: list[dis.Instruction] - successors: list[int] = dataclasses.field(default_factory=list) - predecessors: list[int] = dataclasses.field(default_factory=list) - - -def get_block_starts(instructions: list[dis.Instruction]) -> set[int]: - starts = {0} - for i, inst in enumerate(instructions): - opname = inst.opname - if opname in _ALL_JUMP_OPNAMES: - if isinstance(inst.argval, int): - starts.add(inst.argval) - if i + 1 < len(instructions): - starts.add(instructions[i + 1].offset) - elif opname in _RETURN_OPNAMES: - if i + 1 < len(instructions): - starts.add(instructions[i + 1].offset) - return starts - - -def get_block_successors(block: BasicBlock, next_offsets: dict[int, int]) -> list[int]: - if not block.instructions: - return [] - last_inst = block.instructions[-1] - opname = last_inst.opname - offset = last_inst.offset - - next_offset = next_offsets.get(offset) - - if opname in _RETURN_OPNAMES: - return [] - - if opname in _UNCONDITIONAL_JUMP_OPNAMES: - return [last_inst.argval] - - if opname in _CONDITIONAL_JUMP_OPNAMES: - successors = [last_inst.argval] - if next_offset is not None: - successors.append(next_offset) - return successors - - if next_offset is not None: - return [next_offset] - return [] - - -def build_cfg( - instructions: list[dis.Instruction], next_offsets: dict[int, int] -) -> dict[int, BasicBlock]: - starts = sorted(list(get_block_starts(instructions))) - - blocks: dict[int, BasicBlock] = {} - for i, start in enumerate(starts): - end = starts[i + 1] if i + 1 < len(starts) else None - block_insts = [ - inst - for inst in instructions - if start <= inst.offset and (end is None or inst.offset < end) - ] - blocks[start] = BasicBlock(start_offset=start, instructions=block_insts) - - for block in blocks.values(): - successors = get_block_successors(block, next_offsets) - block.successors = successors - for succ in successors: - blocks[succ].predecessors.append(block.start_offset) - - return blocks - - -def topological_sort(blocks: dict[int, BasicBlock]) -> list[int]: - in_degree = {offset: len(block.predecessors) for offset, block in blocks.items()} - queue = [offset for offset, deg in in_degree.items() if deg == 0] - order = [] - - while queue: - queue.sort() - curr = queue.pop(0) - order.append(curr) - for succ in blocks[curr].successors: - in_degree[succ] -= 1 - if in_degree[succ] == 0: - queue.append(succ) - - # TODO(b/521549179): Support limited loop analysis (eg unroll loops over a constant range). - if len(order) != len(blocks): - raise ValueError( - "Loops are not supported in the Python function for transpilation." - ) - - return order - - -def merge_values( - pairs: list[tuple[expression.Expression, expression.Expression]], -) -> expression.Expression: - if not pairs: - raise ValueError("Cannot merge empty list of values") - if len(pairs) == 1: - return pairs[0][0] - - val = pairs[-1][0] - for next_val, next_cond in reversed(pairs[:-1]): - val = py_exprs.Call( - py_exprs.PyObject(generic_ops.where_op), (next_val, next_cond, val) - ) - return val - - -def _compile_bytecode_to_py_expr(func: Callable) -> expression.Expression: - instructions = list(dis.get_instructions(func)) - next_offsets = { - inst.offset: next_inst.offset - for inst, next_inst in zip(instructions, instructions[1:]) - } - - blocks = build_cfg(instructions, next_offsets) - order = topological_sort(blocks) - - stack: list[expression.Expression] - local_vars: dict[str, expression.Expression] - - globals_dict = func.__globals__ - import builtins - - builtins_dict = builtins.__dict__ - closure_dict = {} - if func.__closure__: - free_vars = func.__code__.co_freevars - for var, cell in zip(free_vars, func.__closure__): - try: - closure_dict[var] = cell.cell_contents - except ValueError: - pass - - block_outputs: dict[ - int, tuple[list[expression.Expression], dict[str, expression.Expression]] - ] = {} - block_reach_conditions: dict[int, expression.Expression] = { - 0: py_exprs.PyObject(True) - } - edge_conditions: dict[tuple[int, int], expression.Expression] = {} - edge_stacks: dict[tuple[int, int], list[expression.Expression]] = {} - returns: list[tuple[expression.Expression, expression.Expression]] = [] - - co = func.__code__ - param_names = list(co.co_varnames[: co.co_argcount]) - kwonly_argcount = co.co_kwonlyargcount - param_names.extend( - co.co_varnames[co.co_argcount : co.co_argcount + kwonly_argcount] - ) - - initial_local_vars: dict[str, expression.Expression] = { - name: expression.UnboundVariableExpression(name) for name in param_names - } - - for offset in order: - block = blocks[offset] - - reach_cond: expression.Expression - if offset == 0: - reach_cond = py_exprs.PyObject(True) - else: - incoming = [ - edge_conditions[(pred, offset)] - for pred in block.predecessors - if (pred, offset) in edge_conditions - ] - if not incoming: - continue - - reach_cond = incoming[0] - for cond in incoming[1:]: - reach_cond = py_exprs.Call( - py_exprs.PyObject(operator.or_), (reach_cond, cond) - ) - - block_reach_conditions[offset] = reach_cond - - if offset == 0: - stack = [] - local_vars = initial_local_vars.copy() - else: - reachable_preds = [ - pred for pred in block.predecessors if (pred, offset) in edge_stacks - ] - if not reachable_preds: - continue - - h = len(edge_stacks[(reachable_preds[0], offset)]) - stack = [] - for i in range(h): - pairs = [ - (edge_stacks[(p, offset)][i], edge_conditions[(p, offset)]) - for p in reachable_preds - ] - stack.append(merge_values(pairs)) - - all_vars: set[str] = set() - for p in reachable_preds: - all_vars.update(block_outputs[p][1].keys()) - - local_vars = {} - for var in all_vars: - pairs = [ - ( - block_outputs[p][1].get( - var, expression.UnboundVariableExpression(var) - ), - edge_conditions[(p, offset)], - ) - for p in reachable_preds - ] - local_vars[var] = merge_values(pairs) - - jumped = False - for inst in block.instructions: - opname = inst.opname - - match opname: - case "RESUME" | "PRECALL" | "COPY_FREE_VARS" | "NOT_TAKEN" | "NOP": - continue - - case "LOAD_FAST_LOAD_FAST" | "LOAD_FAST_BORROW_LOAD_FAST_BORROW": - var1, var2 = inst.argval - stack.append( - local_vars.get(var1, expression.UnboundVariableExpression(var1)) - ) - stack.append( - local_vars.get(var2, expression.UnboundVariableExpression(var2)) - ) - - case ( - "LOAD_FAST" - | "LOAD_FAST_CHECK" - | "LOAD_FAST_AND_CLEAR" - | "LOAD_FAST_BORROW" - ): - stack.append( - local_vars.get( - inst.argval, - expression.UnboundVariableExpression(inst.argval), - ) - ) - - case "STORE_FAST": - if not stack: - raise ValueError("Stack is empty") - local_vars[inst.argval] = stack.pop() - - case "LOAD_CONST" | "LOAD_SMALL_INT": - stack.append(py_exprs.PyObject(inst.argval)) - - case "LOAD_DEREF" | "LOAD_FROM_DICT_OR_DEREF": - name = inst.argval - found = False - val = None - if name in closure_dict: - val = closure_dict[name] - found = True - elif name in globals_dict: - val = globals_dict[name] - found = True - elif name in builtins_dict: - val = builtins_dict[name] - found = True - - if found: - if isinstance(val, ModuleType): - stack.append(py_exprs.Module(val)) - else: - stack.append(py_exprs.PyObject(val)) - else: - stack.append(expression.UnboundVariableExpression(name)) - - case "LOAD_GLOBAL": - if ( - sys.version_info >= (3, 11) - and inst.arg is not None - and (inst.arg & 1) - ): - stack.append(_NULL) - name = inst.argval - found = False - val = None - if name in closure_dict: - val = closure_dict[name] - found = True - elif name in globals_dict: - val = globals_dict[name] - found = True - elif name in builtins_dict: - val = builtins_dict[name] - found = True - - if found: - if isinstance(val, ModuleType): - stack.append(py_exprs.Module(val)) - else: - stack.append(py_exprs.PyObject(val)) - else: - stack.append(expression.UnboundVariableExpression(name)) - - case "LOAD_ATTR" | "LOAD_METHOD": - if not stack: - raise ValueError("Stack is empty") - target = stack.pop() - stack.append(py_exprs.GetAttr(target, inst.argval)) - - is_method_lookup = (opname == "LOAD_METHOD") or ( - opname == "LOAD_ATTR" - and sys.version_info >= (3, 12) - and inst.arg is not None - and (inst.arg & 1) - ) - if is_method_lookup: - if isinstance(target, py_exprs.Module) or ( - isinstance(target, py_exprs.PyObject) - and isinstance(target.value, type) - ): - stack.append(_NULL) - else: - stack.append(target) - - case "PUSH_NULL": - stack.append(_NULL) - - case "TO_BOOL": - if not stack: - raise ValueError("Stack is empty") - val = stack.pop() - stack.append( - py_exprs.Call( - py_exprs.PyObject(generic_ops.coerce_to_bool_op), - (val,), - ) - ) - - case "FORMAT_SIMPLE": - if not stack: - raise ValueError("Stack is empty") - value = stack.pop() - stack.append(py_exprs.Call(py_exprs.PyObject(str), (value,))) - - case "CONVERT_VALUE": - flags = inst.arg - assert flags is not None - value = stack.pop() - if flags == 1: - stack.append(py_exprs.Call(py_exprs.PyObject(str), (value,))) - else: - raise NotImplementedError( - "repr() and ascii() conversions are not supported" - ) - - case "FORMAT_VALUE": - flags = inst.arg - assert flags is not None - if (flags & 0x04) == 0x04: - stack.pop() - raise NotImplementedError( - "Formatting with specifier is not supported" - ) - - value = stack.pop() - conversion = flags & 0x03 - if conversion == 0 or conversion == 1: - stack.append(py_exprs.Call(py_exprs.PyObject(str), (value,))) - else: - raise NotImplementedError( - "repr() and ascii() conversions are not supported" - ) - - case "FORMAT_WITH_SPEC": - raise NotImplementedError( - "Formatting with specifier is not supported" - ) - - case "BUILD_STRING": - count = inst.arg - assert count is not None - if len(stack) < count: - raise ValueError( - "Stack has fewer elements than BUILD_STRING count" - ) - - if count == 0: - stack.append(py_exprs.PyObject("")) - else: - strings = [stack.pop() for _ in range(count)][::-1] - result = strings[0] - for s in strings[1:]: - result = py_exprs.Call( - py_exprs.PyObject(operator.add), - (result, s), - ) - stack.append(result) - - case "COPY": - idx = inst.arg - if idx is None or idx < 1 or len(stack) < idx: - raise ValueError( - f"Invalid COPY index or stack too small: {idx}" - ) - stack.append(stack[-idx]) - - case "UNARY_NOT": - if not stack: - raise ValueError("Stack is empty") - val = stack.pop() - val_bool = py_exprs.Call( - py_exprs.PyObject(generic_ops.coerce_to_bool_op), - (val,), - ) - stack.append( - py_exprs.Call( - py_exprs.PyObject(operator.not_), - (val_bool,), - ) - ) - - case "SWAP": - idx = inst.arg - if idx is None or idx < 1 or len(stack) < idx: - raise ValueError( - f"Invalid SWAP index or stack too small: {idx}" - ) - stack[-1], stack[-idx] = stack[-idx], stack[-1] - - case "ROT_TWO": - if len(stack) < 2: - raise ValueError("Stack has < 2 elements") - stack[-1], stack[-2] = stack[-2], stack[-1] - - case "ROT_THREE": - if len(stack) < 3: - raise ValueError("Stack has < 3 elements") - stack[-1], stack[-2], stack[-3] = stack[-2], stack[-3], stack[-1] - - case "DUP_TOP": - if not stack: - raise ValueError("Stack is empty") - stack.append(stack[-1]) - - case "BINARY_OP": - if len(stack) < 2: - raise ValueError("Stack is empty") - right = stack.pop() - left = stack.pop() - op_symbol = inst.argrepr - if not op_symbol and isinstance(inst.argval, str): - op_symbol = inst.argval - if op_symbol and op_symbol.endswith("="): - op_symbol = op_symbol[:-1] - - if op_symbol not in _BINARY_OP_MAP: - raise ValueError(f"Unsupported binary operator: {op_symbol}") - stack.append( - py_exprs.Call( - py_exprs.PyObject(_BINARY_OP_MAP[op_symbol]), - (left, right), - ) - ) - - case "BINARY_SUBSCR": - if len(stack) < 2: - raise ValueError("Stack has < 2 elements") - key = stack.pop() - container = stack.pop() - stack.append( - py_exprs.Call( - py_exprs.PyObject(operator.getitem), - (container, key), - ) - ) - - case name if name in _OLD_BINARY_OP_MAP: - if len(stack) < 2: - raise ValueError("Stack has < 2 elements") - right = stack.pop() - left = stack.pop() - stack.append( - py_exprs.Call( - py_exprs.PyObject(_OLD_BINARY_OP_MAP[opname]), - (left, right), - ) - ) - - case "IS_OP": - if len(stack) < 2: - raise ValueError("Stack has < 2 elements") - right = stack.pop() - left = stack.pop() - invert = inst.arg - - def is_none_const(expr) -> bool: - if isinstance(expr, py_exprs.PyObject) and expr.value is None: - return True - if ( - isinstance(expr, expression.ScalarConstantExpression) - and expr.value is None - ): - return True - return False - - if is_none_const(right): - op = ( - generic_ops.isnull_op - if not invert - else generic_ops.notnull_op - ) - stack.append(py_exprs.Call(py_exprs.PyObject(op), (left,))) - elif is_none_const(left): - op = ( - generic_ops.isnull_op - if not invert - else generic_ops.notnull_op - ) - stack.append(py_exprs.Call(py_exprs.PyObject(op), (right,))) - else: - raise NotImplementedError( - "Identity comparison (is/is not) is only supported for None" - ) - - case "COMPARE_OP": - if len(stack) < 2: - raise ValueError("Stack has < 2 elements") - right = stack.pop() - left = stack.pop() - op_symbol = inst.argval - if op_symbol not in _COMPARE_OP_MAP: - raise ValueError(f"Unsupported compare operator: {op_symbol}") - stack.append( - py_exprs.Call( - py_exprs.PyObject(_COMPARE_OP_MAP[op_symbol]), - (left, right), - ) - ) - - case "UNARY_NEGATIVE" | "UNARY_INVERT": - if not stack: - raise ValueError("Stack is empty") - target = stack.pop() - stack.append( - py_exprs.Call( - py_exprs.PyObject( - operator.neg - if opname == "UNARY_NEGATIVE" - else operator.invert - ), - (target,), - ) - ) - - case "UNARY_POSITIVE": - if not stack: - raise ValueError("Stack is empty") - target = stack.pop() - stack.append( - py_exprs.Call(py_exprs.PyObject(operator.pos), (target,)) - ) - - case "CALL_INTRINSIC_1": - if inst.argrepr == "INTRINSIC_UNARY_POSITIVE": - if not stack: - raise ValueError("Stack is empty") - target = stack.pop() - stack.append( - py_exprs.Call(py_exprs.PyObject(operator.pos), (target,)) - ) - else: - raise ValueError(f"Unsupported intrinsic: {inst.argrepr}") - - case "CALL" | "CALL_FUNCTION" | "CALL_METHOD": - num_args = inst.arg - assert num_args is not None - if len(stack) < num_args: - raise ValueError(f"Stack has fewer than {num_args} elements") - args = [stack.pop() for _ in range(num_args)][::-1] - - is_method_call = False - if opname == "CALL" or opname == "CALL_METHOD": - if len(stack) >= 2 and stack[-2] == _NULL: - stack[-1], stack[-2] = stack[-2], stack[-1] - if stack and stack[-1] == _NULL: - stack.pop() - is_method_call = False - else: - is_method_call = True - elif opname == "CALL_FUNCTION": - is_method_call = False - - if is_method_call: - if ( - stack - and stack[-1] != _NULL - and isinstance(stack[-1], expression.Expression) - ): - self_arg = stack.pop() - args = [self_arg] + args - - if not stack: - raise ValueError("Stack is empty") - callable_expr = stack.pop() - stack.append(py_exprs.Call(callable_expr, tuple(args))) - - case "RETURN_VALUE": - if not stack: - raise ValueError("Stack is empty") - returns.append((stack[-1], reach_cond)) - jumped = True - break - - case "RETURN_CONST": - returns.append((py_exprs.PyObject(inst.argval), reach_cond)) - jumped = True - break - - case "POP_TOP": - if stack: - stack.pop() - - case name if name in _UNCONDITIONAL_JUMP_OPNAMES: - dest = inst.argval - edge_conditions[(offset, dest)] = reach_cond - edge_stacks[(offset, dest)] = stack.copy() - jumped = True - break - - case "JUMP_IF_FALSE_OR_POP" | "JUMP_IF_TRUE_OR_POP": - if not stack: - raise ValueError("Stack is empty") - cond_expr = stack[-1] - cond_bool = py_exprs.Call( - py_exprs.PyObject(generic_ops.coerce_to_bool_op), - (cond_expr,), - ) - dest = inst.argval - next_offset = next_offsets.get(inst.offset) - if opname == "JUMP_IF_FALSE_OR_POP": - not_cond_bool = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_bool,) - ) - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_bool), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_bool), - ) - edge_stacks[(offset, next_offset)] = stack[:-1] - else: # JUMP_IF_TRUE_OR_POP - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_bool), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - not_cond_bool = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_bool,) - ) - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_bool), - ) - edge_stacks[(offset, next_offset)] = stack[:-1] - jumped = True - break - - case name if ( - name in _JUMP_IF_FALSE_OPNAMES or name in _JUMP_IF_TRUE_OPNAMES - ): - if not stack: - raise ValueError("Stack is empty") - cond_expr = stack.pop() - cond_expr = py_exprs.Call( - py_exprs.PyObject(generic_ops.coerce_to_bool_op), - (cond_expr,), - ) - - dest = inst.argval - next_offset = next_offsets.get(inst.offset) - - if opname in _JUMP_IF_FALSE_OPNAMES: - not_cond_expr = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_expr,) - ) - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_expr), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_expr), - ) - edge_stacks[(offset, next_offset)] = stack.copy() - else: # opname in _JUMP_IF_TRUE_OPNAMES - not_cond_expr = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_expr,) - ) - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_expr), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_expr), - ) - edge_stacks[(offset, next_offset)] = stack.copy() - jumped = True - break - - case name if ( - name in _JUMP_IF_NONE_OPNAMES or name in _JUMP_IF_NOT_NONE_OPNAMES - ): - if not stack: - raise ValueError("Stack is empty") - cond_expr = stack.pop() - cond_bool = py_exprs.Call( - py_exprs.PyObject(generic_ops.isnull_op), - (cond_expr,), - ) - - dest = inst.argval - next_offset = next_offsets.get(inst.offset) - - if opname in _JUMP_IF_NONE_OPNAMES: - not_cond_bool = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_bool,) - ) - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_bool), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_bool), - ) - edge_stacks[(offset, next_offset)] = stack.copy() - else: # opname in _JUMP_IF_NOT_NONE_OPNAMES - not_cond_bool = py_exprs.Call( - py_exprs.PyObject(operator.not_), (cond_bool,) - ) - edge_conditions[(offset, dest)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, not_cond_bool), - ) - edge_stacks[(offset, dest)] = stack.copy() - if next_offset is not None: - edge_conditions[(offset, next_offset)] = py_exprs.Call( - py_exprs.PyObject(operator.and_), - (reach_cond, cond_bool), - ) - edge_stacks[(offset, next_offset)] = stack.copy() - jumped = True - break - - case name if name in _ALL_JUMP_OPNAMES: - raise ValueError(f"Unsupported jump opcode: {opname}") - - case _: - raise ValueError(f"Unsupported opcode: {opname}") - - if not jumped: - next_offset = next_offsets.get(block.instructions[-1].offset) - if next_offset is not None: - edge_conditions[(offset, next_offset)] = reach_cond - edge_stacks[(offset, next_offset)] = stack.copy() - - block_outputs[offset] = (stack, local_vars) - - if not returns: - raise ValueError("No return value found") - - return merge_values(returns) - - -def py_to_expression(func: Callable) -> expression.Expression: - """ - Try to convert a python function to a BigQuery expression. - - This is "best effort" - if the function contains operations that cannot - be converted to BigQuery expressions, it will raise an Exception. - """ - py_expr = _compile_bytecode_to_py_expr(func) - return py_exprs.resolve_py_exprs(py_expr) diff --git a/bigframes/core/col.py b/bigframes/core/col.py deleted file mode 100644 index 50968dfbf94..00000000000 --- a/bigframes/core/col.py +++ /dev/null @@ -1,204 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 TYPE_CHECKING, Any, Hashable, Literal - -import bigframes_vendored.pandas.core.col as pd_col -import numpy - -import bigframes.core.expression as bf_expression -import bigframes.operations as bf_ops -import bigframes.operations.aggregations as agg_ops -from bigframes.core import agg_expressions, window_spec - -if TYPE_CHECKING: - import bigframes.operations.datetimes as datetimes - import bigframes.operations.strings as strings - - -# Not to be confused with the Expression class in `bigframes.core.expressions` -# Name collision unintended -@dataclasses.dataclass(frozen=True) -class Expression: - __doc__ = pd_col.Expression.__doc__ - - _value: bf_expression.Expression - - def _apply_unary_op(self, op: bf_ops.UnaryOp) -> Expression: - return Expression(op.as_expr(self._value)) - - def _apply_unary_agg(self, op: agg_ops.UnaryAggregateOp) -> Expression: - # We probably shouldn't need to windowize here, but block apis expect pre-windowized expressions - # Later on, we will probably have col expressions in windowed context, so will need to defer windowization - # instead of automatically applying the default unbound window - agg_expr = op.as_expr(self._value) - return Expression( - agg_expressions.WindowExpression(agg_expr, window_spec.unbound()) - ) - - # alignment is purely for series compatibility, and is ignored here - def _apply_binary_op( - self, - other: Any, - op: bf_ops.BinaryOp, - alignment: Literal["outer", "left"] = "outer", - reverse: bool = False, - ): - if reverse: - return Expression(op.as_expr(_as_bf_expr(other), self._value)) - else: - return Expression(op.as_expr(self._value, _as_bf_expr(other))) - - def __add__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.add_op) - - def __radd__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.add_op, reverse=True) - - def __sub__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.sub_op) - - def __rsub__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.sub_op, reverse=True) - - def __mul__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.mul_op) - - def __rmul__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.mul_op, reverse=True) - - def __truediv__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.div_op) - - def __rtruediv__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.div_op, reverse=True) - - def __floordiv__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.floordiv_op) - - def __rfloordiv__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.floordiv_op, reverse=True) - - def __ge__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.ge_op) - - def __gt__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.gt_op) - - def __le__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.le_op) - - def __lt__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.lt_op) - - def __eq__(self, other: object) -> Expression: # type: ignore - return self._apply_binary_op(other, bf_ops.eq_op) - - def __ne__(self, other: object) -> Expression: # type: ignore - return self._apply_binary_op(other, bf_ops.ne_op) - - def __mod__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.mod_op) - - def __rmod__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.mod_op, reverse=True) - - def __and__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.and_op) - - def __rand__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.and_op, reverse=True) - - def __or__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.or_op) - - def __ror__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.or_op, reverse=True) - - def __xor__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.xor_op) - - def __rxor__(self, other: Any) -> Expression: - return self._apply_binary_op(other, bf_ops.xor_op, reverse=True) - - def __invert__(self) -> Expression: - return self._apply_unary_op(bf_ops.invert_op) - - def sum(self) -> Expression: - return self._apply_unary_agg(agg_ops.sum_op) - - def mean(self) -> Expression: - return self._apply_unary_agg(agg_ops.mean_op) - - def var(self) -> Expression: - return self._apply_unary_agg(agg_ops.var_op) - - def std(self) -> Expression: - return self._apply_unary_agg(agg_ops.std_op) - - def min(self) -> Expression: - return self._apply_unary_agg(agg_ops.min_op) - - def max(self) -> Expression: - return self._apply_unary_agg(agg_ops.max_op) - - @property - def dt(self) -> datetimes.DatetimeSimpleMethods: - import bigframes.operations.datetimes as datetimes - - return datetimes.DatetimeSimpleMethods(self) - - def __array_ufunc__( - self, ufunc: numpy.ufunc, method: str, *inputs, **kwargs - ) -> Expression: - """Used to support numpy ufuncs. - See: https://numpy.org/doc/stable/reference/ufuncs.html - """ - # Only __call__ supported with zero arguments - if method != "__call__" or len(inputs) > 2 or len(kwargs) > 0: - return NotImplemented - - if len(inputs) == 1 and ufunc in bf_ops.NUMPY_TO_OP: - op = bf_ops.NUMPY_TO_OP[ufunc] - return Expression(op.as_expr(self._value)) - if len(inputs) == 2 and ufunc in bf_ops.NUMPY_TO_BINOP: - binop = bf_ops.NUMPY_TO_BINOP[ufunc] - if inputs[0] is self: - return Expression(binop.as_expr(self._value, _as_bf_expr(inputs[1]))) - else: - return Expression(binop.as_expr(_as_bf_expr(inputs[0]), self._value)) - - return NotImplemented - - # keep this last as str declaration can shadow builtins.str - @property - def str(self) -> strings.StringMethods: - import bigframes.operations.strings as strings - - return strings.StringMethods(self) - - -def _as_bf_expr(arg: Any) -> bf_expression.Expression: - if isinstance(arg, Expression): - return arg._value - return bf_expression.const(arg) - - -def col(col_name: Hashable) -> Expression: - return Expression(bf_expression.free_var(col_name)) - - -col.__doc__ = pd_col.col.__doc__ diff --git a/bigframes/core/compile/__init__.py b/bigframes/core/compile/__init__.py index c1b9c5d9022..68c36df2889 100644 --- a/bigframes/core/compile/__init__.py +++ b/bigframes/core/compile/__init__.py @@ -13,30 +13,13 @@ # limitations under the License. from __future__ import annotations -from typing import Literal - from bigframes.core.compile.api import test_only_ibis_inferred_schema from bigframes.core.compile.configs import CompileRequest, CompileResult - - -def compile_sql( - request: CompileRequest, - compiler_name: Literal["sqlglot", "ibis"] = "sqlglot", -) -> CompileResult: - """Compiles a BigFrameNode according to the request into SQL.""" - if compiler_name == "sqlglot": - import bigframes.core.compile.sqlglot.compiler as sqlglot_compiler - - return sqlglot_compiler.compile_sql(request) - else: - import bigframes.core.compile.ibis_compiler.ibis_compiler as ibis_compiler - - return ibis_compiler.compile_sql(request) - +from bigframes.core.compile.ibis_compiler.ibis_compiler import compile_sql __all__ = [ "test_only_ibis_inferred_schema", + "compile_sql", "CompileRequest", "CompileResult", - "compile_sql", ] diff --git a/bigframes/core/compile/api.py b/bigframes/core/compile/api.py index 82672fc95b5..3a4695c50d2 100644 --- a/bigframes/core/compile/api.py +++ b/bigframes/core/compile/api.py @@ -15,18 +15,19 @@ from typing import TYPE_CHECKING +from bigframes.core import rewrite +from bigframes.core.compile.ibis_compiler import ibis_compiler + if TYPE_CHECKING: import bigframes.core.nodes 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.""" - import bigframes.core.rewrite import bigframes.core.schema - from bigframes.core.compile.ibis_compiler import ibis_compiler node = ibis_compiler._replace_unsupported_ops(node) - node = bigframes.core.rewrite.bake_order(node) + node = rewrite.bake_order(node) ir = ibis_compiler.compile_node(node) items = tuple( bigframes.core.schema.SchemaItem(name, ir.get_column_type(ibis_id)) diff --git a/bigframes/core/compile/compiled.py b/bigframes/core/compile/compiled.py index fea94f6e6ed..f7de5c051a0 100644 --- a/bigframes/core/compile/compiled.py +++ b/bigframes/core/compile/compiled.py @@ -13,6 +13,7 @@ # limitations under the License. from __future__ import annotations +import functools import itertools import typing from typing import Literal, Optional, Sequence @@ -20,23 +21,25 @@ import bigframes_vendored.ibis import bigframes_vendored.ibis.backends.bigquery.backend as ibis_bigquery import bigframes_vendored.ibis.common.deferred as ibis_deferred # type: ignore +from bigframes_vendored.ibis.expr import builders as ibis_expr_builders 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.types as ibis_types -import bigframes_vendored.sqlglot.expressions as sge -import pyarrow as pa from google.cloud import bigquery +import pyarrow as pa -import bigframes.core.agg_expressions as ex_types +from bigframes.core import utils +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.expression as ex +from bigframes.core.ordering import OrderingExpression import bigframes.core.sql +from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec import bigframes.dtypes -from bigframes.core import agg_expressions, rewrite -from bigframes.core.ordering import OrderingExpression -from bigframes.core.window_spec import WindowSpec +import bigframes.operations.aggregations as agg_ops op_compiler = op_compilers.scalar_op_compiler @@ -56,8 +59,7 @@ def __init__( column.resolve(table) # type:ignore # TODO(https://github.com/ibis-project/ibis/issues/7613): use # public API to refer to Deferred type. - if isinstance(column, ibis_deferred.Deferred) - else column + if isinstance(column, ibis_deferred.Deferred) else column for column in columns ) # To allow for more efficient lookup by column name, create a @@ -83,21 +85,13 @@ def to_sql( ) if order_by or limit or not is_noop_selection: - # selections are (ref.id.sql, name) where ref.id.sql is escaped identifier - to_select = [ - sge.Alias( - this=sge.to_identifier(src, quoted=True), - alias=sge.to_identifier(alias, quoted=True), - ) - if src != alias - else sge.to_identifier(src, quoted=True) - for src, alias in selection_strings - ] - # Use string formatting for FROM clause to avoid re-parsing potentially complex SQL (like ARRAY>) - # that sqlglot might not handle perfectly when parsing BigQuery dialect strings. - select_sql = sge.Select().select(*to_select).sql(dialect="bigquery") - ibis_sql = ibis_bigquery.Backend().compile(ibis_table) - sql = f"{select_sql} FROM ({ibis_sql}) AS `t`" + sql = ibis_bigquery.Backend().compile(ibis_table) + sql = ( + bigframes.core.compile.googlesql.Select() + .from_(sql) + .select(selection_strings) + .sql() + ) # Single row frames may not have any ordering columns if len(order_by) > 0: @@ -108,7 +102,7 @@ def to_sql( raise TypeError(f"Limit param: {limit} must be an int.") sql += f"\nLIMIT {limit}" else: - sql = ibis_bigquery.Backend().compile(ibis_table) + sql = ibis_bigquery.Backend().compile(self._to_ibis_expr()) return typing.cast(str, sql) @property @@ -172,6 +166,18 @@ 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, *, @@ -209,7 +215,7 @@ def filter(self, predicate: ex.Expression) -> UnorderedIR: def aggregate( self, - aggregations: typing.Sequence[tuple[ex_types.Aggregation, str]], + aggregations: typing.Sequence[tuple[ex.Aggregation, str]], by_column_ids: typing.Sequence[ex.DerefOp] = (), order_by: typing.Sequence[OrderingExpression] = (), ) -> UnorderedIR: @@ -230,9 +236,7 @@ def aggregate( col_out: agg_compiler.compile_aggregate( aggregate, bindings, - order_by=op_compiler._convert_row_ordering_to_table_values( - table, order_by - ), + order_by=_convert_row_ordering_to_table_values(table, order_by), ) for aggregate, col_out in aggregations } @@ -381,7 +385,6 @@ def isin_join( new_column = ( (left_table[conditions[0]]) .isin((right_table[conditions[1]])) - .fillna(False) .name(indicator_col) ) @@ -398,9 +401,11 @@ def isin_join( def project_window_op( self, - expression: ex_types.Aggregation, + expression: ex.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. @@ -408,6 +413,7 @@ 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) @@ -429,19 +435,118 @@ def project_window_op( expression, window_spec, output_name, + never_skip_nulls=never_skip_nulls, ) - rewritten_expr = rewrite.simplify_complex_windows( - agg_expressions.WindowExpression(expression, window_spec) + 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, ) - ibis_expr = op_compiler.compile_expression(rewritten_expr, self._ibis_bindings) + 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 not expression.op.nulls_count_for_min_values: + # Most operations do not count NULL values towards min_periods + per_col_does_count = (column.notnull() for column in 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}, + ) + 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}, + ) + clauses.append( + ( + observation_count < ibis_types.literal(window_spec.min_periods), + ibis_types.null(), + ) + ) + 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 - return UnorderedIR(self._table, (*self.columns, ibis_expr.name(output_name))) + return UnorderedIR(self._table, (*self.columns, window_op.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.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 = _convert_row_ordering_to_table_values( + self._column_names, + window_spec.ordering, + ) + + elif window_spec.is_range_bounded: + order_by = [ + _convert_range_ordering_to_table_value( + self._column_names, + 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 = _convert_row_ordering_to_table_values( + self._column_names, + 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 is_literal(column: ibis_types.Value) -> bool: # Unfortunately, Literals in ibis are not "Columns"s and therefore can't be aggregated. @@ -459,6 +564,58 @@ def is_window(column: ibis_types.Value) -> bool: return any(isinstance(op, ibis_ops.WindowFunction) for op in matches) +def _convert_row_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 _convert_range_ordering_to_table_value( + value_lookup: typing.Mapping[str, ibis_types.Value], + ordering_column: 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 = op_compiler.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 + + def _string_cast_join_cond( lvalue: ibis_types.Column, rvalue: ibis_types.Column ) -> ibis_types.BooleanColumn: @@ -518,3 +675,53 @@ 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): + 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 utils.timedelta_to_micros(bounds.start) + ), + end=_to_ibis_boundary( + None if bounds.end is None else 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}") diff --git a/bigframes/core/compile/configs.py b/bigframes/core/compile/configs.py index 62c28f87cae..5ffca0cf43b 100644 --- a/bigframes/core/compile/configs.py +++ b/bigframes/core/compile/configs.py @@ -34,4 +34,3 @@ class CompileResult: sql: str sql_schema: typing.Sequence[google.cloud.bigquery.SchemaField] row_order: typing.Optional[ordering.RowOrdering] - encoded_type_refs: str diff --git a/bigframes/core/compile/ibis_compiler/default_ordering.py b/bigframes/core/compile/default_ordering.py similarity index 94% rename from bigframes/core/compile/ibis_compiler/default_ordering.py rename to bigframes/core/compile/default_ordering.py index 84ce52851c4..1a1350cfd6c 100644 --- a/bigframes/core/compile/ibis_compiler/default_ordering.py +++ b/bigframes/core/compile/default_ordering.py @@ -18,7 +18,7 @@ from __future__ import annotations -from typing import Sequence, cast +from typing import cast, Sequence import bigframes_vendored.ibis import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes @@ -47,7 +47,10 @@ 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("")) + ibis_types.StringColumn, + result.fill_null(ibis_types.literal("")) + if hasattr(result, "fill_null") + else result.fillna(""), ).replace( "\\", # type: ignore "\\\\", # type: ignore diff --git a/bigframes/core/compile/googlesql/__init__.py b/bigframes/core/compile/googlesql/__init__.py new file mode 100644 index 00000000000..add0c5ec445 --- /dev/null +++ b/bigframes/core/compile/googlesql/__init__.py @@ -0,0 +1,61 @@ +# 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. + +"""Python classes representing GoogleSQL syntax nodes, adhering to the official syntax: +https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax""" + +from __future__ import annotations + +from bigframes.core.compile.googlesql.datatype import DataType +from bigframes.core.compile.googlesql.expression import ( + _escape_chars, + AliasExpression, + ColumnExpression, + CTEExpression, + identifier, + StarExpression, + TableExpression, +) +from bigframes.core.compile.googlesql.function import Cast +from bigframes.core.compile.googlesql.query import ( + AsAlias, + FromClause, + FromItem, + NonRecursiveCTE, + QueryExpr, + Select, + SelectAll, + SelectExpression, +) + +__all__ = [ + "_escape_chars", + "identifier", + "AliasExpression", + "AsAlias", + "Cast", + "ColumnExpression", + "CTEExpression", + "DataType", + "FromClause", + "FromItem", + "NonRecursiveCTE", + "QueryExpr", + "Select", + "SelectAll", + "SelectExpression", + "StarExpression", + "StringType", + "TableExpression", +] diff --git a/bigframes/core/compile/googlesql/abc.py b/bigframes/core/compile/googlesql/abc.py new file mode 100644 index 00000000000..081836467c2 --- /dev/null +++ b/bigframes/core/compile/googlesql/abc.py @@ -0,0 +1,25 @@ +# 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 abc + + +class SQLSyntax(abc.ABC): + """Abstract base class provides GoogleSQL syntax.""" + + @abc.abstractmethod + def sql(self): + ... diff --git a/bigframes/core/compile/googlesql/datatype.py b/bigframes/core/compile/googlesql/datatype.py new file mode 100644 index 00000000000..ccf3ff4d41b --- /dev/null +++ b/bigframes/core/compile/googlesql/datatype.py @@ -0,0 +1,23 @@ +# 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 enum + +"""This module represents all GoogleSQL for BigQuery data types: +https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types""" + + +class DataType(enum.Enum): + STRING = 1 + FLOAT64 = 2 diff --git a/bigframes/core/compile/googlesql/expression.py b/bigframes/core/compile/googlesql/expression.py new file mode 100644 index 00000000000..581ab67718a --- /dev/null +++ b/bigframes/core/compile/googlesql/expression.py @@ -0,0 +1,124 @@ +# 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 dataclasses +import typing + +import bigframes.core.compile.googlesql.abc as abc + +"""This module represents GoogleSQL `expression` and its extensions. +Core class: + +* `expression`: Models basic SQL expressions. + +Extended classes (not part of standard GoogleSQL syntax, but added for convenience): + +* `ColumnExpression`: Represents column references. +* `TableExpression`: Represents table references. +* `AliasExpression`: Represents aliased expressions. +* ... +""" + + +@dataclasses.dataclass +class Expression(abc.SQLSyntax): + pass + + +@dataclasses.dataclass +class ColumnExpression(Expression): + name: str + parent: typing.Optional[TableExpression | AliasExpression | CTEExpression] = None + + def sql(self) -> str: + if self.parent is not None: + return f"{self.parent.sql()}.{identifier(self.name)}" + return identifier(self.name) + + +@dataclasses.dataclass +class StarExpression(Expression): + parent: typing.Optional[TableExpression | AliasExpression | CTEExpression] = None + + def sql(self) -> str: + if self.parent is not None: + return f"{self.parent.sql()}.*" + return "*" + + +@dataclasses.dataclass +class TableExpression(Expression): + table_id: str + dataset_id: typing.Optional[str] = None + project_id: typing.Optional[str] = None + + def __post_init__(self): + if self.project_id is not None and self.dataset_id is None: + raise ValueError("The `dataset_id` is missing.") + + def sql(self) -> str: + text = [] + if self.project_id is not None: + text.append(identifier(self.project_id)) + if self.dataset_id is not None: + text.append(identifier(self.dataset_id)) + text.append(identifier(self.table_id)) + return ".".join(text) + + +@dataclasses.dataclass +class AliasExpression(Expression): + alias: str + + def sql(self) -> str: + return identifier(self.alias) + + +@dataclasses.dataclass +class CTEExpression(Expression): + name: str + + def sql(self) -> str: + return identifier(self.name) + + +def identifier(id: str) -> str: + """Return a string representing column reference in a SQL.""" + # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#identifiers + # Just always escape, otherwise need to check against every reserved sql keyword + return f"`{_escape_chars(id)}`" + + +def _escape_chars(value: str): + """Escapes all special charactesrs""" + # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#string_and_bytes_literals + trans_table = str.maketrans( + { + "\a": r"\a", + "\b": r"\b", + "\f": r"\f", + "\n": r"\n", + "\r": r"\r", + "\t": r"\t", + "\v": r"\v", + "\\": r"\\", + "?": r"\?", + '"': r"\"", + "'": r"\'", + "`": r"\`", + } + ) + return value.translate(trans_table) diff --git a/bigframes/core/compile/googlesql/function.py b/bigframes/core/compile/googlesql/function.py new file mode 100644 index 00000000000..19b61f2fc99 --- /dev/null +++ b/bigframes/core/compile/googlesql/function.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. + +import dataclasses + +import bigframes.core.compile.googlesql.datatype as datatype +import bigframes.core.compile.googlesql.expression as expr + +# Conversion functions: +# https://cloud.google.com/bigquery/docs/reference/standard-sql/conversion_functions + + +@dataclasses.dataclass +class Cast(expr.Expression): + """This class represents the `cast` function.""" + + expression: expr.ColumnExpression + type: datatype.DataType + + def sql(self) -> str: + return f"CAST ({self.expression.sql()} AS {self.type.name})" diff --git a/bigframes/core/compile/googlesql/query.py b/bigframes/core/compile/googlesql/query.py new file mode 100644 index 00000000000..f591216b3a8 --- /dev/null +++ b/bigframes/core/compile/googlesql/query.py @@ -0,0 +1,231 @@ +# 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 dataclasses +import typing + +import google.cloud.bigquery as bigquery + +import bigframes.core.compile.googlesql.abc as abc +import bigframes.core.compile.googlesql.expression as expr + +"""This module provides a structured representation of GoogleSQL syntax using nodes. +Each node's name and child nodes are designed to strictly follow the official GoogleSQL +syntax rules outlined in the documentation: +https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax""" + +TABLE_SOURCE_TYPE = typing.Union[str, bigquery.TableReference] + + +@dataclasses.dataclass +class QueryExpr(abc.SQLSyntax): + """This class represents GoogleSQL `query_expr` syntax.""" + + select: Select + with_cte_list: typing.Sequence[NonRecursiveCTE] = () + + def sql(self) -> str: + text = [] + if len(self.with_cte_list) > 0: + with_cte_text = ",\n".join( + [with_cte.sql() for with_cte in self.with_cte_list] + ) + text.append(f"WITH {with_cte_text}") + + text.append(self.select.sql()) + return "\n".join(text) + + +@dataclasses.dataclass +class Select(abc.SQLSyntax): + """This class represents GoogleSQL `select` syntax.""" + + select_list: typing.Sequence[ + typing.Union[SelectExpression, SelectAll] + ] = dataclasses.field(default_factory=list) + from_clause_list: typing.Sequence[FromClause] = dataclasses.field( + default_factory=list + ) + distinct: bool = False + + def select( + self, + 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]] = ( + [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]], + ) -> Select: + if (not isinstance(sources, typing.Iterable)) or isinstance(sources, str): + sources = [sources] + self.from_clause_list = [ + FromClause(FromItem.from_source(source)) for source in sources + ] + return self + + def sql(self) -> str: + if (self.select_list is not None) and (not self.select_list): + raise ValueError("Select clause has not been properly initialized.") + + text = ["SELECT"] + + if self.distinct: + text.append("DISTINCT") + + select_list_sql = ",\n".join([select.sql() for select in self.select_list]) + text.append(select_list_sql) + + if self.from_clause_list: + from_clauses_sql = ",\n".join( + [clause.sql() for clause in self.from_clause_list] + ) + text.append(f"FROM\n{from_clauses_sql}") + return "\n".join(text) + + +@dataclasses.dataclass(frozen=True) +class SelectExpression(abc.SQLSyntax): + """This class represents `select_expression`.""" + + expression: expr.ColumnExpression + alias: typing.Optional[expr.AliasExpression] = None + + def sql(self) -> str: + if self.alias is None: + return self.expression.sql() + else: + return f"{self.expression.sql()} AS {self.alias.sql()}" + + +@dataclasses.dataclass +class SelectAll(abc.SQLSyntax): + """This class represents `select_all` (aka. `SELECT *`).""" + + expression: expr.StarExpression + + def sql(self) -> str: + return self.expression.sql() + + +@dataclasses.dataclass +class FromClause(abc.SQLSyntax): + """This class represents GoogleSQL `from_clause` syntax.""" + + from_item: FromItem + + def sql(self) -> str: + return self.from_item.sql() + + +@dataclasses.dataclass +class FromItem(abc.SQLSyntax): + """This class represents GoogleSQL `from_item` syntax.""" + + # Note: Temporarily introduces the `str` type to interact with pre-existing, + # compiled SQL strings. + expression: typing.Union[expr.TableExpression, QueryExpr, str, expr.CTEExpression] + as_alias: typing.Optional[AsAlias] = None + + @classmethod + def from_source( + cls, + subquery_or_tableref: typing.Union[bigquery.TableReference, str], + as_alias: typing.Optional[AsAlias] = None, + ): + if isinstance(subquery_or_tableref, bigquery.TableReference): + return cls( + expression=expr.TableExpression( + table_id=subquery_or_tableref.table_id, + dataset_id=subquery_or_tableref.dataset_id, + project_id=subquery_or_tableref.project, + ), + as_alias=as_alias, + ) + elif isinstance(subquery_or_tableref, str): + return cls( + expression=subquery_or_tableref, + as_alias=as_alias, + ) + else: + raise ValueError("The source must be bigquery.TableReference or str.") + + def sql(self) -> str: + if isinstance(self.expression, (expr.TableExpression, expr.CTEExpression)): + text = self.expression.sql() + elif isinstance(self.expression, str): + text = f"({self.expression})" + elif isinstance(self.expression, QueryExpr): + text = f"({self.expression.sql()})" + else: + raise ValueError( + f"Unsupported expression type {type(self.expression).__name__};" + "expected one of TableExpression, QueryExpr, str, or CTEExpression." + ) + + if self.as_alias is None: + return text + else: + return f"{text} {self.as_alias.sql()}" + + +@dataclasses.dataclass +class NonRecursiveCTE(abc.SQLSyntax): + """This class represents GoogleSQL `non_recursive_cte` syntax.""" + + cte_name: expr.CTEExpression + query_expr: QueryExpr + + def sql(self) -> str: + return f"{self.cte_name.sql()} AS (\n{self.query_expr.sql()}\n)" + + +@dataclasses.dataclass +class AsAlias(abc.SQLSyntax): + """This class represents GoogleSQL `as_alias` syntax.""" + + alias: expr.AliasExpression + + def sql(self) -> str: + return f"AS {self.alias.sql()}" diff --git a/bigframes/core/compile/ibis_compiler/__init__.py b/bigframes/core/compile/ibis_compiler/__init__.py index 6b9d284c536..aef0ed92676 100644 --- a/bigframes/core/compile/ibis_compiler/__init__.py +++ b/bigframes/core/compile/ibis_compiler/__init__.py @@ -21,5 +21,4 @@ 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/ibis_compiler/aggregate_compiler.py b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py index 94607bf04bc..291db445248 100644 --- a/bigframes/core/compile/ibis_compiler/aggregate_compiler.py +++ b/bigframes/core/compile/ibis_compiler/aggregate_compiler.py @@ -16,27 +16,22 @@ import functools import typing -from typing import List, Optional, cast +from typing import cast, List, Optional import bigframes_vendored.constants as constants -import bigframes_vendored.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 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_vendored.ibis.expr import builders as ibis_expr_builders -from bigframes_vendored.ibis.expr.operations import window as ibis_expr_window +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.utils +import bigframes.core.expression as ex import bigframes.core.window_spec as window_spec import bigframes.operations.aggregations as agg_ops -from bigframes.core import agg_expressions -from bigframes.core.compile import constants as compiler_constants -from bigframes.core.window_spec import RangeWindowBounds, RowsWindowBounds, WindowSpec scalar_compiler = scalar_compilers.scalar_op_compiler @@ -53,19 +48,19 @@ def approx_quantiles(expression: float, number) -> List[float]: def compile_aggregate( - aggregate: agg_expressions.Aggregation, + aggregate: ex.Aggregation, bindings: typing.Dict[str, ibis_types.Value], order_by: typing.Sequence[ibis_types.Value] = [], ) -> ibis_types.Value: - if isinstance(aggregate, agg_expressions.NullaryAggregation): + if isinstance(aggregate, ex.NullaryAggregation): return compile_nullary_agg(aggregate.op) - if isinstance(aggregate, agg_expressions.UnaryAggregation): + if isinstance(aggregate, ex.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, agg_expressions.BinaryAggregation): + elif isinstance(aggregate, ex.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 @@ -74,17 +69,16 @@ def compile_aggregate( def compile_analytic( - aggregate: agg_expressions.Aggregation, + aggregate: ex.Aggregation, window: window_spec.WindowSpec, bindings: typing.Dict[str, ibis_types.Value], ) -> ibis_types.Value: - 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): + if isinstance(aggregate, ex.NullaryAggregation): + return compile_nullary_agg(aggregate.op, window) + elif isinstance(aggregate, ex.UnaryAggregation): input = scalar_compiler.compile_expression(aggregate.arg, bindings=bindings) - return compile_unary_agg(aggregate.op, input, ibis_window) # type: ignore - elif isinstance(aggregate, agg_expressions.BinaryAggregation): + return compile_unary_agg(aggregate.op, input, window) # type: ignore + elif isinstance(aggregate, ex.BinaryAggregation): raise NotImplementedError("binary analytic operations not yet supported") else: raise ValueError(f"Unexpected analytic operation: {aggregate}") @@ -175,11 +169,15 @@ def _( @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()) @@ -528,10 +526,8 @@ def _( column: ibis_types.Column, window=None, ) -> ibis_types.Value: - # Ibis FirstNonNullValue expects Value[Any, Columnar], Mypy struggles to see Column as compatible. return _apply_window_if_present( - ibis_ops.FirstNonNullValue(column).to_expr(), # type: ignore[arg-type] - window, # type: ignore + ibis_ops.FirstNonNullValue(column).to_expr(), window # type: ignore ) @@ -550,10 +546,8 @@ def _( column: ibis_types.Column, window=None, ) -> ibis_types.Value: - # Ibis LastNonNullValue expects Value[Any, Columnar], Mypy struggles to see Column as compatible. return _apply_window_if_present( - ibis_ops.LastNonNullValue(column).to_expr(), # type: ignore[arg-type] - window, # type: ignore + ibis_ops.LastNonNullValue(column).to_expr(), window # type: ignore ) @@ -682,29 +676,6 @@ 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 @@ -735,111 +706,6 @@ 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 - # WindowBoundary expects Value[Any, Any], ibis_types.literal returns Scalar which Mypy doesn't see as compatible. - return ibis_expr_window.WindowBoundary( - ibis_types.literal(boundary if boundary >= 0 else -boundary), # type: ignore[arg-type] - 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/ibis_compiler/ibis_compiler.py b/bigframes/core/compile/ibis_compiler/ibis_compiler.py index 938759ae181..ff0441ea22a 100644 --- a/bigframes/core/compile/ibis_compiler/ibis_compiler.py +++ b/bigframes/core/compile/ibis_compiler/ibis_compiler.py @@ -16,13 +16,15 @@ import dataclasses import functools import typing -from typing import Optional, cast +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 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 @@ -30,10 +32,6 @@ import bigframes.core.nodes as nodes import bigframes.core.ordering as bf_ordering import bigframes.core.rewrite as rewrites -import bigframes.core.rewrite.schema_binding as schema_binding -from bigframes import dtypes, operations -from bigframes.core import bq_data, expression, pyarrow_utils -from bigframes.core.logging import data_types as data_type_logger if typing.TYPE_CHECKING: import bigframes.core @@ -50,8 +48,7 @@ def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: # 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 = cast(nodes.ResultNode, _replace_unsupported_ops(result_node)) - result_node = cast(nodes.ResultNode, result_node.bottom_up(rewrites.simplify_join)) + 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( @@ -59,30 +56,15 @@ def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: ) if request.sort_rows: result_node = cast(nodes.ResultNode, rewrites.column_pruning(result_node)) - encoded_type_refs = data_type_logger.encode_type_refs(result_node) - # Have to bind schema as the final step before compilation. - # Probably, should defer even further - result_node = typing.cast( - nodes.ResultNode, schema_binding.bind_schema_to_tree(result_node) - ) sql = compile_result_node(result_node) return configs.CompileResult( - sql, - result_node.schema.to_bigquery(), - result_node.order_by, - encoded_type_refs, + 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)) - encoded_type_refs = data_type_logger.encode_type_refs(result_node) - # Have to bind schema as the final step before compilation. - # Probably, should defer even further - result_node = typing.cast( - nodes.ResultNode, schema_binding.bind_schema_to_tree(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: @@ -90,9 +72,7 @@ def compile_sql(request: configs.CompileRequest) -> configs.CompileResult: 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, encoded_type_refs - ) + return configs.CompileResult(sql, result_node.schema.to_bigquery(), output_order) def _replace_unsupported_ops(node: nodes.BigFrameNode): @@ -100,7 +80,6 @@ def _replace_unsupported_ops(node: nodes.BigFrameNode): 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) - node = nodes.bottom_up(node, rewrites.lower_udfs) return node @@ -149,7 +128,7 @@ def compile_isin( 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), + conditions=(node.left_col.id.sql, node.right_col.id.sql), join_nulls=node.joins_nulls, ) @@ -207,7 +186,7 @@ def compile_readtable(node: nodes.ReadTableNode, *args): # 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 + scan_item.dtype == dtypes.JSON_DTYPE and ibis_table[scan_item.source_id].type() == ibis_dtypes.string ): json_column = scalar_op_registry.parse_json( @@ -225,10 +204,12 @@ def compile_readtable(node: nodes.ReadTableNode, *args): def _table_to_ibis( - source: bq_data.BigqueryDataSource, + source: nodes.BigqueryDataSource, scan_cols: typing.Sequence[str], ) -> ibis_types.Table: - full_table_name = source.table.get_full_id(quoted=False) + 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) @@ -284,13 +265,12 @@ def compile_aggregate(node: nodes.AggregateNode, child: compiled.UnorderedIR): @_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, - ) + result = child.project_window_op( + node.expression, + node.window_spec, + node.output_name.sql, + never_skip_nulls=node.never_skip_nulls, + ) return result diff --git a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py b/bigframes/core/compile/ibis_compiler/operations/geo_ops.py deleted file mode 100644 index 772752112a4..00000000000 --- a/bigframes/core/compile/ibis_compiler/operations/geo_ops.py +++ /dev/null @@ -1,192 +0,0 @@ -# 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 - -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_vendored import ibis -from bigframes_vendored.ibis.expr import types as ibis_types - -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_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_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.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, # type: ignore - b: ibis_dtypes.geography, # type: ignore - use_spheroid: bool, # type: ignore -) -> 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 index 31a8459923c..d5f3e15d343 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_compiler.py @@ -20,21 +20,16 @@ import typing from typing import TYPE_CHECKING -import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.operations.generic as ibis_generic import bigframes_vendored.ibis.expr.types as ibis_types import bigframes.core.compile.ibis_types import bigframes.core.expression as ex -from bigframes.core import agg_expressions, ordering -from bigframes.operations import googlesql as gsql_ops -from bigframes.operations import numeric_ops if TYPE_CHECKING: import bigframes.operations as ops -class ExpressionCompiler: +class ScalarOpCompiler: # Mapping of operation name to implemenations _registry: dict[ str, @@ -72,18 +67,6 @@ def _( 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, @@ -94,20 +77,8 @@ def _( self.compile_expression(sub_expr, bindings) for sub_expr in expression.inputs ] - if isinstance(expression.op, gsql_ops.GoogleSqlScalarOp): - return googlesql_scalar_op_impl( - *inputs, op=expression.op, output_type=expression.output_type - ) return self.compile_row_op(expression.op, inputs) - @compile_expression.register - def _( - self, - expression: ex.OmittedArg, - bindings: typing.Dict[str, ibis_types.Value], - ) -> ibis_types.Value: - return bigframes_vendored.ibis.omitted() - def compile_row_op( self, op: ops.RowOp, inputs: typing.Sequence[ibis_types.Value] ) -> ibis_types.Value: @@ -231,100 +202,6 @@ def _register( 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() - - -def googlesql_scalar_op_impl( - *operands: ibis_types.Value, op: ops.GoogleSqlScalarOp, output_type -): - final_operands: list[ibis_types.Value] = [] - arg_templates = [] - for i, operand in enumerate(operands): - if i < len(op.args): - arg_spec = op.args[i] - else: - assert op.args[-1].is_vararg, ( - f"Too many arguments, for {op.sql_name}, expected {len(op.args)}" - ) - arg_spec = op.args[-1] - if isinstance(operand.op(), ibis_generic.OmittedArg): - assert arg_spec.optional, "Argument omitted, but not optional" - continue - - target_idx = len(final_operands) - final_operands.append(operand) - if arg_spec.arg_name: - arg_templates.append(f"{arg_spec.arg_name} => {{{target_idx}}}") - else: - arg_templates.append(f"{{{target_idx}}}") - args_template = ", ".join(arg_templates) - sql_template = f"{op.sql_name}({args_template})" - return ibis_generic.SqlScalar( - sql_template, - values=tuple( - typing.cast(ibis_generic.Value, expr.op()) for expr in final_operands - ), - output_type=bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( - output_type - ), - ).to_expr() +scalar_op_compiler = ScalarOpCompiler() diff --git a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py index 530d23a8b06..969ae2659da 100644 --- a/bigframes/core/compile/ibis_compiler/scalar_op_registry.py +++ b/bigframes/core/compile/ibis_compiler/scalar_op_registry.py @@ -16,25 +16,22 @@ import functools import typing -from typing import Any, cast 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 -from bigframes_vendored import ibis -import bigframes.core.compile.ibis_compiler.default_ordering -import bigframes.core.compile.ibis_types -import bigframes.operations as ops from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +import bigframes.core.compile.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.operations as ops _ZERO = typing.cast(ibis_types.NumericValue, ibis_types.literal(0)) _NAN = typing.cast(ibis_types.NumericValue, ibis_types.literal(np.nan)) @@ -169,8 +166,6 @@ def arctanh_op_impl(x: ibis_types.Value): @scalar_op_compiler.register_unary_op(ops.floor_op) def floor_op_impl(x: ibis_types.Value): x_numeric = typing.cast(ibis_types.NumericValue, x) - if x_numeric.type().is_boolean(): - return x_numeric.cast(ibis_dtypes.Int64()).cast(ibis_dtypes.Float64()) if x_numeric.type().is_integer(): return x_numeric.cast(ibis_dtypes.Float64()) if x_numeric.type().is_floating(): @@ -183,8 +178,6 @@ def floor_op_impl(x: ibis_types.Value): @scalar_op_compiler.register_unary_op(ops.ceil_op) def ceil_op_impl(x: ibis_types.Value): x_numeric = typing.cast(ibis_types.NumericValue, x) - if x_numeric.type().is_boolean(): - return x_numeric.cast(ibis_dtypes.Int64()).cast(ibis_dtypes.Float64()) if x_numeric.type().is_integer(): return x_numeric.cast(ibis_dtypes.Float64()) if x_numeric.type().is_floating(): @@ -363,6 +356,14 @@ def contains_regex_op_impl(x: ibis_types.Value, op: ops.StrContainsRegexOp): return typing.cast(ibis_types.StringValue, x).re_search(op.pat) +@scalar_op_compiler.register_unary_op(ops.StrGetOp, pass_op=True) +def strget_op_impl(x: ibis_types.Value, op: ops.StrGetOp): + substr = typing.cast( + ibis_types.StringValue, typing.cast(ibis_types.StringValue, x)[op.i] + ) + return substr.nullif(ibis_types.literal("")) + + @scalar_op_compiler.register_unary_op(ops.StrPadOp, pass_op=True) def strpad_op_impl(x: ibis_types.Value, op: ops.StrPadOp): str_val = typing.cast(ibis_types.StringValue, x) @@ -655,7 +656,7 @@ def datetime_to_integer_label_non_fixed_frequency( .else_((x_int - first - 1) // us + 1) # type: ignore .end() ) - elif rule_code in ("M", "ME"): # Monthly + elif rule_code == "ME": # Monthly x_int = x.year() * 12 + x.month() - 1 # type: ignore first = y.year() * 12 + y.month() - 1 # type: ignore x_int_label = ( @@ -664,7 +665,7 @@ def datetime_to_integer_label_non_fixed_frequency( .else_((x_int - first - 1) // n + 1) # type: ignore .end() ) - elif rule_code in ("Q-DEC", "QE-DEC"): # Quarterly + elif rule_code == "QE-DEC": # Quarterly x_int = x.year() * 4 + x.quarter() - 1 # type: ignore first = y.year() * 4 + y.quarter() - 1 # type: ignore x_int_label = ( @@ -673,7 +674,7 @@ def datetime_to_integer_label_non_fixed_frequency( .else_((x_int - first - 1) // n + 1) # type: ignore .end() ) - elif rule_code in ("A-DEC", "Y-DEC", "YE-DEC"): # Yearly + elif rule_code == "YE-DEC": # Yearly x_int = x.year() # type: ignore first = y.year() # type: ignore x_int_label = ( @@ -741,7 +742,7 @@ def integer_label_to_datetime_op_non_fixed_frequency( .cast(ibis_dtypes.Timestamp(timezone="UTC")) .cast(y.type()) ) - elif rule_code in ("M", "ME"): # Monthly + elif rule_code == "ME": # Monthly one = ibis_types.literal(1) twelve = ibis_types.literal(12) first = y.year() * twelve + y.month() - one # type: ignore @@ -761,7 +762,7 @@ def integer_label_to_datetime_op_non_fixed_frequency( 0, ) x_label = next_month_date - ibis_api.interval(days=1) - elif rule_code in ("Q-DEC", "QE-DEC"): # Quarterly + elif rule_code == "QE-DEC": # Quarterly one = ibis_types.literal(1) three = ibis_types.literal(3) four = ibis_types.literal(4) @@ -784,7 +785,7 @@ def integer_label_to_datetime_op_non_fixed_frequency( ) x_label = next_month_date - ibis_api.interval(days=1) - elif rule_code in ("A-DEC", "Y-DEC", "YE-DEC"): # Yearly + elif rule_code == "YE-DEC": # Yearly one = ibis_types.literal(1) first = y.year() # type: ignore x = x * n + first # type: ignore @@ -834,6 +835,98 @@ def normalize_op_impl(x: ibis_types.Value): return result.cast(result_type) +# Geo Ops +@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_boundary_op, pass_op=False) +def geo_st_boundary_op_impl(x: ibis_types.Value): + return st_boundary(x) + + +@scalar_op_compiler.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, + ) + + +@scalar_op_compiler.register_unary_op(ops.geo_st_centroid_op, pass_op=False) +def geo_st_centroid_op_impl(x: ibis_types.Value): + return typing.cast(ibis_types.GeoSpatialValue, x).centroid() + + +@scalar_op_compiler.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) + + +@scalar_op_compiler.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 typing.cast(ibis_types.GeoSpatialValue, x).difference( + typing.cast(ibis_types.GeoSpatialValue, y) + ) + + +@scalar_op_compiler.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) + + +@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) + ) + + +@scalar_op_compiler.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 typing.cast(ibis_types.GeoSpatialValue, x).intersection( + typing.cast(ibis_types.GeoSpatialValue, y) + ) + + +@scalar_op_compiler.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) + + +@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.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) + + +@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() + + # 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): @@ -876,25 +969,6 @@ def numeric_to_datetime( ) -@scalar_op_compiler.register_unary_op(ops.coerce_to_bool_op) -def coerce_to_bool_op_impl(x: ibis_types.Value): - x_type = x.type() - if x_type.is_boolean(): - res = x - elif x_type.is_numeric(): - res = x != 0 # type: ignore - elif x_type.is_string(): - res = x.length() > 0 # type: ignore - elif x_type.is_binary(): - res = x.length() > 0 # type: ignore - elif isinstance(x_type, ibis_dtypes.Array): - res = x.length() > 0 # type: ignore - else: - res = x.notnull() - - return res.fill_null(False) # type: ignore - - @scalar_op_compiler.register_unary_op(ops.AsTypeOp, pass_op=True) def astype_op_impl(x: ibis_types.Value, op: ops.AsTypeOp): to_type = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( @@ -933,6 +1007,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) @@ -948,7 +1051,7 @@ def isin_op_impl(x: ibis_types.Value, op: ops.IsInOp): # to actually cast it, as that could be lossy (eg float -> int) item_inferred_type = ibis_types.literal(item).type() if ( - x.type().name == item_inferred_type.name + x.type() == item_inferred_type or x.type().is_numeric() and item_inferred_type.is_numeric() ): @@ -959,14 +1062,12 @@ 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).fill_null(ibis.literal(False)) + return x.isin(matchable_ibis_values).fillna(False) @scalar_op_compiler.register_unary_op(ops.ToDatetimeOp, pass_op=True) def to_datetime_op_impl(x: ibis_types.Value, op: ops.ToDatetimeOp): - if x.type() == ibis_dtypes.Timestamp(None): # type: ignore - return x # already a timestamp, no-op - elif x.type() in (ibis_dtypes.str, ibis_dtypes.Timestamp("UTC")): # type: ignore + if x.type() == ibis_dtypes.str: return x.try_cast(ibis_dtypes.Timestamp(None)) # type: ignore else: # Numerical inputs. @@ -989,8 +1090,6 @@ def to_timestamp_op_impl(x: ibis_types.Value, op: ops.ToTimestampOp): if op.format else timestamp(x) ) - elif x.type() == ibis_dtypes.Timestamp(None): # type: ignore - return timestamp(x) else: # Numerical inputs. if op.format: @@ -1013,32 +1112,65 @@ def to_timedelta_op_impl(x: ibis_types.Value, op: ops.ToTimedeltaOp): @scalar_op_compiler.register_unary_op(ops.timedelta_floor_op) def timedelta_floor_op_impl(x: ibis_types.NumericValue): - return ibis_api.case().when(x > ibis.literal(0), x.floor()).else_(x.ceil()).end() + return x.floor() -@scalar_op_compiler.register_nary_op(ops.RemoteFunctionOp, pass_op=True) -def remote_function_op_impl(*values: ibis_types.Value, op: ops.RemoteFunctionOp): +@scalar_op_compiler.register_unary_op(ops.RemoteFunctionOp, pass_op=True) +def remote_function_op_impl(x: ibis_types.Value, op: ops.RemoteFunctionOp): udf_sig = op.function_def.signature - assert not udf_sig.is_virtual # should have been devirtualized in lowering pass - ibis_py_sig = (tuple(arg.py_type for arg in udf_sig.inputs), udf_sig.output.py_type) - arg_names = tuple(arg.name for arg in udf_sig.inputs) + 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: + 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): ... + def udf(*inputs): + ... - return udf(*values) + 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]) @@ -1051,39 +1183,13 @@ def array_to_string_op_impl(x: ibis_types.Value, op: ops.ArrayToStringOp): return typing.cast(ibis_types.ArrayValue, x).join(op.delimiter) -@scalar_op_compiler.register_unary_op(ops.GetItemOp, pass_op=True) -def getitem_op_impl(x: ibis_types.Value, op: ops.GetItemOp): - if x.type().is_struct(): - struct_value = typing.cast(ibis_types.StructValue, x) - if isinstance(op.key, str): - name = op.key - else: - name = struct_value.names[op.key] - result = struct_value[name] - return result.cast(result.type()(nullable=True)).name(name) - elif x.type().is_array(): - key = typing.cast(int, op.key) - res = typing.cast(ibis_types.ArrayValue, x)[key] - return res - elif x.type().is_string(): - key = typing.cast(int, op.key) - res = typing.cast(ibis_types.StringValue, x)[key] - return _null_or_value(res, res != ibis_types.literal("")) - else: - raise TypeError(f"Cannot subscript input of type {x.type()}") - - -@scalar_op_compiler.register_binary_op(ops.DynamicGetItemOp) -def dynamic_getitem_op_impl(left: ibis_types.Value, right: ibis_types.Value): - if left.type().is_array(): - int_right = typing.cast(ibis_types.IntegerValue, right) - return typing.cast(ibis_types.ArrayValue, left)[int_right] - elif left.type().is_string(): - scalar_right = typing.cast(ibis_types.IntegerScalar, right) - res = typing.cast(ibis_types.StringValue, left)[scalar_right] +@scalar_op_compiler.register_unary_op(ops.ArrayIndexOp, pass_op=True) +def array_index_op_impl(x: ibis_types.Value, op: ops.ArrayIndexOp): + res = typing.cast(ibis_types.ArrayValue, x)[op.index] + if x.type().is_string(): return _null_or_value(res, res != ibis_types.literal("")) else: - raise TypeError(f"Cannot dynamically subscript input of type {left.type()}") + return res @scalar_op_compiler.register_unary_op(ops.ArraySliceOp, pass_op=True) @@ -1110,24 +1216,10 @@ def to_arry_op_impl(*values: ibis_types.Value): 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) - ) + 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) ) - - -@scalar_op_compiler.register_unary_op(ops.ArrayMapOp, pass_op=True) -def array_map_op_impl(x: ibis_types.Value, op: ops.ArrayMapOp): - return typing.cast(ibis_types.ArrayValue, x).map( - lambda arr_vals: scalar_op_compiler.compile_row_op(op.map_op, (arr_vals,)) ) @@ -1201,32 +1293,9 @@ 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, pass_op=True) -def to_json_op_impl(x: ibis_types.Value, op: ops.ToJSON): - if x.type() == ibis_dtypes.string: - return parse_json_in_safe(x) if op.safe else parse_json(x) - return x.isnull().ifelse(ibis.null().cast(ibis_dtypes.json), to_json(x)) - - -@scalar_op_compiler.register_unary_op(ops.JSONDecode, pass_op=True) -def json_decode_op_impl(x: ibis_types.Value, op: ops.JSONDecode): - to_type = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( - op.to_type - ) - 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) - raise TypeError(f"Cannot cast from JSON to type {to_type}") - - @scalar_op_compiler.register_unary_op(ops.ToJSONString) -def to_json_string_op_impl(x: ibis_types.Value): - return to_json_string(value=x) +def to_json_string_op_impl(json_obj: ibis_types.Value): + return to_json_string(json_obj=json_obj) @scalar_op_compiler.register_unary_op(ops.JSONValue, pass_op=True) @@ -1239,11 +1308,6 @@ 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): @@ -1252,13 +1316,6 @@ def obj_fetch_metadata_op_impl(obj_ref: ibis_types.Value): @scalar_op_compiler.register_unary_op(ops.ObjGetAccessUrl, pass_op=True) def obj_get_access_url_op_impl(obj_ref: ibis_types.Value, op: ops.ObjGetAccessUrl): - if op.duration is not None: - duration_value = cast( - ibis_types.IntegerValue, ibis_types.literal(op.duration) - ).to_interval("us") - return obj_get_access_url_with_duration( - obj_ref=obj_ref, mode=op.mode, duration=duration_value - ) return obj_get_access_url(obj_ref=obj_ref, mode=op.mode) @@ -1312,8 +1369,8 @@ def eq_nulls_match_op( 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).fill_null(literal) - right = y.cast(ibis_dtypes.str).fill_null(literal) + left = x.cast(ibis_dtypes.str).fillna(literal) + right = y.cast(ibis_dtypes.str).fillna(literal) return left == right @@ -1739,7 +1796,10 @@ def fillna_op( x: ibis_types.Value, y: ibis_types.Value, ): - return x.fill_null(typing.cast(ibis_types.Scalar, y)) + 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)) @scalar_op_compiler.register_binary_op(ops.round_op) @@ -1819,11 +1879,6 @@ def obj_make_ref_op(x: ibis_types.Value, y: ibis_types.Value): return obj_make_ref(uri=x, authorizer=y) -@scalar_op_compiler.register_unary_op(ops.obj_make_ref_json_op) -def obj_make_ref_json_op(x: ibis_types.Value): - return obj_make_ref_json(objectref_json=x) - - # Ternary Operations @scalar_op_compiler.register_ternary_op(ops.where_op) def where_op( @@ -1901,181 +1956,66 @@ 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, # 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, # 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, # 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, # type: ignore - op.model_params, # type: ignore - ).to_expr() - - -@scalar_op_compiler.register_unary_op(ops.AIEmbed, pass_op=True) -def ai_embed(value: ibis_types.Value, op: ops.AIEmbed) -> ibis_types.StructValue: - return ai_ops.AIEmbed( - value, # type: ignore - connection_id=op.connection_id, # type: ignore - endpoint=op.endpoint, # type: ignore - model=op.model, # type: ignore - task_type=op.task_type, # type: ignore - title=op.title, # type: ignore - model_params=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 - op.endpoint, # type: ignore - op.optimization_mode, # type: ignore - op.max_error_ratio, # 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 - _construct_examples(op.examples), # type: ignore - op.connection_id, # type: ignore - op.endpoint, # type: ignore - op.output_mode, # type: ignore - op.optimization_mode, # type: ignore - op.max_error_ratio, # 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 - op.endpoint, # type: ignore - op.max_error_ratio, # type: ignore - ).to_expr() - - -@scalar_op_compiler.register_binary_op(ops.AISimilarity, pass_op=True) -def ai_similarity( - content1: ibis_types.Value, content2: ibis_types.Value, op: ops.AISimilarity -) -> ibis_types.Value: - return ai_ops.AISimilarity( - content1, # type: ignore - content2, # type: ignore - op.endpoint, # type: ignore - op.model, # type: ignore - op.model_params, # type: ignore - op.connection_id, # type: ignore - ).to_expr() +@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) -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 +# 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) - 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) +def _ibis_num(number: float): + return typing.cast(ibis_types.NumericValue, ibis_types.literal(number)) -def _construct_examples( - examples: tuple[tuple[str, str | tuple[str, ...]], ...] | None, -) -> ibis_types.ArrayValue | None: - if examples is None: - return None +@ibis_udf.scalar.builtin +def st_convexhull(x: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """ST_CONVEXHULL""" + ... - results: list[ibis_types.StructValue] = [] - for example in examples: - value: Any = example[1] - if isinstance(example[1], (list, tuple)): - value = list(example[1]) +@ibis_udf.scalar.builtin +def st_geogfromtext(a: str) -> ibis_dtypes.geography: # type: ignore + """Convert string to geography.""" - ibis_example = ibis.struct({"_field_1": example[0], "_field_2": value}) - results.append(ibis_example) - return ibis.array(results) +@ibis_udf.scalar.builtin +def timestamp(a: str) -> ibis_dtypes.timestamp: # type: ignore + """Convert string to timestamp.""" -@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.ibis_compiler.default_ordering.gen_row_key(values) +@ibis_udf.scalar.builtin +def unix_millis(a: ibis_dtypes.timestamp) -> int: # type: ignore + """Convert a timestamp to milliseconds""" -# 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) +@ibis_udf.scalar.builtin +def st_boundary(a: ibis_dtypes.geography) -> ibis_dtypes.geography: # type: ignore + """Find the boundary of a geography.""" -def _ibis_num(number: float): - return typing.cast(ibis_types.NumericValue, ibis_types.literal(number)) +@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 timestamp(a) -> ibis_dtypes.timestamp: # type: ignore - """Convert string or a datetime to timestamp.""" +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 unix_millis(a: ibis_dtypes.timestamp) -> int: # type: ignore - """Convert a timestamp to milliseconds""" +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 @@ -2120,22 +2060,11 @@ 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(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] +def to_json_string( # type: ignore[empty-body] json_obj: ibis_dtypes.JSON, - max_depth: ibis_dtypes.Int64, -) -> ibis_dtypes.Array[ibis_dtypes.String]: - """Extracts unique JSON keys from a JSON expression.""" +) -> ibis_dtypes.String: + """Convert JSON to STRING.""" @ibis_udf.scalar.builtin(name="json_value") @@ -2207,23 +2136,8 @@ def obj_make_ref(uri: str, authorizer: str) -> _OBJ_REF_IBIS_DTYPE: # type: ign """Make ObjectRef Struct from uri and connection.""" -@ibis_udf.scalar.builtin(name="OBJ.MAKE_REF") -def obj_make_ref_json(objectref_json: ibis_dtypes.JSON) -> _OBJ_REF_IBIS_DTYPE: # type: ignore - """Make ObjectRef Struct from json.""" - - @ibis_udf.scalar.builtin(name="OBJ.GET_ACCESS_URL") -# Stub for BigQuery UDF, empty body is intentional. -# _OBJ_REF_IBIS_DTYPE is a variable holding a type, Mypy complains about it being used as type hint. -def obj_get_access_url( # type: ignore[empty-body] - obj_ref: _OBJ_REF_IBIS_DTYPE, # type: ignore[valid-type] - mode: ibis_dtypes.String, -) -> ibis_dtypes.JSON: - """Get access url (as ObjectRefRumtime JSON) from ObjectRef.""" - - -@ibis_udf.scalar.builtin(name="OBJ.GET_ACCESS_URL") -def obj_get_access_url_with_duration(obj_ref, mode, duration) -> ibis_dtypes.JSON: # type: ignore +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.""" @@ -2234,6 +2148,11 @@ def str_lstrip_op( # type: ignore[empty-body] """Remove leading and trailing characters.""" +@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(name="rtrim") def str_rstrip_op( # type: ignore[empty-body] x: ibis_dtypes.String, to_strip: ibis_dtypes.String diff --git a/bigframes/core/compile/ibis_types.py b/bigframes/core/compile/ibis_types.py index 788f6db4435..0a61be716ad 100644 --- a/bigframes/core/compile/ibis_types.py +++ b/bigframes/core/compile/ibis_types.py @@ -13,7 +13,7 @@ # limitations under the License. from __future__ import annotations -from typing import Dict, Iterable, Optional, Tuple, Union, cast +from typing import cast, Dict, Iterable, Optional, Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.ibis @@ -386,6 +386,10 @@ def literal_to_ibis_scalar( 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) diff --git a/bigframes/core/compile/polars/__init__.py b/bigframes/core/compile/polars/__init__.py index 027582c7ded..7ae6fcc7552 100644 --- a/bigframes/core/compile/polars/__init__.py +++ b/bigframes/core/compile/polars/__init__.py @@ -16,7 +16,6 @@ Make sure to import all polars implementations here so that they get registered. """ - from __future__ import annotations import warnings @@ -24,10 +23,7 @@ # 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.array_ops # noqa: F401 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 bigframes._importing diff --git a/bigframes/core/compile/polars/compiler.py b/bigframes/core/compile/polars/compiler.py index 65b217602b9..3316154de7e 100644 --- a/bigframes/core/compile/polars/compiler.py +++ b/bigframes/core/compile/polars/compiler.py @@ -16,11 +16,14 @@ import dataclasses import functools import itertools -from typing import TYPE_CHECKING, Literal, Optional, Sequence, Tuple, Type, cast +import operator +from typing import cast, Literal, Optional, Sequence, Tuple, Type, TYPE_CHECKING import pandas as pd import bigframes.core +from bigframes.core import 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.rewrite @@ -31,17 +34,11 @@ 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.remote_function_ops as remote_function_ops import bigframes.operations.string_ops as string_ops -import bigframes.operations.struct_ops as struct_ops -from bigframes.core import agg_expressions, identifiers, nodes, ordering, window_spec -from bigframes.core.compile.polars import lowering polars_installed = True if TYPE_CHECKING: @@ -78,20 +75,6 @@ def decorator(func): 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(), @@ -124,7 +107,7 @@ def _bigframes_dtype_to_polars_dtype( ] ) if bigframes.dtypes.is_array_like(dtype): - return pl.List( + return pl.Array( inner=_bigframes_dtype_to_polars_dtype( bigframes.dtypes.get_array_inner_type(dtype) ) @@ -140,20 +123,11 @@ class PolarsExpressionCompiler: Should be extended to dispatch based on bigframes schema types. """ - _expr_types: dict[int, bigframes.dtypes.ExpressionType] = dataclasses.field( - default_factory=dict, init=False, compare=False - ) - - def compile_expression(self, expression: ex.Expression) -> pl.Expr: - res = self._compile_expression(expression) - self._expr_types[id(res)] = expression.output_type - return res - @functools.singledispatchmethod - def _compile_expression(self, expression: ex.Expression) -> pl.Expr: + def compile_expression(self, expression: ex.Expression) -> pl.Expr: raise NotImplementedError(f"Cannot compile expression: {expression}") - @_compile_expression.register + @compile_expression.register def _( self, expression: ex.ScalarConstantExpression, @@ -163,85 +137,29 @@ def _( 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 + @compile_expression.register def _( self, expression: ex.DerefOp, ) -> pl.Expr: return pl.col(expression.id.sql) - @_compile_expression.register + @compile_expression.register def _( self, expression: ex.ResolvedDerefOp, ) -> pl.Expr: return pl.col(expression.id.sql) - @_compile_expression.register + @compile_expression.register def _( self, expression: ex.OpExpression, ) -> pl.Expr: - import datetime - - import pyarrow as pa - - op = expression.op - - # Polars panics on nulls from pandas objects in timezone-aware - # datetimes for certain ops. Convert to timezone-naive temporarily - # to avoid this issue. - # TODO(tswast): Remove workaround when - # https://github.com/pola-rs/polars/issues/27862 has been fixed. - is_problematic_op = type(op) in ( - date_ops.YearOp, - date_ops.QuarterOp, - date_ops.MonthOp, - date_ops.DayOp, - date_ops.IsoWeekOp, - ) - - if is_problematic_op and len(expression.inputs) == 1: - input_expr = expression.inputs[0] - if ( - input_expr.is_resolved - and isinstance(input_expr.output_type, pd.ArrowDtype) - and isinstance( - input_expr.output_type.pyarrow_dtype, pa.TimestampType - ) - and input_expr.output_type.pyarrow_dtype.tz is not None - ): - tz_str = input_expr.output_type.pyarrow_dtype.tz - if tz_str == "UTC": - dummy_tz = datetime.timezone.utc - else: - try: - from zoneinfo import ZoneInfo - - dummy_tz = ZoneInfo(tz_str) # type: ignore - except Exception: - dummy_tz = datetime.timezone.utc - - dummy_val = datetime.datetime(1970, 1, 1, tzinfo=dummy_tz) - - compiled_input = self.compile_expression(input_expr) - filled_input = compiled_input.fill_null(dummy_val) - compiled_op_with_fill = self.compile_op(op, filled_input) - - return ( - pl.when(compiled_input.is_null()) - .then(None) - .otherwise(compiled_op_with_fill) - ) - # TODO: Complete the implementation + op = expression.op args = tuple(map(self.compile_expression, expression.inputs)) return self.compile_op(op, *args) @@ -372,28 +290,6 @@ def _( ) -> pl.Expr: return pl.when(condition).then(original).otherwise(otherwise) - @compile_op.register(gen_ops.CoerceToBoolOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, gen_ops.CoerceToBoolOp) - from_type = self._expr_types.get(id(input)) - if from_type is None: - return input.cast(pl.Boolean).fill_null(False) - - if from_type == bigframes.dtypes.BOOL_DTYPE: - res = input - elif bigframes.dtypes.is_numeric(from_type): - res = input != 0 - elif from_type == bigframes.dtypes.BYTES_DTYPE: - res = input.bin.size() > 0 - elif bigframes.dtypes.is_string_like(from_type): - res = input.str.len_chars() > 0 - elif bigframes.dtypes.is_array_like(from_type): - res = input.list.len() > 0 - else: - res = input.is_not_null() - - return res.fill_null(False) - @compile_op.register(gen_ops.AsTypeOp) def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: assert isinstance(op, gen_ops.AsTypeOp) @@ -416,26 +312,6 @@ 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) @@ -454,96 +330,11 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: else: return pl.any_horizontal(*(input.str.ends_with(pat) for pat in op.pat)) - @compile_op.register(string_ops.CapitalizeOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.CapitalizeOp) - return ( - input.str.slice(0, 1).str.to_uppercase() - + input.str.slice(1).str.to_lowercase() - ) - - @compile_op.register(string_ops.IsAlnumOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsAlnumOp) - return input.str.contains(r"^[a-zA-Z0-9]+$") - - @compile_op.register(string_ops.IsAlphaOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsAlphaOp) - return input.str.contains(r"^[a-zA-Z]+$") - - @compile_op.register(string_ops.IsDigitOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsDigitOp) - return input.str.contains(r"^[0-9]+$") - - @compile_op.register(string_ops.IsSpaceOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsSpaceOp) - return input.str.contains(r"^\s+$") - - @compile_op.register(string_ops.IsDecimalOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsDecimalOp) - return input.str.contains(r"^[0-9]+$") - - @compile_op.register(string_ops.IsNumericOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsNumericOp) - return input.str.contains(r"^[0-9]+$") - - @compile_op.register(string_ops.IsLowerOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsLowerOp) - return input.str.contains(r"[a-z]") & ~input.str.contains(r"[A-Z]") - - @compile_op.register(string_ops.IsUpperOp) - def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: - assert isinstance(op, string_ops.IsUpperOp) - return input.str.contains(r"[A-Z]") & ~input.str.contains(r"[a-z]") - - @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) @@ -559,65 +350,10 @@ def _(self, op: ops.ScalarOp, input: pl.Expr) -> pl.Expr: ) @compile_op.register(json_ops.JSONDecode) - def _(self, op: json_ops.JSONDecode, input: pl.Expr) -> pl.Expr: + 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(json_ops.ToJSON) - def _(self, op: json_ops.ToJSON, input: pl.Expr) -> pl.Expr: - from_type = self._expr_types.get(id(input)) - if from_type in ( - bigframes.dtypes.STRING_DTYPE, - bigframes.dtypes.JSON_DTYPE, - ): - return input - else: - return input.cast(pl.String()) - - @compile_op.register(json_ops.ToJSONString) - def _(self, op: json_ops.ToJSONString, input: pl.Expr) -> pl.Expr: - from_type = self._expr_types.get(id(input)) - - def preprocess_binary( - expr: pl.Expr, dtype: bigframes.dtypes.ExpressionType - ) -> pl.Expr: - if dtype == bigframes.dtypes.BYTES_DTYPE: - return expr.bin.encode("base64") - if bigframes.dtypes.is_struct_like(dtype): - fields = bigframes.dtypes.get_struct_fields(dtype) - return pl.struct( - *[ - preprocess_binary( - expr.struct.field(name), field_type - ).alias(name) - for name, field_type in fields.items() - ] - ) - if bigframes.dtypes.is_array_like(dtype): - inner_type = bigframes.dtypes.get_array_inner_type(dtype) - return expr.list.eval(preprocess_binary(pl.element(), inner_type)) - return expr - - preprocessed = preprocess_binary(input, from_type) - - if bigframes.dtypes.is_struct_like(from_type): - result = preprocessed.struct.json_encode() - elif from_type == bigframes.dtypes.INT_DTYPE: - result = preprocessed.cast(pl.String) - elif from_type == bigframes.dtypes.BOOL_DTYPE: - result = ( - pl.when(preprocessed) - .then(pl.lit("true")) - .otherwise(pl.lit("false")) - ) - elif from_type == bigframes.dtypes.BYTES_DTYPE: - result = pl.lit('"') + preprocessed + pl.lit('"') - else: - wrapped = pl.struct(value=preprocessed).struct.json_encode() - result = wrapped.str.slice(9, wrapped.str.len_chars() - 10) - - return pl.when(input.is_null()).then(pl.lit("null")).otherwise(result) - @compile_op.register(arr_ops.ToArrayOp) def _(self, op: ops.ToArrayOp, *inputs: pl.Expr) -> pl.Expr: return pl.concat_list(*inputs) @@ -648,51 +384,21 @@ def _(self, op: ops.ArrayReduceOp, input: pl.Expr) -> pl.Expr: f"Haven't implemented array aggregation: {op.aggregation}" ) - @compile_op.register(struct_ops.StructOp) - def _(self, op: struct_ops.StructOp, *inputs: pl.Expr) -> pl.Expr: - return pl.struct(**{col: inp for col, inp in zip(op.column_names, inputs)}) # type: ignore - - @compile_op.register(struct_ops.StructFieldOp) - def _(self, op: struct_ops.StructFieldOp, *inputs: pl.Expr) -> pl.Expr: - return inputs[0].struct[op.name_or_index] - - @compile_op.register(remote_function_ops.PythonUdfOp) - def _(self, op: ops.PythonUdfOp, *inputs: pl.Expr) -> pl.Expr: - from bigframes.functions import function_template - - code = op.function_def.code.to_callable() - if op.function_def.signature.is_row_processor: - - def handler(py_struct): - args = list(py_struct.values()) - series_arg = function_template.get_pd_series(args[0]) - return code(series_arg, *args[1:]) - else: - - def handler(py_struct): - return code(*(field for field in py_struct.values())) - - return pl.struct(*inputs).map_elements( - handler, - return_dtype=_bigframes_dtype_to_polars_dtype(op.output_type()), - skip_nulls=False, - ) - @dataclasses.dataclass(frozen=True) class PolarsAggregateCompiler: scalar_compiler = PolarsExpressionCompiler() def get_args( self, - agg: agg_expressions.Aggregation, + agg: ex.Aggregation, ) -> Sequence[pl.Expr]: """Prepares arguments for aggregation by compiling them.""" - if isinstance(agg, agg_expressions.NullaryAggregation): + if isinstance(agg, ex.NullaryAggregation): return [] - elif isinstance(agg, agg_expressions.UnaryAggregation): + elif isinstance(agg, ex.UnaryAggregation): arg = self.scalar_compiler.compile_expression(agg.arg) return [arg] - elif isinstance(agg, agg_expressions.BinaryAggregation): + elif isinstance(agg, ex.BinaryAggregation): larg = self.scalar_compiler.compile_expression(agg.left) rarg = self.scalar_compiler.compile_expression(agg.right) return [larg, rarg] @@ -701,13 +407,13 @@ def get_args( f"Aggregation {agg} not yet supported in polars engine." ) - def compile_agg_expr(self, expr: agg_expressions.Aggregation): - if isinstance(expr, agg_expressions.NullaryAggregation): + def compile_agg_expr(self, expr: ex.Aggregation): + if isinstance(expr, ex.NullaryAggregation): inputs: Tuple = () - elif isinstance(expr, agg_expressions.UnaryAggregation): + elif isinstance(expr, ex.UnaryAggregation): assert isinstance(expr.arg, ex.DerefOp) inputs = (expr.arg.id.sql,) - elif isinstance(expr, agg_expressions.BinaryAggregation): + elif isinstance(expr, ex.BinaryAggregation): assert isinstance(expr.left, ex.DerefOp) assert isinstance(expr.right, ex.DerefOp) inputs = ( @@ -734,9 +440,9 @@ def compile_agg_op( if isinstance(op, agg_ops.MedianOp): return pl.median(*inputs) if isinstance(op, agg_ops.AllOp): - return pl.col(inputs).cast(pl.Boolean).all() + return pl.all(*inputs) if isinstance(op, agg_ops.AnyOp): - return pl.col(inputs).cast(pl.Boolean).any() + return pl.any(*inputs) # type: ignore if isinstance(op, agg_ops.NuniqueOp): return pl.col(*inputs).drop_nulls().n_unique() if isinstance(op, agg_ops.MinOp): @@ -756,11 +462,9 @@ def compile_agg_op( 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) + return pl.var(inputs[0]) if isinstance(op, agg_ops.PopVarOp): - # polars var doesnt' support decimal, so use std instead - return pl.std(inputs[0], ddof=0).pow(2) + return pl.var(inputs[0], ddof=0) if isinstance(op, agg_ops.FirstNonNullOp): return pl.col(*inputs).drop_nulls().first() if isinstance(op, agg_ops.LastNonNullOp): @@ -769,9 +473,6 @@ def compile_agg_op( 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): @@ -784,353 +485,357 @@ def compile_agg_op( 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. - """ - - 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) +@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. + """ - @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, plan: nodes.BigFrameNode) -> 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, + # 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 sorted + return lazy_frame - @compile_node.register - def compile_reversed(self, node: nodes.ReversedNode): - return self.compile_node(node.child).reverse() + @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_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_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 + @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) ) - 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)] - ) + new_cols.append(new_col) + return self.compile_node(node.child).with_columns(new_cols) - @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_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_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)) + @compile_node.register + def compile_join(self, node: nodes.JoinNode): + left = self.compile_node(node.left_child) + right = self.compile_node(node.right_child) - right_col = ex.ResolvedDerefOp.from_field(node.right_child.fields[0]) - left_ex, right_ex = lowering._coerce_comparables(node.left_col, right_col) + 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)) - left_pl_ex = self.expr_compiler.compile_expression(left_ex) - right_pl_ex = 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(node.right_col.id.sql) + right = right.with_columns(pl.lit(True).alias(node.indicator_col.sql)) + + left_ex, right_ex = lowering._coerce_comparables(node.left_col, node.right_col) + + left_pl_ex = self.expr_compiler.compile_expression(left_ex) + right_pl_ex = self.expr_compiler.compile_expression(right_ex) + + joined = left.join( + 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, + ) + 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="left", - left_on=left_pl_ex, - right_on=right_pl_ex, + how=how, + left_on=left_on, + right_on=right_on, # Note: join_nulls renamed to nulls_equal for polars 1.24 - join_nulls=node.joins_nulls, # type: ignore + join_nulls=join_nulls, # type: ignore coalesce=False, ) - passthrough = [pl.col(id) for id in left.columns] - indicator = pl.col(node.indicator_col.sql).fill_null(False) - return joined.select((*passthrough, indicator)) + else: + joined = left.join(right, how=how, coalesce=False) - 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"), - ] + join_order = ( + ["_bf_join_l", "_bf_join_r"] + if how != "right" + else ["_bf_join_r", "_bf_join_l"] + ) + 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 + } ) - right = right_frame.with_columns( - [ - pl.int_range(pl.len()).alias("_bf_join_r"), - ] + 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 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, + 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[ex.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), ) - 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"] ) - return joined.sort(join_order, nulls_last=True).drop( - ["_bf_join_l", "_bf_join_r"] + 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") + + 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(node.expression) + if window.grouping_keys: + agg_pl = agg_pl.over(id.id.sql for id in window.grouping_keys) + result = df.with_columns(agg_pl.alias(node.output_name.sql)) + else: # row-bounded window + window_result = self._calc_row_analytic_func( + df, node.expression, node.window_spec, node.output_name.sql ) + result = pl.concat([df, window_result], how="horizontal") - @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) + # Probably easier just to pull this out as a rewriter + if ( + node.expression.op.skips_nulls + and not node.never_skip_nulls + and node.expression.column_references + ): + nullity_expr = functools.reduce( + operator.or_, + ( + pl.col(column.sql).is_null() + for column in node.expression.column_references + ), + ) + result = result.with_columns( + pl.when(nullity_expr) + .then(None) + .otherwise(pl.col(node.output_name.sql)) + .alias(node.output_name.sql) + ) + return result + + def _calc_row_analytic_func( + self, + frame: pl.LazyFrame, + agg_expr: ex.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 ] - 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" - ) - - 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 = 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 is not None) else None, - group_by=groupby, - ) - .agg(pl_agg_expr) - .select(name) + # 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 = 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 is not None) else None, + group_by=groupby, ) + .agg(pl_agg_expr) + .select(name) + ) def _get_period_and_offset( diff --git a/bigframes/core/compile/polars/lowering.py b/bigframes/core/compile/polars/lowering.py index 5b3d9154b73..876ff2794f9 100644 --- a/bigframes/core/compile/polars/lowering.py +++ b/bigframes/core/compile/polars/lowering.py @@ -18,7 +18,6 @@ import numpy as np import pandas as pd -import bigframes.operations as ops from bigframes import dtypes from bigframes.core import bigframe_node, expression from bigframes.core.rewrite import op_lowering @@ -26,9 +25,10 @@ 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) @@ -173,10 +173,12 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: divisor.output_type ): # exact same as floordiv impl for timedelta - numeric_result = ops.div_op.as_expr( + numeric_result = ops.floordiv_op.as_expr( ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), divisor ) - return _numeric_to_timedelta(numeric_result) + 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 @@ -223,10 +225,11 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: divisor.output_type ): # this is pretty fragile as zero will break it, and must fit back into int - numeric_result = ops.div_op.as_expr( + numeric_result = expr.op.as_expr( ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(dividend), divisor ) - return _numeric_to_timedelta(numeric_result) + 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) @@ -315,32 +318,6 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: return expr -class LowerCeilOp(op_lowering.OpLoweringRule): - @property - def op(self) -> type[ops.ScalarOp]: - return numeric_ops.CeilOp - - def lower(self, expr: expression.OpExpression) -> expression.Expression: - assert isinstance(expr.op, numeric_ops.CeilOp) - arg = expr.children[0] - if arg.output_type in (dtypes.INT_DTYPE, dtypes.BOOL_DTYPE): - return expr.op.as_expr(ops.AsTypeOp(dtypes.FLOAT_DTYPE).as_expr(arg)) - return expr - - -class LowerFloorOp(op_lowering.OpLoweringRule): - @property - def op(self) -> type[ops.ScalarOp]: - return numeric_ops.FloorOp - - def lower(self, expr: expression.OpExpression) -> expression.Expression: - assert isinstance(expr.op, numeric_ops.FloorOp) - arg = expr.children[0] - if arg.output_type in (dtypes.INT_DTYPE, dtypes.BOOL_DTYPE): - return expr.op.as_expr(ops.AsTypeOp(dtypes.FLOAT_DTYPE).as_expr(arg)) - return expr - - class LowerIsinOp(op_lowering.OpLoweringRule): @property def op(self) -> type[ops.ScalarOp]: @@ -370,28 +347,11 @@ def lower(self, expr: expression.OpExpression) -> expression.Expression: 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, + bools_only: bool = False ): if bools_only: if ( @@ -411,6 +371,9 @@ def _coerce_comparables( 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 @@ -483,22 +446,8 @@ def _lower_cast(cast_op: ops.AsTypeOp, arg: expression.Expression): LowerAsTypeRule(), LowerInvertOp(), LowerIsinOp(), - LowerLenOp(), - LowerCeilOp(), - LowerFloorOp(), ) def lower_ops_to_polars(root: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode: return op_lowering.lower_ops(root, rules=POLARS_LOWERING_RULES) - - -def _numeric_to_timedelta(expr: expression.Expression) -> expression.Expression: - """rounding logic used for emulating timedelta ops""" - rounded_value = ops.where_op.as_expr( - ops.floor_op.as_expr(expr), - ops.gt_op.as_expr(expr, expression.const(0)), - ops.ceil_op.as_expr(expr), - ) - int_value = ops.AsTypeOp(to_type=dtypes.INT_DTYPE).as_expr(rounded_value) - return ops.AsTypeOp(to_type=dtypes.TIMEDELTA_DTYPE).as_expr(int_value) diff --git a/bigframes/core/compile/polars/operations/array_ops.py b/bigframes/core/compile/polars/operations/array_ops.py deleted file mode 100644 index 1f2960471db..00000000000 --- a/bigframes/core/compile/polars/operations/array_ops.py +++ /dev/null @@ -1,62 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.array_ops. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -import bigframes.core.compile.polars.compiler as polars_compiler -import bigframes.dtypes as dtypes -from bigframes.operations import generic_ops - -if TYPE_CHECKING: - import polars as pl - - -@polars_compiler.register_op(generic_ops.GetItemOp) -def getitem_op_impl( - compiler: polars_compiler.PolarsExpressionCompiler, - op: generic_ops.GetItemOp, # type: ignore - input: pl.Expr, -) -> pl.Expr: - input_type = compiler._expr_types.get(id(input)) - if input_type is not None and dtypes.is_struct_like(input_type): - if isinstance(op.key, str): - return input.struct.field(op.key) - else: - raise NotImplementedError( - "Referencing a struct field by number not implemented in polars compiler." - ) - elif input_type is not None and dtypes.is_string_like(input_type): - return input.str.slice(op.key, 1) - else: - return input.list.get(op.key) - - -@polars_compiler.register_op(generic_ops.DynamicGetItemOp) -def dynamic_getitem_op_impl( - compiler: polars_compiler.PolarsExpressionCompiler, - op: generic_ops.DynamicGetItemOp, # type: ignore - left: pl.Expr, - right: pl.Expr, -) -> pl.Expr: - left_type = compiler._expr_types.get(id(left)) - if left_type is not None and dtypes.is_string_like(left_type): - return left.str.slice(right, 1) - else: - return left.list.get(right) diff --git a/bigframes/core/compile/polars/operations/numeric_ops.py b/bigframes/core/compile/polars/operations/numeric_ops.py deleted file mode 100644 index 440415014e9..00000000000 --- a/bigframes/core/compile/polars/operations/numeric_ops.py +++ /dev/null @@ -1,172 +0,0 @@ -# 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 deleted file mode 100644 index 1573d4aa9b4..00000000000 --- a/bigframes/core/compile/polars/operations/struct_ops.py +++ /dev/null @@ -1,48 +0,0 @@ -# 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 index fa515e4f15a..2f408949759 100644 --- a/bigframes/core/compile/sqlglot/__init__.py +++ b/bigframes/core/compile/sqlglot/__init__.py @@ -13,20 +13,6 @@ # limitations under the License. from __future__ import annotations -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 -from bigframes.core.compile.sqlglot.compiler import compile_sql +from bigframes.core.compile.sqlglot.compiler import SQLGlotCompiler -__all__ = ["compile_sql"] +__all__ = ["SQLGlotCompiler"] diff --git a/bigframes/core/compile/sqlglot/aggregate_compiler.py b/bigframes/core/compile/sqlglot/aggregate_compiler.py index c0781e260c6..52ef4cc26c0 100644 --- a/bigframes/core/compile/sqlglot/aggregate_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregate_compiler.py @@ -13,10 +13,9 @@ # limitations under the License. from __future__ import annotations -import bigframes_vendored.sqlglot.expressions as sge +import sqlglot.expressions as sge -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes.core import agg_expressions, window_spec +from bigframes.core import expression, window_spec from bigframes.core.compile.sqlglot.aggregations import ( binary_compiler, nullary_compiler, @@ -24,18 +23,19 @@ 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, + aggregate: expression.Aggregation, order_by: tuple[sge.Expression, ...], ) -> sge.Expression: """Compiles BigFrames aggregation expression into SQLGlot expression.""" - if isinstance(aggregate, agg_expressions.NullaryAggregation): + if isinstance(aggregate, expression.NullaryAggregation): return nullary_compiler.compile(aggregate.op) - if isinstance(aggregate, agg_expressions.UnaryAggregation): + if isinstance(aggregate, expression.UnaryAggregation): column = typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(aggregate.arg), + scalar_compiler.compile_scalar_expression(aggregate.arg), aggregate.arg.output_type, ) if not aggregate.op.order_independent: @@ -44,13 +44,13 @@ def compile_aggregate( ) else: return unary_compiler.compile(aggregate.op, column) - elif isinstance(aggregate, agg_expressions.BinaryAggregation): + elif isinstance(aggregate, expression.BinaryAggregation): left = typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(aggregate.left), + scalar_compiler.compile_scalar_expression(aggregate.left), aggregate.left.output_type, ) right = typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(aggregate.right), + scalar_compiler.compile_scalar_expression(aggregate.right), aggregate.right.output_type, ) return binary_compiler.compile(aggregate.op, left, right) @@ -59,16 +59,18 @@ def compile_aggregate( def compile_analytic( - aggregate: agg_expressions.Aggregation, + aggregate: expression.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): + if isinstance(aggregate, expression.NullaryAggregation): + return nullary_compiler.compile(aggregate.op) + if isinstance(aggregate, expression.UnaryAggregation): column = typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(aggregate.arg), + scalar_compiler.compile_scalar_expression(aggregate.arg), aggregate.arg.output_type, ) return unary_compiler.compile(aggregate.op, column, window) + elif isinstance(aggregate, expression.BinaryAggregation): + raise NotImplementedError("binary analytic operations not yet supported") else: raise ValueError(f"Unexpected analytic operation: {aggregate}") diff --git a/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py index df8437fe76f..a162a9c18aa 100644 --- a/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/binary_compiler.py @@ -16,12 +16,11 @@ import typing -import bigframes_vendored.sqlglot.expressions as sge +import sqlglot.expressions as sge +from bigframes.core import window_spec import bigframes.core.compile.sqlglot.aggregations.op_registration as reg import bigframes.core.compile.sqlglot.expressions.typed_expr as typed_expr -from bigframes.core import window_spec -from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present from bigframes.operations import aggregations as agg_ops BINARY_OP_REGISTRATION = reg.OpRegistration() @@ -33,28 +32,4 @@ def compile( right: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - if op.order_independent and (window is not None) and window.is_unbounded: - window = window.without_order() 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 index f2f1978908f..99e3562b42e 100644 --- a/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/nullary_compiler.py @@ -16,10 +16,10 @@ import typing -import bigframes_vendored.sqlglot.expressions as sge +import sqlglot.expressions as sge -import bigframes.core.compile.sqlglot.aggregations.op_registration as reg 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 @@ -30,8 +30,6 @@ def compile( op: agg_ops.WindowOp, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - if op.order_independent and (window is not None) and window.is_unbounded: - window = window.without_order() return NULLARY_OP_REGISTRATION[op](op, window=window) @@ -41,15 +39,3 @@ def _( 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 index 2b3ba20ef09..996bf5b362b 100644 --- a/bigframes/core/compile/sqlglot/aggregations/op_registration.py +++ b/bigframes/core/compile/sqlglot/aggregations/op_registration.py @@ -16,7 +16,7 @@ import typing -from bigframes_vendored.sqlglot import expressions as sge +from sqlglot import expressions as sge from bigframes.operations import aggregations as agg_ops @@ -41,16 +41,22 @@ def arg_checker(*args, **kwargs): ) return item(*args, **kwargs) - key = str(op) - if key in self._registered_ops: - raise ValueError(f"{key} is already registered") + if hasattr(op, "name"): + key = typing.cast(str, op.name) + if key in self._registered_ops: + raise ValueError(f"{key} is already registered") + else: + raise ValueError(f"The operator must have a 'name' attribute. Got {op}") 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)] + if isinstance(op, agg_ops.WindowOp): + if not hasattr(op, "name"): + raise ValueError(f"The operator must have a 'name' attribute. Got {op}") + else: + key = typing.cast(str, op.name) + return self._registered_ops[key] + return self._registered_ops[op] diff --git a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py index 5feaf794e0b..dea30ec2066 100644 --- a/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/ordered_unary_compiler.py @@ -14,8 +14,11 @@ from __future__ import annotations -import bigframes_vendored.sqlglot.expressions as sge +import typing +import sqlglot.expressions as sge + +from bigframes.core import window_spec 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 @@ -26,35 +29,9 @@ 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, ...], + window: typing.Optional[window_spec.WindowSpec] = None, + order_by: typing.Sequence[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("")) + return ORDERED_UNARY_OP_REGISTRATION[op]( + op, column, window=window, order_by=order_by + ) diff --git a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py index 417faef34aa..c7eb84cba65 100644 --- a/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py +++ b/bigframes/core/compile/sqlglot/aggregations/unary_compiler.py @@ -16,17 +16,14 @@ import typing -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import pandas as pd +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 import dtypes from bigframes.core import window_spec -from bigframes.core.compile.sqlglot import sql +import bigframes.core.compile.sqlglot.aggregations.op_registration as reg from bigframes.core.compile.sqlglot.aggregations.windows import apply_window_if_present -from bigframes.core.compile.sqlglot.expressions import constants +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() @@ -37,81 +34,9 @@ def compile( column: typed_expr.TypedExpr, window: typing.Optional[window_spec.WindowSpec] = None, ) -> sge.Expression: - if op.order_independent and (window is not None) and window.is_unbounded: - window = window.without_order() 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: - expr = column.expr - if column.dtype != dtypes.BOOL_DTYPE: - expr = sge.NEQ(this=expr, expression=sge.convert(0)) - expr = apply_window_if_present(sge.func("LOGICAL_AND", expr), window) - - # BQ will return null for empty column, result would be true in pandas. - return sge.func("COALESCE", expr, sge.convert(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 - if column.dtype != dtypes.BOOL_DTYPE: - expr = sge.NEQ(this=expr, expression=sge.convert(0)) - 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, @@ -121,480 +46,6 @@ def _( 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 = sql.literal(this_bin, dtypes.INT_DTYPE) - elif isinstance(op.labels, typing.Iterable): - value = sql.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=sge.paren(column.expr, copy=False), - expression=sg.not_(sge.Null(), copy=False), - ) - 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 = sql.literal( - interval[0], dtypes.infer_literal_type(interval[0]) - ) - right: sge.Expression = sql.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 = sql.literal(this_bin, dtypes.INT_DTYPE) - elif isinstance(op.labels, typing.Iterable): - value = sql.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"), - ) - - if column.dtype == dtypes.DATE_DTYPE: - date_diff = sge.DateDiff( - this=column.expr, expression=shifted, unit=sge.Identifier(this="DAY") - ) - return sge.Cast( - this=sge.Floor(this=date_diff * constants._DAY_TO_MICROSECONDS), - to="INT64", - ) - - 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: - expr = column.expr - if column.dtype == dtypes.BOOL_DTYPE: - expr = sge.Cast(this=expr, to="INT64") - - # Need to short-circuit as log with zeroes is illegal sql - is_zero = sge.EQ(this=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.If( - this=is_zero, - true=sge.convert(0), - false=sge.func("LOG", sge.convert(2), sge.func("ABS", expr)), - ) - logs_sum = apply_window_if_present(sge.func("SUM", logs), window) - magnitude = sge.func("POWER", sge.convert(2), logs_sum) - - # Can't determine sign from logs, so have to determine parity of count of negative inputs - is_negative = ( - sge.Case() - .when( - sge.EQ(this=sge.func("SIGN", expr), expression=sge.convert(-1)), - 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.func("POWER", sge.convert(-1), negative_count_parity), - ) - ) - ) - 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: - expr = column.expr - if column.dtype == dtypes.BOOL_DTYPE: - expr = sge.Cast(this=expr, to="INT64") - - result: sge.Expression = sge.func("PERCENTILE_CONT", 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 or column.dtype == dtypes.TIMEDELTA_DTYPE: - 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, @@ -604,23 +55,15 @@ def _( 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, sql.literal(zero, column.dtype)) + expr = apply_window_if_present(sge.func("SUM", expr), window) + return sge.func("IFNULL", expr, ir._literal(0, column.dtype)) -@UNARY_OP_REGISTRATION.register(agg_ops.VarOp) +@UNARY_OP_REGISTRATION.register(agg_ops.SizeUnaryOp) def _( - op: agg_ops.VarOp, - column: typed_expr.TypedExpr, + op: agg_ops.SizeUnaryOp, + _, 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) + return apply_window_if_present(sge.func("COUNT", sge.convert(1)), window) diff --git a/bigframes/core/compile/sqlglot/aggregations/windows.py b/bigframes/core/compile/sqlglot/aggregations/windows.py index cb4a2e70edd..47fd43bd089 100644 --- a/bigframes/core/compile/sqlglot/aggregations/windows.py +++ b/bigframes/core/compile/sqlglot/aggregations/windows.py @@ -15,20 +15,16 @@ import typing -import bigframes_vendored.sqlglot.expressions as sge +import sqlglot.expressions as sge -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -import bigframes.core.expression as ex -import bigframes.core.ordering as ordering_spec -import bigframes.dtypes as dtypes from bigframes.core import utils, window_spec +import bigframes.core.compile.sqlglot.scalar_compiler as scalar_compiler +import bigframes.core.ordering as ordering_spec 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 @@ -43,33 +39,30 @@ def apply_window_if_present( # Unbound grouping window. order_by = None elif window.is_range_bounded: - order_by = get_window_order_by((window.ordering[0],)) - order_by = remove_null_ordering_for_range_windows(order_by) + # Note that, when the window is range-bounded, we only need one ordering key. + # 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. + order_by = get_window_order_by((window.ordering[0],), override_null_order=False) else: - order_by = get_window_order_by(window.ordering) + order_by = get_window_order_by(window.ordering, override_null_order=True) - 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) + order = sge.Order(expressions=order_by) if order_by else None group_by = ( - [_compile_group_by_key(key) for key in window.grouping_keys] + [scalar_compiler.compile_scalar_expression(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: + if not window.bounds 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" + "ROWS" if isinstance(window.bounds, window_spec.RowsWindowBounds) else "RANGE" ) start: typing.Union[int, float, None] = None @@ -102,27 +95,21 @@ 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. - """ + """Returns the SQL order by clause for a window specification.""" if not ordering: return None order_by = [] for ordering_spec_item in ordering: - expr = expression_compiler.expression_compiler.compile_expression( + expr = scalar_compiler.compile_scalar_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: + # Bigquery SQL considers NULLS to be "smallest" values, but we need + # to override in these cases. is_null_expr = sge.Is(this=expr, expression=sge.Null()) if nulls_first and desc: order_by.append( @@ -132,7 +119,7 @@ def get_window_order_by( nulls_first=nulls_first, ) ) - elif (not nulls_first) and (not desc): + elif not nulls_first and not desc: order_by.append( sge.Ordered( this=is_null_expr, @@ -151,30 +138,6 @@ def get_window_order_by( return tuple(order_by) -def remove_null_ordering_for_range_windows( - order_by: typing.Optional[tuple[sge.Ordered, ...]], -) -> typing.Optional[tuple[sge.Ordered, ...]]: - """Removes NULL FIRST/LAST from ORDER BY expressions in RANGE windows. - Here's the support matrix: - ✅ sum(x) over (order by y desc nulls last) - 🚫 sum(x) over (order by y asc nulls last) - ✅ sum(x) over (order by y asc nulls first) - 🚫 sum(x) over (order by y desc nulls first) - """ - if order_by is None: - return None - - new_order_by = [] - for key in order_by: - kargs = key.args - if kargs.get("desc") is True and kargs.get("nulls_first", False): - kargs["nulls_first"] = False - elif kargs.get("desc") is False and not kargs.setdefault("nulls_first", True): - kargs["nulls_first"] = True - new_order_by.append(sge.Ordered(**kargs)) - return tuple(new_order_by) - - def _get_window_bounds( value, is_preceding: bool ) -> tuple[typing.Union[str, sge.Expression], typing.Optional[str]]: @@ -188,18 +151,3 @@ def _get_window_bounds( side = "PRECEDING" if value < 0 else "FOLLOWING" return sge.convert(abs(value)), side - - -def _compile_group_by_key(key: ex.Expression) -> sge.Expression: - expr = expression_compiler.expression_compiler.compile_expression(key) - # The group_by keys has been rewritten by bind_schema_to_node - assert key.is_scalar_expr and key.is_resolved - - # Some types need to be converted to another type to enable groupby - if key.output_type == dtypes.FLOAT_DTYPE: - expr = sge.Cast(this=expr, to="STRING") - elif key.output_type == dtypes.GEO_DTYPE: - expr = sge.func("ST_ASBINARY", expr) - elif key.output_type == 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 index 393d10ec825..b4dc6174beb 100644 --- a/bigframes/core/compile/sqlglot/compiler.py +++ b/bigframes/core/compile/sqlglot/compiler.py @@ -17,362 +17,358 @@ import functools import typing -import bigframes_vendored.sqlglot.expressions as sge +from google.cloud import bigquery +import sqlglot.expressions as sge -import bigframes.core.compile.sqlglot.aggregate_compiler as aggregate_compiler -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -import bigframes.core.ordering as bf_ordering -from bigframes import dtypes -from bigframes.core import ( - expression, - guid, - identifiers, - nodes, - pyarrow_utils, - rewrite, - sql_nodes, -) +from bigframes.core import expression, guid, identifiers, nodes, pyarrow_utils, rewrite from bigframes.core.compile import configs -from bigframes.core.compile.sqlglot import sql, sqlglot_ir +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 -from bigframes.core.logging import data_types as data_type_logger +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.""" - - 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 = typing.cast(nodes.ResultNode, _replace_unsupported_ops(result_node)) - result_node = typing.cast( - nodes.ResultNode, result_node.bottom_up(rewrite.simplify_join) - ) - # 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: +class SQLGlotCompiler: + """Compiles BigFrame nodes into SQL using SQLGlot.""" + + uid_gen: guid.SequentialUIDGenerator + """Generator for unique identifiers.""" + + def __init__(self): + self.uid_gen = guid.SequentialUIDGenerator() + + def compile( + self, + node: nodes.BigFrameNode, + *, + ordered: bool = True, + limit: typing.Optional[int] = None, + ) -> str: + """Compiles node into sql where rows are sorted with ORDER BY.""" + request = configs.CompileRequest(node, sort_rows=ordered, peek_count=limit) + return self._compile_sql(request).sql + + def compile_raw( + self, + node: nodes.BigFrameNode, + ) -> typing.Tuple[ + str, typing.Sequence[bigquery.SchemaField], bf_ordering.RowOrdering + ]: + """Compiles node into sql that exposes all columns, including hidden + ordering-only columns.""" + request = configs.CompileRequest( + node, sort_rows=False, materialize_all_order_keys=True + ) + result = self._compile_sql(request) + assert result.row_order is not None + return result.sql, result.sql_schema, result.row_order + + def _compile_sql(self, 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 = 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 = self._remap_variables(result_node) + result_node = typing.cast( + nodes.ResultNode, rewrite.defer_selection(result_node) + ) + sql = self._compile_result_node(result_node) + 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)) - encoded_type_refs = data_type_logger.encode_type_refs(result_node) - # TODO: Extract CTEs earlier - result_node = typing.cast(nodes.ResultNode, rewrite.extract_ctes(result_node)) - sql = _compile_result_node(result_node) + + result_node = self._remap_variables(result_node) + result_node = typing.cast( + nodes.ResultNode, rewrite.defer_selection(result_node) + ) + sql = self._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(), - result_node.order_by, - encoded_type_refs, + sql, result_node.schema.to_bigquery(), output_order ) - 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)) - encoded_type_refs = data_type_logger.encode_type_refs(result_node) - # TODO: Extract CTEs earlier - result_node = typing.cast(nodes.ResultNode, rewrite.extract_ctes(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 + def _remap_variables(self, node: nodes.ResultNode) -> nodes.ResultNode: + """Remaps `ColumnId`s in the BFET of a `ResultNode` to produce deterministic UIDs.""" + + result_node, _ = rewrite.remap_variables( + node, map(identifiers.ColumnId, self.uid_gen.get_uid_stream("bfcol_")) ) - assert (not request.materialize_all_order_keys) or (output_order is not None) - return configs.CompileResult( - sql, result_node.schema.to_bigquery(), output_order, encoded_type_refs - ) - - -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_")) - ) - result_node.validate_tree() - return typing.cast(nodes.ResultNode, result_node) - - -def _compile_result_node(root: nodes.ResultNode) -> str: - # Create UIDs to standardize variable names and ensure consistent compilation - # of nodes using the same generator. - uid_gen = guid.SequentialUIDGenerator() - root = _remap_variables(root, uid_gen) - # Remap variables creates too mayn new - # root = rewrite.select_pullup(root, prefer_source_names=False) - root = typing.cast(nodes.ResultNode, rewrite.defer_selection(root)) - - # Have to bind schema as the final step before compilation. - # Probably, should defer even further - root = typing.cast(nodes.ResultNode, schema_binding.bind_schema_to_tree(root)) - - # TODO: Bake all IDs in tree, stop passing uid_gen to emitters - sqlglot_ir_obj = compile_node(rewrite.as_sql_nodes(root, uid_gen), uid_gen) - return sqlglot_ir_obj.sql - - -def compile_node( - node: nodes.BigFrameNode, uid_gen: guid.SequentialUIDGenerator -) -> sqlglot_ir.SQLGlotIR: - """Compiles the given BigFrameNode from bottem-up into SQLGlotIR.""" - bf_to_sqlglot: dict[nodes.BigFrameNode, sqlglot_ir.SQLGlotIR] = {} - child_results: tuple[sqlglot_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([sqlglot_ir.SQLGlotIR.empty(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 + return typing.cast(nodes.ResultNode, result_node) + + def _compile_result_node(self, root: nodes.ResultNode) -> 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.compile_scalar_expression(ref)) + for ref, name in root.output_cols + ) + sqlglot_ir = self.compile_node(root.child).select(selected_cols) + + if root.order_by is not None: + ordering_cols = tuple( + sge.Ordered( + this=scalar_compiler.compile_scalar_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 ) - result = _compile_node(current_node, *child_results) - bf_to_sqlglot[current_node] = result + sqlglot_ir = sqlglot_ir.order_by(ordering_cols) - return bf_to_sqlglot[node] + if root.limit is not None: + sqlglot_ir = sqlglot_ir.limit(root.limit) + return sqlglot_ir.sql -@functools.singledispatch -def _compile_node( - node: nodes.BigFrameNode, *compiled_children: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - """Defines transformation but isn't cached, always use compile_node instead""" - raise ValueError(f"Can't compile unrecognized node: {node}") + @functools.lru_cache(maxsize=5000) + def compile_node(self, node: nodes.BigFrameNode) -> ir.SQLGlotIR: + """Compiles node into CompileArrayValue. Caches result.""" + return node.reduce_up( + lambda node, children: self._compile_node(node, *children) + ) + @functools.singledispatchmethod + def _compile_node( + self, 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(self, node: nodes.ReadLocalNode, *args) -> 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] + ) -@_compile_node.register -def compile_sql_select(node: sql_nodes.SqlSelectNode, child: sqlglot_ir.SQLGlotIR): - ordering_cols = tuple( - sge.Ordered( - this=expression_compiler.expression_compiler.compile_expression( - ordering.scalar_expression - ), - desc=ordering.direction.is_ascending is False, - nulls_first=ordering.na_last is False, + 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=self.uid_gen) + + @_compile_node.register + def compile_readtable(self, node: nodes.ReadTableNode, *args): + 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=self.uid_gen, ) - for ordering in node.sorting - ) - projected_cols: tuple[tuple[str, sge.Expression], ...] = tuple() - if not node.is_star_selection: - projected_cols = tuple( + @_compile_node.register + def compile_selection( + self, node: nodes.SelectionNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + selected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.compile_scalar_expression(expr)) + for expr, id in node.input_output_pairs + ) + return child.select(selected_cols) + + @_compile_node.register + def compile_projection( + self, node: nodes.ProjectionNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + projected_cols: tuple[tuple[str, sge.Expression], ...] = tuple( + (id.sql, scalar_compiler.compile_scalar_expression(expr)) + for expr, id in node.assignments + ) + return child.project(projected_cols) + + @_compile_node.register + def compile_filter( + self, node: nodes.FilterNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + condition = scalar_compiler.compile_scalar_expression(node.predicate) + return child.filter(tuple([condition])) + + @_compile_node.register + def compile_join( + self, node: nodes.JoinNode, left: ir.SQLGlotIR, right: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + conditions = tuple( ( - cdef.id.sql, - expression_compiler.expression_compiler.compile_expression( - cdef.expression + typed_expr.TypedExpr( + scalar_compiler.compile_scalar_expression(left), left.output_type + ), + typed_expr.TypedExpr( + scalar_compiler.compile_scalar_expression(right), right.output_type ), ) - for cdef in node.selections + for left, right in node.conditions ) - sge_predicates = tuple( - expression_compiler.expression_compiler.compile_expression(expression) - for expression in node.predicates - ) - - return child.select(projected_cols, sge_predicates, ordering_cols, node.limit) - - -@_compile_node.register -def compile_readlocal( - node: nodes.ReadLocalNode, child: sqlglot_ir.SQLGlotIR -) -> sqlglot_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 sqlglot_ir.SQLGlotIR.from_pyarrow( - pa_table, node.schema, uid_gen=child.uid_gen - ) - - -@_compile_node.register -def compile_readtable(node: sql_nodes.SqlDataSource, child: sqlglot_ir.SQLGlotIR): - table_obj = node.source.table - columns = () if node.is_star_selection else node.source.schema.names - return sqlglot_ir.SQLGlotIR.from_table( - table_obj.project_id, - table_obj.dataset_id, - table_obj.table_id, - uid_gen=child.uid_gen, - columns=columns, - sql_predicate=node.source.sql_predicate, - system_time=node.source.at_time, - ) - - -@_compile_node.register -def compile_join( - node: nodes.JoinNode, left: sqlglot_ir.SQLGlotIR, right: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - conditions = tuple( - ( - typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(left_expr), - left_expr.output_type, - ), - typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(right_expr), - right_expr.output_type, - ), + return left.join( + right, + join_type=node.type, + conditions=conditions, + joins_nulls=node.joins_nulls, ) - for left_expr, right_expr 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: sqlglot_ir.SQLGlotIR, right: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - right_field = node.right_child.fields[0] - conditions = ( - typed_expr.TypedExpr( - expression_compiler.expression_compiler.compile_expression(node.left_col), - node.left_col.output_type, - ), - typed_expr.TypedExpr( - expression_compiler.expression_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_cte_ref_node(node: sql_nodes.SqlCteRefNode, child: sqlglot_ir.SQLGlotIR): - return sqlglot_ir.SQLGlotIR.from_cte_ref( - node.cte_name, - uid_gen=child.uid_gen, - ) - - -@_compile_node.register -def compile_with_ctes_node( - node: sql_nodes.SqlWithCtesNode, - child: sqlglot_ir.SQLGlotIR, - *ctes: sqlglot_ir.SQLGlotIR, -): - return child.with_ctes(tuple(zip(node.cte_names, ctes))) - - -@_compile_node.register -def compile_concat( - node: nodes.ConcatNode, *children: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - assert len(children) >= 1 - uid_gen = children[0].uid_gen - - # BigQuery `UNION` query takes the column names from the first `SELECT` clause. - default_output_ids = [field.id.sql for field in node.child_nodes[0].fields] - output_aliases = [ - (default_output_id, output_id.sql) - for default_output_id, output_id in zip(default_output_ids, node.output_ids) - ] - - return sqlglot_ir.SQLGlotIR.from_union( - [child.expr.as_select_all() for child in children], - output_aliases=output_aliases, - uid_gen=uid_gen, - ) - - -@_compile_node.register -def compile_explode( - node: nodes.ExplodeNode, child: sqlglot_ir.SQLGlotIR -) -> sqlglot_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_fromrange( - node: nodes.FromRangeNode, start: sqlglot_ir.SQLGlotIR, end: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - start_col_id = node.start.fields[0].id - end_col_id = node.end.fields[0].id - - start_expr = expression_compiler.expression_compiler.compile_expression( - expression.DerefOp(start_col_id) - ) - end_expr = expression_compiler.expression_compiler.compile_expression( - expression.DerefOp(end_col_id) - ) - step_expr = sql.literal(node.step, dtypes.INT_DTYPE) - - return start.resample(end, node.output_id.sql, start_expr, end_expr, step_expr) - - -@_compile_node.register -def compile_random_sample( - node: nodes.RandomSampleNode, child: sqlglot_ir.SQLGlotIR -) -> sqlglot_ir.SQLGlotIR: - return child.sample(node.fraction) - - -@_compile_node.register -def compile_aggregate( - node: nodes.AggregateNode, child: sqlglot_ir.SQLGlotIR -) -> sqlglot_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 () - ), + + @_compile_node.register + def compile_concat( + self, node: nodes.ConcatNode, *children: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + 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=self.uid_gen, ) - for agg, id in node.aggregations - ) - by_cols: tuple[sge.Expression, ...] = tuple( - expression_compiler.expression_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) + @_compile_node.register + def compile_explode( + self, 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( + self, node: nodes.RandomSampleNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + return child.sample(node.fraction) + + @_compile_node.register + def compile_aggregate( + self, node: nodes.AggregateNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + 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.compile_scalar_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( + self, node: nodes.WindowOpNode, child: ir.SQLGlotIR + ) -> ir.SQLGlotIR: + window_spec = node.window_spec + if node.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(node.expression, window_spec) - return child.aggregate(aggregations, by_cols, tuple(dropna_cols)) + inputs: tuple[sge.Expression, ...] = tuple( + scalar_compiler.compile_scalar_expression(expression.DerefOp(column)) + for column in node.expression.column_references + ) + + clauses: list[tuple[sge.Expression, sge.Expression]] = [] + if node.expression.op.skips_nulls and not node.never_skip_nulls: + for column in inputs: + clauses.append((sge.Is(this=column, expression=sge.Null()), sge.Null())) + + if window_spec.min_periods and len(inputs) > 0: + if node.expression.op.skips_nulls: + # 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. + return child.window( + window_op=window_op, + output_column_id=node.output_name.sql, + ) def _replace_unsupported_ops(node: nodes.BigFrameNode): node = nodes.bottom_up(node, rewrite.rewrite_slice) node = nodes.bottom_up(node, rewrite.rewrite_range_rolling) - node = nodes.bottom_up(node, rewrite.lower_udfs) return node diff --git a/bigframes/core/compile/sqlglot/expression_compiler.py b/bigframes/core/compile/sqlglot/expression_compiler.py deleted file mode 100644 index b412249a39f..00000000000 --- a/bigframes/core/compile/sqlglot/expression_compiler.py +++ /dev/null @@ -1,236 +0,0 @@ -# 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 bigframes_vendored.sqlglot.expressions as sge - -import bigframes.core.agg_expressions as agg_exprs -import bigframes.core.expression as ex -import bigframes.operations as ops -from bigframes.core.compile.sqlglot import sql -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - - -class ExpressionCompiler: - # 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, - sge.Like, - sge.RegexpLike, - sge.In, - sge.Between, - # 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 sql.literal(expr.value, expr.dtype) - - @compile_expression.register - def _(self, expr: agg_exprs.WindowExpression) -> sge.Expression: - import bigframes.core.compile.sqlglot.aggregate_compiler as agg_compile - - return agg_compile.compile_analytic( - expr.analytic_expr, - expr.window, - ) - - @compile_expression.register - def _(self, expr: ex.OpExpression) -> sge.Expression: - inputs = tuple( - TypedExpr(self.compile_expression(sub_expr), sub_expr.output_type) - if not isinstance(sub_expr, ex.OmittedArg) - else TypedExpr(sge.Null(), None, is_omitted=True) - 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 -expression_compiler = ExpressionCompiler() diff --git a/bigframes/core/compile/sqlglot/expressions/__init__.py b/bigframes/core/compile/sqlglot/expressions/__init__.py index f42d5c7d99e..0a2669d7a25 100644 --- a/bigframes/core/compile/sqlglot/expressions/__init__.py +++ b/bigframes/core/compile/sqlglot/expressions/__init__.py @@ -11,11 +11,3 @@ # WITHOUT 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 deleted file mode 100644 index d092f662f0f..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/ai_ops.py +++ /dev/null @@ -1,161 +0,0 @@ -# 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 -from typing import Any - -import bigframes_vendored.sqlglot.expressions as sge - -from bigframes import operations as ops -from bigframes.core.compile.sqlglot import expression_compiler -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_nary_op = expression_compiler.expression_compiler.register_nary_op -register_binary_op = expression_compiler.expression_compiler.register_binary_op -register_unary_op = expression_compiler.expression_compiler.register_unary_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_unary_op(ops.AIEmbed, pass_op=True) -def _(expr: TypedExpr, op: ops.AIEmbed) -> sge.Expression: - args: list[Any] = [expr.expr] + _construct_named_args(op) - - return sge.func("AI.EMBED", *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: - args = [ - _construct_prompt(exprs, op.prompt_context, param_name="input"), - ] + _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) - - -@register_binary_op(ops.AISimilarity, pass_op=True) -def _(content1: TypedExpr, content2: TypedExpr, op: ops.AISimilarity) -> sge.Expression: - args = [ - sge.Kwarg(this="content1", expression=content1.expr), - sge.Kwarg(this="content2", expression=content2.expr), - ] + _construct_named_args(op) - - return sge.func("AI.SIMILARITY", *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)) - - # Need Struct rather than tuple syntax, as tuple syntax is ambiguous for single arg - return sge.Kwarg(this=param_name, expression=sge.Struct(expressions=prompt)) - - -def _construct_named_args(op: ops.ScalarOp) -> list[sge.Kwarg]: - args = [] - - op_args = asdict(op) - - for field, value in op_args.items(): - if value is None or field == "prompt_context": - continue - - if field == "categories": - category_literals = [sge.Literal.string(cat) for cat in value] - categories_arg = sge.Kwarg( - this="categories", expression=sge.array(*category_literals) - ) - args.append(categories_arg) - elif field == "model_params": - # model_params is a JSON string, so we need to use the JSON function to pass it as a named argument. - 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(value)), - ) - ) - elif field == "examples": - example_expressions = [] - for key, val in value: - if isinstance(val, (list, tuple)): - val_expr: sge.Array | sge.Literal = sge.array( - *[sge.Literal.string(v) for v in val] - ) - else: - val_expr = sge.Literal.string(val) - example_expressions.append( - sge.Tuple(expressions=[sge.Literal.string(key), val_expr]) - ) - args.append( - sge.Kwarg(this=field, expression=sge.array(*example_expressions)) - ) - else: - args.append(sge.Kwarg(this=field, expression=sge.convert(value))) - - return args diff --git a/bigframes/core/compile/sqlglot/expressions/array_ops.py b/bigframes/core/compile/sqlglot/expressions/array_ops.py deleted file mode 100644 index 56ffbf24cb3..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/array_ops.py +++ /dev/null @@ -1,193 +0,0 @@ -# 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.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import pandas as pd -import pyarrow as pa - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -import bigframes.dtypes as dtypes -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 - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_nary_op = expression_compiler.expression_compiler.register_nary_op - - -@register_unary_op(ops.GetItemOp, pass_op=True) -def _(expr: TypedExpr, op: ops.GetItemOp) -> sge.Expression: - if dtypes.is_struct_like(expr.dtype): - if isinstance(op.key, str): - name = op.key - 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.key).name - - return sge.Column( - this=sge.to_identifier(name, quoted=True), - catalog=expr.expr, - ) - elif dtypes.is_array_like(expr.dtype): - return sge.Bracket( - this=expr.expr, - expressions=[sge.convert(op.key)], - safe=True, - offset=False, - ) - elif expr.dtype == dtypes.STRING_DTYPE: - return string_index(expr, typing.cast(int, op.key)) - else: - raise TypeError(f"Cannot subscript input of type {expr.dtype}") - - -@register_nary_op(ops.DynamicGetItemOp) # type: ignore[arg-type] -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - if dtypes.is_array_like(left.dtype): - return sge.Bracket( - this=left.expr, - expressions=[right.expr], - safe=True, - offset=False, - ) - elif left.dtype == dtypes.STRING_DTYPE: - start_expr = sge.Add(this=right.expr, expression=sge.convert(1)) - sub_str = sge.Substring( - this=left.expr, - start=start_expr, - length=sge.convert(1), - ) - return sge.If( - this=sge.NEQ(this=sub_str, expression=sge.convert("")), - true=sub_str, - false=sge.Null(), - ) - else: - raise TypeError(f"Cannot dynamically subscript input of type {left.dtype}") - - -@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.ArrayMapOp, pass_op=True) -def _(expr: TypedExpr, op: ops.ArrayMapOp) -> sge.Expression: - sub_expr = sg.to_identifier("bf_arr_map_uid") - sub_type = dtypes.get_array_inner_type(expr.dtype) - - # TODO: Expression should be provided instead of invoking compiler manually - map_expr = expression_compiler.expression_compiler.compile_row_op( - op.map_op, (TypedExpr(sub_expr, sub_type),) - ) - - return sge.array( - sge.select(map_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=sge.convert(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 _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/binary_compiler.py b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py new file mode 100644 index 00000000000..3fcba04cfd6 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/binary_compiler.py @@ -0,0 +1,244 @@ +# 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.op_registration import OpRegistration +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +BINARY_OP_REGISTRATION = OpRegistration() + + +def compile(op: ops.BinaryOp, left: TypedExpr, right: TypedExpr) -> sge.Expression: + return BINARY_OP_REGISTRATION[op](op, left, right) + + +# TODO: add parenthesize for operators +@BINARY_OP_REGISTRATION.register(ops.add_op) +def _(op, 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}" + ) + + +@BINARY_OP_REGISTRATION.register(ops.eq_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.eq_null_match_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.div_op) +def _(op, 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 + + +@BINARY_OP_REGISTRATION.register(ops.floordiv_op) +def _(op, 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 + + +@BINARY_OP_REGISTRATION.register(ops.ge_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.gt_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.JSONSet) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("JSON_SET", left.expr, sge.convert(op.json_path), right.expr) + + +@BINARY_OP_REGISTRATION.register(ops.lt_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.le_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.mul_op) +def _(op, 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 + + +@BINARY_OP_REGISTRATION.register(ops.ne_op) +def _(op, 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) + + +@BINARY_OP_REGISTRATION.register(ops.obj_make_ref_op) +def _(op, left: TypedExpr, right: TypedExpr) -> sge.Expression: + return sge.func("OBJ.MAKE_REF", left.expr, right.expr) + + +@BINARY_OP_REGISTRATION.register(ops.sub_op) +def _(op, 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}" + ) + + +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/blob_ops.py b/bigframes/core/compile/sqlglot/expressions/blob_ops.py deleted file mode 100644 index 01b4f7a1617..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/blob_ops.py +++ /dev/null @@ -1,52 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_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, pass_op=True) -def _(expr: TypedExpr, op: ops.ObjGetAccessUrl) -> sge.Expression: - args = [expr.expr, sge.Literal.string(op.mode)] - if op.duration is not None: - args.append( - sge.Interval( - this=sge.Literal.number(op.duration), - unit=sge.Var(this="MICROSECOND"), - ) - ) - return sge.func("OBJ.GET_ACCESS_URL", *args) - - -@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) - - -@register_unary_op(ops.obj_make_ref_json_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("OBJ.MAKE_REF", expr.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/bool_ops.py b/bigframes/core/compile/sqlglot/expressions/bool_ops.py deleted file mode 100644 index 7e31646b295..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/bool_ops.py +++ /dev/null @@ -1,86 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot import sql -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_binary_op = expression_compiler.expression_compiler.register_binary_op - - -@register_binary_op(ops.and_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - # For AND, when we encounter a NULL value, we only know when the result is FALSE, - # otherwise the result is unknown (NULL). See: truth table at - # https://en.wikibooks.org/wiki/Structured_Query_Language/NULLs_and_the_Three_Valued_Logic#AND,_OR - if sql.is_null_literal(left.expr): - condition = sge.EQ(this=right.expr, expression=sge.convert(False)) - return sge.If(this=condition, true=right.expr, false=sge.null()) - if sql.is_null_literal(right.expr): - condition = sge.EQ(this=left.expr, expression=sge.convert(False)) - return sge.If(this=condition, true=left.expr, false=sge.null()) - - 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: - # For OR, when we encounter a NULL value, we only know when the result is TRUE, - # otherwise the result is unknown (NULL). See: truth table at - # https://en.wikibooks.org/wiki/Structured_Query_Language/NULLs_and_the_Three_Valued_Logic#AND,_OR - if sql.is_null_literal(left.expr): - condition = sge.EQ(this=right.expr, expression=sge.convert(True)) - return sge.If(this=condition, true=right.expr, false=sge.null()) - if sql.is_null_literal(right.expr): - condition = sge.EQ(this=left.expr, expression=sge.convert(True)) - return sge.If(this=condition, true=left.expr, false=sge.null()) - - 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: - # For XOR, cast NULL operands to BOOLEAN to ensure the resulting expression - # maintains the boolean data type. - left_expr = left.expr - left_dtype = left.dtype - if sql.is_null_literal(left_expr): - left_expr = sge.Cast(this=sge.convert(None), to="BOOLEAN") - left_dtype = dtypes.BOOL_DTYPE - right_expr = right.expr - right_dtype = right.dtype - if sql.is_null_literal(right_expr): - right_expr = sge.Cast(this=sge.convert(None), to="BOOLEAN") - right_dtype = dtypes.BOOL_DTYPE - - if left_dtype == dtypes.BOOL_DTYPE and right_dtype == dtypes.BOOL_DTYPE: - return sge.Or( - this=sge.paren( - sge.And(this=left_expr, expression=sge.Not(this=right_expr)) - ), - expression=sge.paren( - sge.And(this=sge.Not(this=left_expr), expression=right_expr) - ), - ) - return sge.BitwiseXor(this=left.expr, expression=right.expr) diff --git a/bigframes/core/compile/sqlglot/expressions/common.py b/bigframes/core/compile/sqlglot/expressions/common.py deleted file mode 100644 index 067ca070edf..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/common.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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.sqlglot.expressions as sge - - -def round_towards_zero(expr: sge.Expression): - """ - Round a float value to to an integer, always rounding towards zero. - - This is used to handle duration/timedelta emulation mostly. - """ - return sge.Cast( - this=sge.If( - this=sge.GT(this=expr, expression=sge.convert(0)), - true=sge.Floor(this=expr), - false=sge.Ceil(this=expr), - ), - to="INT64", - ) diff --git a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py b/bigframes/core/compile/sqlglot/expressions/comparison_ops.py deleted file mode 100644 index a3331ce6fb5..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/comparison_ops.py +++ /dev/null @@ -1,181 +0,0 @@ -# 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.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import pandas as pd - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot import sql -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_compiler.register_binary_op - - -@register_unary_op(ops.IsInOp, pass_op=True) -def _(expr: TypedExpr, op: ops.IsInOp) -> sge.Expression: - values = [] - # bools are not comparable to non-bools in SQL, so we need to cast the expression to INT64 if the values contain non-bools. - must_upcast_bools = dtypes.is_numeric(expr.dtype, include_bool=False) or any( - dtypes.is_numeric(dtypes.bigframes_type(type(value)), include_bool=False) - for value in op.values - if not _is_null(value) - ) - for value in op.values: - if _is_null(value): - continue - dtype = dtypes.bigframes_type(type(value)) - if dtypes.can_compare(expr.dtype, dtype): - if must_upcast_bools and dtype == dtypes.BOOL_DTYPE: - value = int(value) - values.append(sql.literal(value)) - - sg_lexpr: sge.Expression = expr.expr - if expr.dtype == dtypes.BOOL_DTYPE and must_upcast_bools: - sg_lexpr = sge.cast(expr.expr, "INT64") - - if op.match_nulls: - contains_nulls = any(_is_null(value) for value in op.values) - if contains_nulls: - if len(values) == 0: - return sge.Is(this=sg_lexpr, expression=sge.Null()) - return sge.Is(this=sg_lexpr, expression=sge.Null()) | sge.In( - this=sg_lexpr, expressions=values - ) - - if len(values) == 0: - return sge.convert(False) - - return sge.func( - "COALESCE", sge.In(this=sg_lexpr, expressions=values), sge.convert(False) - ) - - -@register_binary_op(ops.eq_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - if sql.is_null_literal(left.expr): - return sge.Is(this=right.expr, expression=sge.Null()) - if sql.is_null_literal(right.expr): - return sge.Is(this=left.expr, expression=sge.Null()) - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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: - if sql.is_null_literal(left.expr): - return sge.Is( - this=sge.paren(right.expr, copy=False), - expression=sg.not_(sge.Null(), copy=False), - ) - if sql.is_null_literal(right.expr): - return sge.Is( - this=sge.paren(left.expr, copy=False), - expression=sg.not_(sge.Null(), copy=False), - ) - - 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 index 5ba4a72279f..20857f62913 100644 --- a/bigframes/core/compile/sqlglot/expressions/constants.py +++ b/bigframes/core/compile/sqlglot/expressions/constants.py @@ -12,27 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import math - -import bigframes_vendored.sqlglot.expressions as sge +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") -_DAY_TO_MICROSECONDS = sge.convert(86400000000) # 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 deleted file mode 100644 index 2410926887b..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/date_ops.py +++ /dev/null @@ -1,72 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_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 deleted file mode 100644 index 399b3062273..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/datetime_ops.py +++ /dev/null @@ -1,734 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -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 import sqlglot_types -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_compiler.register_binary_op - - -@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 in ("M", "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 in ("Q-DEC", "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 in ("A-DEC", "Y-DEC", "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)) - - -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="DATE"), to="TIMESTAMP"), - ) - elif origin == "start": - return sge.func("UNIX_MICROS", sge.Cast(this=y.expr, to="TIMESTAMP")) - else: - raise ValueError(f"Origin {origin} not supported") - - -@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: - return sge.TryCast(this=result, to="DATETIME") - else: - 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.TIMESTAMP_DTYPE: - return sge.func("DATETIME", expr.expr, sge.convert("UTC")) - - if expr.dtype in ( - dtypes.STRING_DTYPE, - dtypes.DATETIME_DTYPE, - dtypes.DATE_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.func( - "DATETIME", - sge.func("TIMESTAMP_MICROS", sge.Cast(this=value, to="INT64")), - sge.convert("UTC"), - ) - - -@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), result, sge.convert("UTC") - ) - - if expr.dtype in ( - dtypes.STRING_DTYPE, - dtypes.DATETIME_DTYPE, - dtypes.TIMESTAMP_DTYPE, - dtypes.DATE_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) - - -@register_binary_op(ops.IntegerLabelToDatetimeOp, pass_op=True) -def integer_label_to_datetime_op( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - # Determine if the frequency is fixed by checking if 'op.freq.nanos' is defined. - try: - return _integer_label_to_datetime_op_fixed_frequency(x, y, op) - - except ValueError: - # Non-fixed frequency conversions for units ranging from weeks to years. - rule_code = op.freq.rule_code - - if rule_code == "W-SUN": - return _integer_label_to_datetime_op_weekly_freq(x, y, op) - - if rule_code in ("ME", "M"): - return _integer_label_to_datetime_op_monthly_freq(x, y, op) - - if rule_code in ("QE-DEC", "Q-DEC"): - return _integer_label_to_datetime_op_quarterly_freq(x, y, op) - - if rule_code in ("YE-DEC", "A-DEC", "Y-DEC"): - return _integer_label_to_datetime_op_yearly_freq(x, y, op) - - # If the rule_code is not recognized, raise an error here. - raise ValueError(f"Unsupported frequency rule code: {rule_code}") - - -def _integer_label_to_datetime_op_fixed_frequency( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - """ - This function handles fixed frequency conversions where the unit can range - from microseconds (us) to days. - """ - us = op.freq.nanos / 1000 - first = _calculate_resample_first(y, op.origin) # type: ignore - x_label = sge.Cast( - this=sge.func( - "TIMESTAMP_MICROS", - sge.Cast( - this=sge.Add( - this=sge.Mul( - this=sge.Cast(this=x.expr, to="BIGNUMERIC"), - expression=sge.convert(int(us)), - ), - expression=sge.Cast(this=first, to="BIGNUMERIC"), - ), - to="INT64", - ), - ), - to=sqlglot_types.from_bigframes_dtype(y.dtype), - ) - return x_label - - -def _integer_label_to_datetime_op_weekly_freq( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - n = op.freq.n - # Calculate microseconds for the weekly interval. - us = n * 7 * 24 * 60 * 60 * 1000000 - first = sge.func( - "UNIX_MICROS", - sge.Add( - this=sge.TimestampTrunc( - this=sge.Cast(this=y.expr, to="TIMESTAMP"), - unit=sge.Var(this="WEEK(MONDAY)"), - ), - expression=sge.Interval( - this=sge.convert(6), unit=sge.Identifier(this="DAY") - ), - ), - ) - return sge.Cast( - this=sge.func( - "TIMESTAMP_MICROS", - sge.Cast( - this=sge.Add( - this=sge.Mul( - this=sge.Cast(this=x.expr, to="BIGNUMERIC"), - expression=sge.convert(us), - ), - expression=sge.Cast(this=first, to="BIGNUMERIC"), - ), - to="INT64", - ), - ), - to=sqlglot_types.from_bigframes_dtype(y.dtype), - ) - - -def _integer_label_to_datetime_op_monthly_freq( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - n = op.freq.n - one = sge.convert(1) - twelve = sge.convert(12) - first = sge.Sub( # type: ignore - this=sge.Add( - this=sge.Mul( - this=sge.Extract(this="YEAR", expression=y.expr), - expression=twelve, - ), - expression=sge.Extract(this="MONTH", expression=y.expr), - ), - expression=one, - ) - x_val = sge.Add( - this=sge.Mul(this=x.expr, expression=sge.convert(n)), expression=first - ) - year = sge.Cast( - this=sge.Floor(this=sge.func("IEEE_DIVIDE", x_val, twelve)), - to="INT64", - ) - month = sge.Add(this=sge.Mod(this=x_val, expression=twelve), expression=one) - - next_year = sge.Case( - ifs=[ - sge.If( - this=sge.EQ(this=month, expression=twelve), - true=sge.Add(this=year, expression=one), - ) - ], - default=year, - ) - next_month = sge.Case( - ifs=[sge.If(this=sge.EQ(this=month, expression=twelve), true=one)], - default=sge.Add(this=month, expression=one), - ) - next_month_date = sge.func( - "TIMESTAMP", - sge.Anonymous( - this="DATETIME", - expressions=[ - next_year, - next_month, - one, - sge.convert(0), - sge.convert(0), - sge.convert(0), - ], - ), - ) - x_label = sge.Sub( # type: ignore - this=next_month_date, expression=sge.Interval(this=one, unit="DAY") - ) - return sge.Cast(this=x_label, to=sqlglot_types.from_bigframes_dtype(y.dtype)) - - -def _integer_label_to_datetime_op_quarterly_freq( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - n = op.freq.n - one = sge.convert(1) - three = sge.convert(3) - four = sge.convert(4) - twelve = sge.convert(12) - first = sge.Sub( # type: ignore - this=sge.Add( - this=sge.Mul( - this=sge.Extract(this="YEAR", expression=y.expr), - expression=four, - ), - expression=sge.Extract(this="QUARTER", expression=y.expr), - ), - expression=one, - ) - x_val = sge.Add( - this=sge.Mul(this=x.expr, expression=sge.convert(n)), expression=first - ) - year = sge.Cast( - this=sge.Floor(this=sge.func("IEEE_DIVIDE", x_val, four)), - to="INT64", - ) - month = sge.Mul( # type: ignore - this=sge.Paren( - this=sge.Add(this=sge.Mod(this=x_val, expression=four), expression=one) - ), - expression=three, - ) - - next_year = sge.Case( - ifs=[ - sge.If( - this=sge.EQ(this=month, expression=twelve), - true=sge.Add(this=year, expression=one), - ) - ], - default=year, - ) - next_month = sge.Case( - ifs=[sge.If(this=sge.EQ(this=month, expression=twelve), true=one)], - default=sge.Add(this=month, expression=one), - ) - next_month_date = sge.Anonymous( - this="DATETIME", - expressions=[ - next_year, - next_month, - one, - sge.convert(0), - sge.convert(0), - sge.convert(0), - ], - ) - x_label = sge.Sub( # type: ignore - this=next_month_date, expression=sge.Interval(this=one, unit="DAY") - ) - return sge.Cast(this=x_label, to=sqlglot_types.from_bigframes_dtype(y.dtype)) - - -def _integer_label_to_datetime_op_yearly_freq( - x: TypedExpr, y: TypedExpr, op: ops.IntegerLabelToDatetimeOp -) -> sge.Expression: - n = op.freq.n - one = sge.convert(1) - first = sge.Extract(this="YEAR", expression=y.expr) - x_val = sge.Add( - this=sge.Mul(this=x.expr, expression=sge.convert(n)), expression=first - ) - next_year = sge.Add(this=x_val, expression=one) # type: ignore - next_month_date = sge.func( - "TIMESTAMP", - sge.Anonymous( - this="DATETIME", - expressions=[ - next_year, - one, - one, - sge.convert(0), - sge.convert(0), - sge.convert(0), - ], - ), - ) - x_label = sge.Sub( # type: ignore - this=next_month_date, expression=sge.Interval(this=one, unit="DAY") - ) - return sge.Cast(this=x_label, to=sqlglot_types.from_bigframes_dtype(y.dtype)) - - -@register_binary_op(ops.timestamp_add_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.TimestampAdd( - this=left.expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - - -@register_binary_op(ops.timestamp_sub_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.TimestampSub( - this=left.expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - - -@register_binary_op(ops.timestamp_diff_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - return sge.TimestampDiff( - this=left.expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - - -@register_binary_op(ops.date_add_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = sge.Cast(this=left.expr, to="TIMESTAMP") - return sge.TimestampAdd( - this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - - -@register_binary_op(ops.date_sub_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - left_expr = sge.Cast(this=left.expr, to="TIMESTAMP") - return sge.TimestampSub( - this=left_expr, expression=right.expr, unit=sge.Var(this="MICROSECOND") - ) - - -@register_binary_op(ops.date_diff_op) -def _(left: TypedExpr, right: TypedExpr) -> sge.Expression: - diff = sge.DateDiff(this=left.expr, expression=right.expr, unit=sge.Var(this="DAY")) - return sge.Mul( - this=diff, - expression=sge.convert(int(UNIT_TO_US_CONVERSION_FACTORS["d"])), - ) diff --git a/bigframes/core/compile/sqlglot/expressions/generic_ops.py b/bigframes/core/compile/sqlglot/expressions/generic_ops.py deleted file mode 100644 index 90c8270ae1d..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/generic_ops.py +++ /dev/null @@ -1,320 +0,0 @@ -# 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.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot import sql, sqlglot_types -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_compiler.register_binary_op -register_nary_op = expression_compiler.expression_compiler.register_nary_op -register_ternary_op = expression_compiler.expression_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.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 = sql.cast(sg_expr, "INT64", op.safe) - return sql.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 = sql.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 sql.cast(sg_expr, sg_to_type, op.safe) - - return sql.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.GoogleSqlScalarOp, pass_op=True) -def _(*operands: TypedExpr, op: ops.GoogleSqlScalarOp) -> sge.Expression: - args: list[sge.Expression] = [] - for i, operand in enumerate(operands): - if i < len(op.args): - arg_spec = op.args[i] - else: - assert op.args[-1].is_vararg, ( - f"Too many arguments, for {op.sql_name}, expected {len(op.args)}" - ) - arg_spec = op.args[-1] - if operand.is_omitted: - assert arg_spec.optional, "Argument omitted, but not optional" - continue - elif arg_spec.arg_name: - args.append(sge.Kwarg(this=arg_spec.arg_name, expression=operand.expr)) - else: - args.append(operand.expr) - return sg.func(op.sql_name, *args) - - -@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=sge.paren(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 - - mappings = [ - ( - sql.literal(key, dtypes.is_compatible(key, expr.dtype)), - sql.literal(value, dtypes.is_compatible(value, expr.dtype)), - ) - for key, value in op.mappings - ] - return sge.Case( - ifs=[ - sge.If( - this=( - sge.EQ(this=expr.expr, expression=key) - if not sql.is_null_literal(key) - else sge.Is(this=expr.expr, expression=sge.Null()) - ), - true=value, - ) - for key, value in mappings - ], - default=expr.expr, - ) - - -@register_unary_op(ops.notnull_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Is( - this=sge.paren(expr.expr, copy=False), - expression=sg.not_(sge.Null(), copy=False), - ) - - -@register_unary_op(ops.coerce_to_bool_op) -def _(expr: TypedExpr) -> sge.Expression: - from_type = expr.dtype - sg_expr = expr.expr - - if from_type == dtypes.BOOL_DTYPE: - res = sg_expr - elif dtypes.is_numeric(from_type): - res = sge.NEQ(this=sg_expr, expression=sge.convert(0)) - elif dtypes.is_string_like(from_type): - res = sge.GT(this=sge.func("LENGTH", sg_expr), expression=sge.convert(0)) - elif dtypes.is_array_like(from_type): - res = sge.GT(this=sge.func("ARRAY_LENGTH", sg_expr), expression=sge.convert(0)) - else: - res = sge.Is( - this=sge.paren(sg_expr, copy=False), - expression=sg.not_(sge.Null(), copy=False), - ) - - return sge.Coalesce(this=res, expressions=[sge.convert(False)]) - - -@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]) - - -def _get_remote_function_name(op): - routine_ref = op.function_def.routine_ref - # Quote project, dataset, and routine IDs to avoid keyword clashes. - return ( - f"`{routine_ref.project}`.`{routine_ref.dataset_id}`.`{routine_ref.routine_id}`" - ) - - -@register_nary_op(ops.RemoteFunctionOp, pass_op=True) -def _(*values: TypedExpr, op: ops.RemoteFunctionOp) -> sge.Expression: - return sge.func(_get_remote_function_name(op), *(value.expr for value in values)) - - -@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_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 = sql.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", - sql.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 sql.cast(sg_expr, "INT64", op.safe) - return None - - -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 deleted file mode 100644 index 8c353988ae3..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/geo_ops.py +++ /dev/null @@ -1,112 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_compiler.register_binary_op - - -@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_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.geo_x_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("ST_X", expr.expr) - - -@register_unary_op(ops.geo_y_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.func("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 deleted file mode 100644 index f9a92d3d7a6..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/json_ops.py +++ /dev/null @@ -1,115 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_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, pass_op=True) -def _(expr: TypedExpr, op: ops.ToJSON) -> sge.Expression: - from_type = expr.dtype - sg_expr = expr.expr - - # Parsing really should be a distinct operation from serialization, but - # this was the way things were intially launched. - if from_type == dtypes.STRING_DTYPE: - func_name = "SAFE.PARSE_JSON" if op.safe else "PARSE_JSON" - return sge.func(func_name, sg_expr) - else: - return sge.func( - "IF", sg_expr.is_(sge.Null()), sge.Null(), sge.func("TO_JSON", sg_expr) - ) - - -@register_unary_op(ops.JSONDecode, pass_op=True) -def _(expr: TypedExpr, op: ops.JSONDecode) -> 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}") - - -@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/nary_compiler.py b/bigframes/core/compile/sqlglot/expressions/nary_compiler.py new file mode 100644 index 00000000000..12f68613d70 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/nary_compiler.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. + +from __future__ import annotations + +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +NARY_OP_REGISTRATION = OpRegistration() + + +def compile(op: ops.NaryOp, *args: TypedExpr) -> sge.Expression: + return NARY_OP_REGISTRATION[op](op, *args) diff --git a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py b/bigframes/core/compile/sqlglot/expressions/numeric_ops.py deleted file mode 100644 index d62a93111be..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/numeric_ops.py +++ /dev/null @@ -1,677 +0,0 @@ -# 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 bigframes_vendored.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -import bigframes.core.compile.sqlglot.expressions.constants as constants -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot import sql -from bigframes.core.compile.sqlglot.expressions.common import round_towards_zero -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr -from bigframes.operations import numeric_ops - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_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=[ - # |x| < 1: The standard formula - sge.If( - this=sge.func("ABS", expr.expr) < sge.convert(1), - true=sge.func("ATANH", expr.expr), - ), - # |x| > 1: Returns NaN - sge.If( - this=sge.func("ABS", expr.expr) > sge.convert(1), - true=constants._NAN, - ), - ], - # |x| = 1: Returns Infinity or -Infinity - default=sge.Mul(this=constants._INF, expression=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.If( - this=expr.expr > constants._FLOAT64_EXP_BOUND, - true=constants._INF, - false=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=sge.Is(this=expr.expr, expression=sge.Null()), - true=sge.null(), - ), - # |x| > 0: The standard formula - sge.If( - this=expr.expr > sge.convert(0), - true=sge.Ln(this=expr.expr), - ), - # |x| < 0: Returns NaN - sge.If( - this=expr.expr < sge.convert(0), - true=constants._NAN, - ), - ], - # |x| == 0: Returns -Infinity - default=constants._NEG_INF, - ) - - -@register_unary_op(ops.log10_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.Is(this=expr.expr, expression=sge.Null()), - true=sge.null(), - ), - # |x| > 0: The standard formula - sge.If( - this=expr.expr > sge.convert(0), - true=sge.Log(this=sge.convert(10), expression=expr.expr), - ), - # |x| < 0: Returns NaN - sge.If( - this=expr.expr < sge.convert(0), - true=constants._NAN, - ), - ], - # |x| == 0: Returns -Infinity - default=constants._NEG_INF, - ) - - -@register_unary_op(ops.log1p_op) -def _(expr: TypedExpr) -> sge.Expression: - return sge.Case( - ifs=[ - sge.If( - this=sge.Is(this=expr.expr, expression=sge.Null()), - true=sge.null(), - ), - # Domain: |x| > -1 (The standard formula) - sge.If( - this=expr.expr > sge.convert(-1), - true=sge.Ln(this=sge.convert(1) + expr.expr), - ), - # Out of Domain: |x| < -1 (Returns NaN) - sge.If( - this=expr.expr < sge.convert(-1), - true=constants._NAN, - ), - ], - # Boundary: |x| == -1 (Returns -Infinity) - default=constants._NEG_INF, - ) - - -@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: - if sql.is_null_literal(left_expr) or sql.is_null_literal(right_expr): - return sge.null() - - 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: - if sql.is_null_literal(left_expr) or sql.is_null_literal(right_expr): - return sge.null() - - # 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=sge.paren(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 sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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 round_towards_zero(result) - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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 = round_towards_zero(sge.func("IEEE_DIVIDE", left_expr, right_expr)) - - 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - # 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: - if sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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 round_towards_zero(result) - 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 sql.is_null_literal(left.expr) or sql.is_null_literal(right.expr): - return sge.null() - - 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), - expression=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/op_registration.py b/bigframes/core/compile/sqlglot/expressions/op_registration.py new file mode 100644 index 00000000000..d5e4853a45e --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/op_registration.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. + +from __future__ import annotations + +import typing + +from sqlglot import expressions as sge + +from bigframes import operations as 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: ops.ScalarOp | type[ops.ScalarOp] + ) -> typing.Callable[[CompilationFunc], CompilationFunc]: + def decorator(item: CompilationFunc): + def arg_checker(*args, **kwargs): + if not isinstance(args[0], ops.ScalarOp): + raise ValueError( + f"The first parameter must be an operator. Got {type(args[0])}" + ) + return item(*args, **kwargs) + + key = typing.cast(str, op.name) + 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 | ops.ScalarOp) -> CompilationFunc: + if isinstance(op, ops.ScalarOp): + return self._registered_ops[op.name] + return self._registered_ops[op] diff --git a/bigframes/core/compile/sqlglot/expressions/string_ops.py b/bigframes/core/compile/sqlglot/expressions/string_ops.py deleted file mode 100644 index 65a13a45f8b..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/string_ops.py +++ /dev/null @@ -1,383 +0,0 @@ -# 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 bigframes_vendored.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_unary_op = expression_compiler.expression_compiler.register_unary_op -register_binary_op = expression_compiler.expression_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(").*")) - n = 1 - else: - pat_expr = sge.func("CONCAT", sge.convert(".*?"), pat_expr, sge.convert(".*")) - n = op.n - - rex_replace = sge.func("REGEXP_REPLACE", expr.expr, pat_expr, sge.convert(f"\\{n}")) - 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.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.Greatest( - expressions=[ - sge.convert(0), - 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 deleted file mode 100644 index 01022210182..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/struct_ops.py +++ /dev/null @@ -1,53 +0,0 @@ -# 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.sqlglot.expressions as sge -import pandas as pd -import pyarrow as pa - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -from bigframes import operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - -register_nary_op = expression_compiler.expression_compiler.register_nary_op -register_unary_op = expression_compiler.expression_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/ternary_compiler.py b/bigframes/core/compile/sqlglot/expressions/ternary_compiler.py new file mode 100644 index 00000000000..9b00771f7d8 --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/ternary_compiler.py @@ -0,0 +1,29 @@ +# 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.op_registration import OpRegistration +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +TERNATRY_OP_REGISTRATION = OpRegistration() + + +def compile( + op: ops.TernaryOp, expr1: TypedExpr, expr2: TypedExpr, expr3: TypedExpr +) -> sge.Expression: + return TERNATRY_OP_REGISTRATION[op](op, expr1, expr2, expr3) diff --git a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py b/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py deleted file mode 100644 index fbc982829ca..00000000000 --- a/bigframes/core/compile/sqlglot/expressions/timedelta_ops.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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.sqlglot.expressions as sge - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -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 - -register_unary_op = expression_compiler.expression_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 index d8c38c2e718..e693dd94a23 100644 --- a/bigframes/core/compile/sqlglot/expressions/typed_expr.py +++ b/bigframes/core/compile/sqlglot/expressions/typed_expr.py @@ -14,7 +14,7 @@ import dataclasses -import bigframes_vendored.sqlglot.expressions as sge +import sqlglot.expressions as sge from bigframes import dtypes @@ -25,6 +25,3 @@ class TypedExpr: expr: sge.Expression dtype: dtypes.ExpressionType - - # kludge to support optional args in argument lists - is_omitted: bool = False diff --git a/bigframes/core/compile/sqlglot/expressions/unary_compiler.py b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py new file mode 100644 index 00000000000..98f1603be7b --- /dev/null +++ b/bigframes/core/compile/sqlglot/expressions/unary_compiler.py @@ -0,0 +1,868 @@ +# 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 pandas as pd +import pyarrow as pa +import sqlglot +import sqlglot.expressions as sge + +from bigframes import operations as ops +from bigframes.core.compile.constants import UNIT_TO_US_CONVERSION_FACTORS +import bigframes.core.compile.sqlglot.expressions.constants as constants +from bigframes.core.compile.sqlglot.expressions.op_registration import OpRegistration +from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr + +UNARY_OP_REGISTRATION = OpRegistration() + + +def compile(op: ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return UNARY_OP_REGISTRATION[op](op, expr) + + +@UNARY_OP_REGISTRATION.register(ops.abs_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Abs(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.arccosh_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.arccos_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.arcsin_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.arcsinh_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ASINH", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.arctan_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ATAN", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.arctanh_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.AsTypeOp) +def _(op: ops.AsTypeOp, expr: TypedExpr) -> sge.Expression: + # TODO: Support more types for casting, such as JSON, etc. + return sge.Cast(this=expr.expr, to=op.to_type) + + +@UNARY_OP_REGISTRATION.register(ops.ArrayToStringOp) +def _(op: ops.ArrayToStringOp, expr: TypedExpr) -> sge.Expression: + return sge.ArrayToString(this=expr.expr, expression=f"'{op.delimiter}'") + + +@UNARY_OP_REGISTRATION.register(ops.ArrayIndexOp) +def _(op: ops.ArrayIndexOp, expr: TypedExpr) -> sge.Expression: + return sge.Bracket( + this=expr.expr, + expressions=[sge.Literal.number(op.index)], + safe=True, + offset=False, + ) + + +@UNARY_OP_REGISTRATION.register(ops.ArraySliceOp) +def _(op: ops.ArraySliceOp, expr: TypedExpr) -> sge.Expression: + slice_idx = sqlglot.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) + + # local name for each element in the array + el = sqlglot.to_identifier("el") + + 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) + + +@UNARY_OP_REGISTRATION.register(ops.capitalize_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Initcap(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ceil_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Ceil(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.cos_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("COS", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.cosh_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrContainsOp) +def _(op: ops.StrContainsOp, expr: TypedExpr) -> sge.Expression: + return sge.Like(this=expr.expr, expression=sge.convert(f"%{op.pat}%")) + + +@UNARY_OP_REGISTRATION.register(ops.StrContainsRegexOp) +def _(op: ops.StrContainsRegexOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(op.pat)) + + +@UNARY_OP_REGISTRATION.register(ops.StrExtractOp) +def _(op: ops.StrExtractOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpExtract( + this=expr.expr, expression=sge.convert(op.pat), group=sge.convert(op.n) + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrFindOp) +def _(op: ops.StrFindOp, expr: TypedExpr) -> 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) + + +@UNARY_OP_REGISTRATION.register(ops.StrLstripOp) +def _(op: ops.StrLstripOp, expr: TypedExpr) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="LEFT") + + +@UNARY_OP_REGISTRATION.register(ops.StrPadOp) +def _(op: ops.StrPadOp, expr: TypedExpr) -> sge.Expression: + pad_length = sge.func( + "GREATEST", sge.Length(this=expr.expr), sge.convert(op.length) + ) + if op.side == "left": + return sge.func( + "LPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + elif op.side == "right": + return sge.func( + "RPAD", + expr.expr, + pad_length, + sge.convert(op.fillchar), + ) + else: # side == both + lpad_amount = sge.Cast( + this=sge.func( + "SAFE_DIVIDE", + sge.Sub(this=pad_length, expression=sge.Length(this=expr.expr)), + sge.convert(2), + ), + to="INT64", + ) + sge.Length(this=expr.expr) + return sge.func( + "RPAD", + sge.func( + "LPAD", + expr.expr, + lpad_amount, + sge.convert(op.fillchar), + ), + pad_length, + sge.convert(op.fillchar), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrRepeatOp) +def _(op: ops.StrRepeatOp, expr: TypedExpr) -> sge.Expression: + return sge.Repeat(this=expr.expr, times=sge.convert(op.repeats)) + + +@UNARY_OP_REGISTRATION.register(ops.date_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Date(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.day_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAY"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.dayofweek_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + # Adjust the 1-based day-of-week index (from SQL) to a 0-based index. + return sge.Extract( + this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr + ) - sge.convert(1) + + +@UNARY_OP_REGISTRATION.register(ops.dayofyear_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAYOFYEAR"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.EndsWithOp) +def _(op: ops.EndsWithOp, expr: TypedExpr) -> 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) + + +@UNARY_OP_REGISTRATION.register(ops.exp_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.expm1_op) +def _(op: ops.base_ops.UnaryOp, 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) + + +@UNARY_OP_REGISTRATION.register(ops.FloorDtOp) +def _(op: ops.FloorDtOp, expr: TypedExpr) -> sge.Expression: + # TODO: Remove this method when it is covered by ops.FloorOp + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this=op.freq)) + + +@UNARY_OP_REGISTRATION.register(ops.floor_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_area_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_AREA", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_astext_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ASTEXT", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_boundary_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_BOUNDARY", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.GeoStBufferOp) +def _(op: ops.GeoStBufferOp, expr: TypedExpr) -> 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_centroid_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CENTROID", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_convexhull_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_CONVEXHULL", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_geogfromtext_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_GEOGFROMTEXT", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_st_isclosed_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_ISCLOSED", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.GeoStLengthOp) +def _(op: ops.GeoStLengthOp, expr: TypedExpr) -> sge.Expression: + return sge.func("ST_LENGTH", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_x_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_X", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.geo_y_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("SAFE.ST_Y", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.hash_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("FARM_FINGERPRINT", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.hour_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="HOUR"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.invert_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.BitwiseNot(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.IsInOp) +def _(op: ops.IsInOp, expr: TypedExpr) -> sge.Expression: + return sge.In(this=expr.expr, expressions=[sge.convert(v) for v in op.values]) + + +@UNARY_OP_REGISTRATION.register(ops.isalnum_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^(\p{N}|\p{L})+$")) + + +@UNARY_OP_REGISTRATION.register(ops.isalpha_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{L}+$")) + + +@UNARY_OP_REGISTRATION.register(ops.isdecimal_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\d+$")) + + +@UNARY_OP_REGISTRATION.register(ops.isdigit_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\p{Nd}+$")) + + +@UNARY_OP_REGISTRATION.register(ops.islower_op) +def _(op: ops.base_ops.UnaryOp, 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, + ), + ) + + +@UNARY_OP_REGISTRATION.register(ops.iso_day_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="DAYOFWEEK"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.iso_week_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOWEEK"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.iso_year_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="ISOYEAR"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.isnull_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Is(this=expr.expr, expression=sge.Null()) + + +@UNARY_OP_REGISTRATION.register(ops.isnumeric_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\pN+$")) + + +@UNARY_OP_REGISTRATION.register(ops.isspace_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.RegexpLike(this=expr.expr, expression=sge.convert(r"^\s+$")) + + +@UNARY_OP_REGISTRATION.register(ops.isupper_op) +def _(op: ops.base_ops.UnaryOp, 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, + ), + ) + + +@UNARY_OP_REGISTRATION.register(ops.len_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Length(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ln_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.log10_op) +def _(op: ops.base_ops.UnaryOp, 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)), + ) + + +@UNARY_OP_REGISTRATION.register(ops.log1p_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.lower_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Lower(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.MapOp) +def _(op: ops.MapOp, expr: TypedExpr) -> sge.Expression: + return sge.Case( + this=expr.expr, + ifs=[ + sge.If(this=sge.convert(key), true=sge.convert(value)) + for key, value in op.mappings + ], + ) + + +@UNARY_OP_REGISTRATION.register(ops.minute_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MINUTE"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.month_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="MONTH"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.neg_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Neg(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.normalize_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.TimestampTrunc(this=expr.expr, unit=sge.Identifier(this="DAY")) + + +@UNARY_OP_REGISTRATION.register(ops.notnull_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Not(this=sge.Is(this=expr.expr, expression=sge.Null())) + + +@UNARY_OP_REGISTRATION.register(ops.obj_fetch_metadata_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.FETCH_METADATA", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ObjGetAccessUrl) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("OBJ.GET_ACCESS_URL", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.pos_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return expr.expr + + +@UNARY_OP_REGISTRATION.register(ops.quarter_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="QUARTER"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ReplaceStrOp) +def _(op: ops.ReplaceStrOp, expr: TypedExpr) -> sge.Expression: + return sge.func("REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl)) + + +@UNARY_OP_REGISTRATION.register(ops.RegexReplaceStrOp) +def _(op: ops.RegexReplaceStrOp, expr: TypedExpr) -> sge.Expression: + return sge.func( + "REGEXP_REPLACE", expr.expr, sge.convert(op.pat), sge.convert(op.repl) + ) + + +@UNARY_OP_REGISTRATION.register(ops.reverse_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("REVERSE", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.second_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="SECOND"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.StrRstripOp) +def _(op: ops.StrRstripOp, expr: TypedExpr) -> sge.Expression: + return sge.Trim(this=expr.expr, expression=sge.convert(op.to_strip), side="RIGHT") + + +@UNARY_OP_REGISTRATION.register(ops.sqrt_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StartsWithOp) +def _(op: ops.StartsWithOp, expr: TypedExpr) -> 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) + + +@UNARY_OP_REGISTRATION.register(ops.StrStripOp) +def _(op: ops.StrStripOp, expr: TypedExpr) -> sge.Expression: + return sge.Trim(this=sge.convert(op.to_strip), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.sin_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("SIN", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.sinh_op) +def _(op: ops.base_ops.UnaryOp, 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), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StringSplitOp) +def _(op: ops.StringSplitOp, expr: TypedExpr) -> sge.Expression: + return sge.Split(this=expr.expr, expression=sge.convert(op.pat)) + + +@UNARY_OP_REGISTRATION.register(ops.StrGetOp) +def _(op: ops.StrGetOp, expr: TypedExpr) -> sge.Expression: + return sge.Substring( + this=expr.expr, + start=sge.convert(op.i + 1), + length=sge.convert(1), + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrSliceOp) +def _(op: ops.StrSliceOp, expr: TypedExpr) -> sge.Expression: + start = op.start + 1 if op.start is not None else None + if op.end is None: + length = None + elif op.start is None: + length = op.end + else: + length = op.end - op.start + return sge.Substring( + this=expr.expr, + start=sge.convert(start) if start is not None else None, + length=sge.convert(length) if length is not None else None, + ) + + +@UNARY_OP_REGISTRATION.register(ops.StrftimeOp) +def _(op: ops.StrftimeOp, expr: TypedExpr) -> sge.Expression: + return sge.func("FORMAT_TIMESTAMP", sge.convert(op.date_format), expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.StructFieldOp) +def _(op: ops.StructFieldOp, expr: TypedExpr) -> 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, + ) + + +@UNARY_OP_REGISTRATION.register(ops.tan_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("TAN", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.tanh_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("TANH", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.time_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.func("TIME", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.timedelta_floor_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Floor(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ToDatetimeOp) +def _(op: ops.ToDatetimeOp, expr: TypedExpr) -> sge.Expression: + return sge.Cast(this=sge.func("TIMESTAMP_SECONDS", expr.expr), to="DATETIME") + + +@UNARY_OP_REGISTRATION.register(ops.ToTimestampOp) +def _(op: ops.ToTimestampOp, expr: TypedExpr) -> sge.Expression: + return sge.func("TIMESTAMP_SECONDS", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ToTimedeltaOp) +def _(op: ops.ToTimedeltaOp, expr: TypedExpr) -> sge.Expression: + value = expr.expr + factor = UNIT_TO_US_CONVERSION_FACTORS[op.unit] + if factor != 1: + value = sge.Mul(this=value, expression=sge.convert(factor)) + return sge.Interval(this=value, unit=sge.Identifier(this="MICROSECOND")) + + +@UNARY_OP_REGISTRATION.register(ops.UnixMicros) +def _(op: ops.UnixMicros, expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MICROS", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.UnixMillis) +def _(op: ops.UnixMillis, expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_MILLIS", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.UnixSeconds) +def _(op: ops.UnixSeconds, expr: TypedExpr) -> sge.Expression: + return sge.func("UNIX_SECONDS", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.JSONExtract) +def _(op: ops.JSONExtract, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_EXTRACT", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONExtractArray) +def _(op: ops.JSONExtractArray, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_EXTRACT_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONExtractStringArray) +def _(op: ops.JSONExtractStringArray, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_EXTRACT_STRING_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONQuery) +def _(op: ops.JSONQuery, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_QUERY", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONQueryArray) +def _(op: ops.JSONQueryArray, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_QUERY_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONValue) +def _(op: ops.JSONValue, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_VALUE", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.JSONValueArray) +def _(op: ops.JSONValueArray, expr: TypedExpr) -> sge.Expression: + return sge.func("JSON_VALUE_ARRAY", expr.expr, sge.convert(op.json_path)) + + +@UNARY_OP_REGISTRATION.register(ops.ParseJSON) +def _(op: ops.ParseJSON, expr: TypedExpr) -> sge.Expression: + return sge.func("PARSE_JSON", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ToJSONString) +def _(op: ops.ToJSONString, expr: TypedExpr) -> sge.Expression: + return sge.func("TO_JSON_STRING", expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.upper_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Upper(this=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.year_op) +def _(op: ops.base_ops.UnaryOp, expr: TypedExpr) -> sge.Expression: + return sge.Extract(this=sge.Identifier(this="YEAR"), expression=expr.expr) + + +@UNARY_OP_REGISTRATION.register(ops.ZfillOp) +def _(op: ops.ZfillOp, expr: TypedExpr) -> sge.Expression: + return sge.Case( + ifs=[ + sge.If( + this=sge.EQ( + this=sge.Substring( + this=expr.expr, start=sge.convert(1), length=sge.convert(1) + ), + expression=sge.convert("-"), + ), + true=sge.Concat( + expressions=[ + sge.convert("-"), + sge.func( + "LPAD", + sge.Substring(this=expr.expr, start=sge.convert(1)), + sge.convert(op.width - 1), + sge.convert("0"), + ), + ] + ), + ) + ], + default=sge.func("LPAD", expr.expr, sge.convert(op.width), sge.convert("0")), + ) diff --git a/bigframes/core/compile/sqlglot/scalar_compiler.py b/bigframes/core/compile/sqlglot/scalar_compiler.py new file mode 100644 index 00000000000..65c2501b713 --- /dev/null +++ b/bigframes/core/compile/sqlglot/scalar_compiler.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 __future__ import annotations + +import functools + +import sqlglot.expressions as sge + +from bigframes.core import expression +from bigframes.core.compile.sqlglot.expressions import ( + binary_compiler, + nary_compiler, + ternary_compiler, + typed_expr, + unary_compiler, +) +import bigframes.core.compile.sqlglot.sqlglot_ir as ir +import bigframes.operations as ops + + +@functools.singledispatch +def compile_scalar_expression( + expr: expression.Expression, +) -> sge.Expression: + """Compiles BigFrames scalar expression into SQLGlot expression.""" + raise ValueError(f"Can't compile unrecognized node: {expression}") + + +@compile_scalar_expression.register +def compile_deref_expression(expr: expression.DerefOp) -> sge.Expression: + return sge.Column(this=sge.to_identifier(expr.id.sql, quoted=True)) + + +@compile_scalar_expression.register +def compile_constant_expression( + expr: expression.ScalarConstantExpression, +) -> sge.Expression: + return ir._literal(expr.value, expr.dtype) + + +@compile_scalar_expression.register +def compile_op_expression(expr: expression.OpExpression) -> sge.Expression: + # Non-recursively compiles the children scalar expressions. + args = tuple( + typed_expr.TypedExpr(compile_scalar_expression(input), input.output_type) + for input in expr.inputs + ) + + op = expr.op + if isinstance(op, ops.UnaryOp): + return unary_compiler.compile(op, args[0]) + elif isinstance(op, ops.BinaryOp): + return binary_compiler.compile(op, args[0], args[1]) + elif isinstance(op, ops.TernaryOp): + return ternary_compiler.compile(op, args[0], args[1], args[2]) + elif isinstance(op, ops.NaryOp): + return nary_compiler.compile(op, *args) + else: + raise TypeError( + f"Operator '{op.name}' has an unrecognized arity or type " + "and cannot be compiled." + ) diff --git a/bigframes/core/compile/sqlglot/sql/__init__.py b/bigframes/core/compile/sqlglot/sql/__init__.py deleted file mode 100644 index 751c3cfc3a5..00000000000 --- a/bigframes/core/compile/sqlglot/sql/__init__.py +++ /dev/null @@ -1,41 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.sql.base import ( - cast, - identifier, - is_null_literal, - literal, - table, - to_sql, -) -from bigframes.core.compile.sqlglot.sql.ddl import create_external_table, load_data -from bigframes.core.compile.sqlglot.sql.dml import insert, replace - -__all__ = [ - # From base.py - "cast", - "identifier", - "is_null_literal", - "literal", - "table", - "to_sql", - # From ddl.py - "create_external_table", - "load_data", - # From dml.py - "insert", - "replace", -] diff --git a/bigframes/core/compile/sqlglot/sql/base.py b/bigframes/core/compile/sqlglot/sql/base.py deleted file mode 100644 index f77dcbee4d9..00000000000 --- a/bigframes/core/compile/sqlglot/sql/base.py +++ /dev/null @@ -1,147 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import numpy as np -import pandas as pd -import pyarrow as pa -from google.cloud import bigquery - -import bigframes.core.compile.sqlglot.sqlglot_types as sgt -from bigframes import dtypes -from bigframes.core import utils -from bigframes.core.compile.sqlglot.expressions import constants - -# 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 - - -QUOTED: bool = True -"""Whether to quote identifiers in the generated SQL.""" - -PRETTY: bool = True -"""Whether to pretty-print the generated SQL.""" - -DIALECT = sg.dialects.bigquery.BigQuery -"""The SQL dialect used for generation.""" - - -def to_sql(expr: sge.Expression) -> str: - """Generate SQL string from the given expression.""" - return expr.sql(dialect=DIALECT, pretty=PRETTY) - - -def identifier(id: str) -> sge.Identifier: - """Return a string representing column reference in a SQL.""" - return sge.to_identifier(id, quoted=QUOTED) - - -def literal(value: typing.Any, dtype: dtypes.Dtype | None = None) -> sge.Expression: - """Return a string representing column reference in a SQL.""" - if dtype is None: - dtype = dtypes.infer_literal_type(value) - - 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: - if str(sqlglot_type).upper() == "NULL": - return sge.Null() - 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 dtype == dtypes.FLOAT_DTYPE: - if pd.isna(value): - if isinstance(value, (float, np.floating)) and np.isnan(value): - return constants._NAN - return cast(sge.Null(), sqlglot_type) - if np.isinf(value): - return constants._INF if value > 0 else constants._NEG_INF - return sge.convert(value) - elif pd.isna(value) or (isinstance(value, pa.Scalar) and not value.is_valid): - 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)) - else: - if isinstance(value, np.generic): - value = value.item() - if isinstance(value, pa.Scalar): - value = value.as_py() - return sge.convert(value) - - -def cast(arg: typing.Any, to: str, safe: bool = False) -> sge.Cast | sge.TryCast: - """Return a SQL expression that casts the given argument to the specified type.""" - if safe: - return sge.TryCast(this=arg, to=to) - else: - return sge.Cast(this=arg, to=to) - - -def table(table: bigquery.TableReference) -> sge.Table: - """Return a SQLGlot Table expression representing the given BigQuery table reference.""" - return sge.Table( - this=sge.to_identifier(table.table_id, quoted=True), - db=sge.to_identifier(table.dataset_id, quoted=True), - catalog=sge.to_identifier(table.project, quoted=True), - ) - - -def is_null_literal(expr: sge.Expression) -> bool: - """Checks if the given expression is a NULL literal.""" - if isinstance(expr, sge.Null): - return True - if isinstance(expr, sge.Cast) and isinstance(expr.this, sge.Null): - return True - return False diff --git a/bigframes/core/compile/sqlglot/sql/ddl.py b/bigframes/core/compile/sqlglot/sql/ddl.py deleted file mode 100644 index 1a63d016d5e..00000000000 --- a/bigframes/core/compile/sqlglot/sql/ddl.py +++ /dev/null @@ -1,220 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Mapping, Optional, Union - -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge - -from bigframes.core.compile.sqlglot.sql import base - - -def load_data( - table_name: str, - *, - write_disposition: str = "INTO", - columns: Optional[Mapping[str, str]] = None, - partition_by: Optional[list[str]] = None, - cluster_by: Optional[list[str]] = None, - table_options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, - from_files_options: Mapping[str, Union[str, int, float, bool, list]], - with_partition_columns: Optional[Mapping[str, str]] = None, - connection_name: Optional[str] = None, -) -> sge.LoadData: - """Generates the LOAD DATA DDL statement.""" - # We use a Table with a simple identifier for the table name. - # Quoting is handled by the dialect. - table_expr = sge.Table(this=base.identifier(table_name)) - - sge_partition_by = ( - sge.PartitionedByProperty( - this=base.identifier(partition_by[0]) - if len(partition_by) == 1 - else sge.Tuple(expressions=[base.identifier(col) for col in partition_by]) - ) - if partition_by - else None - ) - - sge_cluster_by = ( - sge.Cluster(expressions=[base.identifier(col) for col in cluster_by]) - if cluster_by - else None - ) - - sge_from_files = sge.Tuple( - expressions=[ - sge.Property(this=base.identifier(k), value=base.literal(v)) - for k, v in from_files_options.items() - ] - ) - - sge_connection = base.identifier(connection_name) if connection_name else None - - return sge.LoadData( - this=table_expr, - overwrite=(write_disposition == "OVERWRITE"), - inpath=sge.convert("fake"), # satisfy sqlglot's required inpath arg - columns=_get_sge_schema(columns), - partition_by=sge_partition_by, - cluster_by=sge_cluster_by, - options=_get_sge_properties(table_options), - from_files=sge_from_files, - with_partition_columns=_get_sge_schema(with_partition_columns), - connection=sge_connection, - ) - - -def create_external_table( - table_name: str, - *, - replace: bool = False, - if_not_exists: bool = False, - columns: Optional[Mapping[str, str]] = None, - partition_columns: Optional[Mapping[str, str]] = None, - connection_name: Optional[str] = None, - options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, -) -> sge.Create: - """Generates the CREATE EXTERNAL TABLE DDL statement.""" - sge_connection = base.identifier(connection_name) if connection_name else None - - table_expr = sge.Table(this=base.identifier(table_name)) - - # sqlglot.expressions.Create usually takes 'this' (Table or Schema) - sge_schema = _get_sge_schema(columns) - this: sge.Table | sge.Schema - if sge_schema: - sge_schema.set("this", table_expr) - this = sge_schema - else: - this = table_expr - - return sge.Create( - this=this, - kind="EXTERNAL TABLE", - replace=replace, - exists_ok=if_not_exists, - properties=_get_sge_properties(options), - connection=sge_connection, - partition_columns=_get_sge_schema(partition_columns), - ) - - -def _get_sge_schema( - columns: Optional[Mapping[str, str]] = None, -) -> Optional[sge.Schema]: - if not columns: - return None - - return sge.Schema( - this=None, - expressions=[ - sge.ColumnDef( - this=base.identifier(name), - kind=sge.DataType.build(typ, dialect=base.DIALECT), - ) - for name, typ in columns.items() - ], - ) - - -def _get_sge_properties( - options: Optional[Mapping[str, Union[str, int, float, bool, list]]] = None, -) -> Optional[sge.Properties]: - if not options: - return None - - return sge.Properties( - expressions=[ - sge.Property(this=base.identifier(k), value=base.literal(v)) - for k, v in options.items() - ] - ) - - -def _loaddata_sql(self: sg.Generator, expression: sge.LoadData) -> str: - out = ["LOAD DATA"] - if expression.args.get("overwrite"): - out.append("OVERWRITE") - - out.append(f"INTO {self.sql(expression, 'this').strip()}") - - # We ignore inpath as it's just a dummy to satisfy sqlglot requirements - # but BigQuery uses FROM FILES instead. - - columns = self.sql(expression, "columns").strip() - if columns: - out.append(columns) - - partition_by = self.sql(expression, "partition_by").strip() - if partition_by: - out.append(partition_by) - - cluster_by = self.sql(expression, "cluster_by").strip() - if cluster_by: - out.append(cluster_by) - - options = self.sql(expression, "options").strip() - if options: - out.append(options) - - from_files = self.sql(expression, "from_files").strip() - if from_files: - out.append(f"FROM FILES {from_files}") - - with_partition_columns = self.sql(expression, "with_partition_columns").strip() - if with_partition_columns: - out.append(f"WITH PARTITION COLUMNS {with_partition_columns}") - - connection = self.sql(expression, "connection").strip() - if connection: - out.append(f"WITH CONNECTION {connection}") - - return " ".join(out) - - -def _create_sql(self: sg.Generator, expression: sge.Create) -> str: - kind = expression.args.get("kind") - if kind != "EXTERNAL TABLE": - return self.create_sql(expression) - - out = ["CREATE"] - if expression.args.get("replace"): - out.append("OR REPLACE") - out.append("EXTERNAL TABLE") - if expression.args.get("exists_ok"): - out.append("IF NOT EXISTS") - - out.append(self.sql(expression, "this")) - - connection = self.sql(expression, "connection").strip() - if connection: - out.append(f"WITH CONNECTION {connection}") - - partition_columns = self.sql(expression, "partition_columns").strip() - if partition_columns: - out.append(f"WITH PARTITION COLUMNS {partition_columns}") - - properties = self.sql(expression, "properties").strip() - if properties: - out.append(properties) - - return " ".join(out) - - -# Register the transform for BigQuery generator -base.DIALECT.Generator.TRANSFORMS[sge.LoadData] = _loaddata_sql -base.DIALECT.Generator.TRANSFORMS[sge.Create] = _create_sql diff --git a/bigframes/core/compile/sqlglot/sql/dml.py b/bigframes/core/compile/sqlglot/sql/dml.py deleted file mode 100644 index 0f0ae9dff2b..00000000000 --- a/bigframes/core/compile/sqlglot/sql/dml.py +++ /dev/null @@ -1,59 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.sqlglot.expressions as sge -from google.cloud import bigquery - -from bigframes import dtypes -from bigframes.core.compile.sqlglot.sql import base - - -def insert( - query_or_table: typing.Union[sge.Select, sge.Table], - destination: bigquery.TableReference, -) -> sge.Insert: - """Generates an INSERT INTO SQL statement from the given SELECT statement or - table reference.""" - return sge.insert(_as_from_item(query_or_table), base.table(destination)) - - -def replace( - query_or_table: typing.Union[sge.Select, sge.Table], - destination: bigquery.TableReference, -) -> sge.Merge: - """Generates a MERGE statement to replace the contents of the destination table.""" - return sge.Merge( - this=base.table(destination), - using=_as_from_item(query_or_table), - on=base.literal(False, dtypes.BOOL_DTYPE), - whens=sge.Whens( - expressions=[ - sge.When(matched=False, source=True, then=sge.Delete()), - sge.When(matched=False, then=sge.Insert(this=sge.Var(this="ROW"))), - ] - ), - ) - - -def _as_from_item( - query_or_table: typing.Union[sge.Select, sge.Table], -) -> typing.Union[sge.Subquery, sge.Table]: - if isinstance(query_or_table, sge.Select): - return query_or_table.subquery() - else: # table - return query_or_table diff --git a/bigframes/core/compile/sqlglot/sqlglot_ir.py b/bigframes/core/compile/sqlglot/sqlglot_ir.py index b29a23cd84b..1a00cd0a936 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_ir.py +++ b/bigframes/core/compile/sqlglot/sqlglot_ir.py @@ -15,19 +15,20 @@ from __future__ import annotations import dataclasses -import datetime import functools import typing -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge +from google.cloud import bigquery +import numpy as np import pyarrow as pa +import sqlglot as sg +import sqlglot.dialects.bigquery +import sqlglot.expressions as sge -import bigframes.core.compile.sqlglot.sqlglot_types as sgt from bigframes import dtypes -from bigframes.core import guid, local_data, schema -from bigframes.core.compile.sqlglot import sql +from bigframes.core import guid, local_data, schema, utils from bigframes.core.compile.sqlglot.expressions import 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: @@ -38,92 +39,29 @@ to_wkt = dumps -class SelectFragment: - def __init__(self, select_expr: sge.Select): - self.select_expr = select_expr - - def as_select_all(self) -> sge.Select: - return self.select_expr - - def select(self, *items: sge.Expression) -> sge.Select: - return sge.Select().select(*items).from_(self.select_expr.subquery()) - - def as_from_item(self) -> sge.Expression: - return self.select_expr.subquery() - - -class TableFragment: - def __init__(self, table: sge.Table | sge.Unnest): - self.table = table - - def as_select_all(self) -> sge.Select: - return sge.Select().select(sge.Star()).from_(self.table) - - def select(self, *items: sge.Expression) -> sge.Select: - return sge.Select().select(*items).from_(self.table) - - def as_from_item(self) -> sge.Expression: - return self.table - - -class DeferredSelectFragment: - def __init__(self, select_supplier: typing.Callable[[sge.Select], sge.Select]): - self.select_supplier = select_supplier - - def as_select_all(self) -> sge.Select: - return self.select_supplier(sge.Select().select(sge.Star())) - - def select(self, *items: sge.Expression) -> sge.Select: - return self.select_supplier(sge.Select().select(*items)) - - def as_from_item(self) -> sge.Expression: - return self.select_supplier(sge.Select().select(sge.Star())).subquery() - - -ExprT = SelectFragment | TableFragment | DeferredSelectFragment - - @dataclasses.dataclass(frozen=True) class SQLGlotIR: """Helper class to build SQLGlot Query and generate SQL string.""" - expr: ExprT + 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 sql.to_sql(self.expr.as_select_all()) - - @classmethod - def empty( - cls, uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator() - ) -> SQLGlotIR: - return cls(expr=SelectFragment(sge.select()), uid_gen=uid_gen) - - @classmethod - def from_expr( - cls, - expr: sge.Expression, - uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator(), - ) -> SQLGlotIR: - if isinstance(expr, sge.Select): - return cls(expr=SelectFragment(expr), uid_gen=uid_gen) - elif isinstance(expr, (sge.Table, sge.Unnest)): - return cls(expr=TableFragment(expr), uid_gen=uid_gen) - else: - raise ValueError(f"Unsupported expression type: {type(expr)}") - - @classmethod - def from_func( - cls, - select_handler: typing.Callable[[sge.Select], sge.Select], - uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator(), - ): - return cls(expr=DeferredSelectFragment(select_handler), uid_gen=uid_gen) + return self.expr.sql(dialect=self.dialect, pretty=self.pretty) @classmethod def from_pyarrow( @@ -141,7 +79,7 @@ def from_pyarrow( expressions=[ sge.ColumnDef( this=sge.to_identifier(field.column, quoted=True), - kind=sgt.from_bigframes_dtype(field.dtype), + kind=sgt.SQLGlotType.from_bigframes_dtype(field.dtype), ) for field in schema.items ], @@ -150,7 +88,7 @@ def from_pyarrow( data_expr = [ sge.Struct( expressions=tuple( - sql.literal( + _literal( value=value, dtype=field.dtype, ) @@ -169,7 +107,7 @@ def from_pyarrow( ), ], ) - return cls.from_expr(expr=expr, uid_gen=uid_gen) + return cls(expr=sg.select(sge.Star()).from_(expr), uid_gen=uid_gen) @classmethod def from_table( @@ -177,10 +115,9 @@ def from_table( project_id: str, dataset_id: str, table_id: str, - uid_gen: guid.SequentialUIDGenerator | None = None, - columns: typing.Sequence[str] = (), - sql_predicate: typing.Optional[str] = None, - system_time: typing.Optional[datetime.datetime] = None, + col_names: typing.Sequence[str], + alias_names: typing.Sequence[str], + uid_gen: guid.SequentialUIDGenerator, ) -> SQLGlotIR: """Builds a SQLGlotIR expression from a BigQuery table. @@ -188,140 +125,176 @@ def from_table( 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. - columns (typing.Sequence[str]): The names of the columns to select. - sql_predicate (typing.Optional[str]): An optional SQL predicate for filtering. - system_time (typing.Optional[str]): An optional system time for time-travel queries. """ - version = ( - sge.Version( - this=sge.Identifier(this="SYSTEM_TIME", quoted=False), - expression=sge.Literal.string(system_time.isoformat()), - kind="AS OF", - ) - if system_time - else None - ) - if uid_gen is None: - uid_gen = guid.SequentialUIDGenerator() - table_alias = next(uid_gen.get_uid_stream("bft_")) - table_expr = sge.Table( - this=sql.identifier(table_id), - db=sql.identifier(dataset_id), - catalog=sql.identifier(project_id), - version=version, - alias=sql.identifier(table_alias), - ) - - if not columns and not sql_predicate: - return cls.from_expr(expr=table_expr, uid_gen=uid_gen) - - select_items: list[sge.Identifier | sge.Star] = ( - [sql.identifier(col) for col in columns] if columns else [sge.Star()] - ) - select_expr = sge.Select().select(*select_items).from_(table_expr) - - if sql_predicate: - select_expr = select_expr.where( - sg.parse_one(sql_predicate, dialect=sql.base.DIALECT), append=False + selections = [ + sge.Alias( + this=sge.to_identifier(col_name, quoted=cls.quoted), + alias=sge.to_identifier(alias_name, quoted=cls.quoted), ) - - return cls.from_expr(expr=select_expr, uid_gen=uid_gen) - - @classmethod - def from_cte_ref( - cls, - cte_ref: str, - uid_gen: guid.SequentialUIDGenerator, - ) -> SQLGlotIR: + for col_name, alias_name in zip(col_names, alias_names) + ] table_expr = sge.Table( - this=sql.identifier(cte_ref), + 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), ) - return cls.from_expr(expr=table_expr, uid_gen=uid_gen) - - def select( - self, - selections: tuple[tuple[str, sge.Expression], ...] = (), - predicates: tuple[sge.Expression, ...] = (), - sorting: tuple[sge.Ordered, ...] = (), - limit: typing.Optional[int] = None, - ) -> SQLGlotIR: - # TODO: Explicitly insert CTEs into plan - if len(selections) > 0: - to_select = [ - expr - if (isinstance(expr, sge.Alias) and expr.alias == id) - or (isinstance(expr, sge.Column) and expr.name == id) - else sge.Alias( - this=expr.this if isinstance(expr, sge.Alias) else expr, - alias=sql.identifier(id), - ) - for id, expr in selections - ] - new_expr = self.expr.select(*to_select) - else: - new_expr = self.expr.as_select_all() - - if len(sorting) > 0: - new_expr = new_expr.order_by(*sorting) - - if len(predicates) > 0: - condition = _and(predicates) - new_expr = new_expr.where(condition, append=False) - if limit is not None: - new_expr = new_expr.limit(limit) - - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + select_expr = sge.Select().select(*selections).from_(table_expr) + return cls(expr=select_expr, uid_gen=uid_gen) @classmethod - def from_unparsed_query( + def from_query_string( cls, query_string: str, ) -> SQLGlotIR: - """Builds a SQLGlot expression from a query string. Wrapping the query - in a CTE can avoid the query parsing issue for unsupported syntax in - SQLGlot.""" + """Builds a SQLGlot expression from a query string""" uid_gen: guid.SequentialUIDGenerator = guid.SequentialUIDGenerator() - cte_name = sql.identifier(next(uid_gen.get_uid_stream("bfcte_"))) + 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.from_expr(expr=select_expr, uid_gen=uid_gen) + select_expr.set("with", sge.With(expressions=[cte])) + return cls(expr=select_expr, uid_gen=uid_gen) @classmethod def from_union( cls, selects: typing.Sequence[sge.Select], - output_aliases: typing.Sequence[typing.Tuple[str, str]], + 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}." - ) - union_expr: sge.Query = selects[0].subquery() - for select in selects[1:]: - union_expr = sge.Union( - this=union_expr, - expression=select.subquery(), - distinct=False, - copy=False, + 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.Select] = [] + 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() + existing_ctes = [*existing_ctes, *select_expr.args.pop("with", [])] + + 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 = sg.union( + *union_selects, + distinct=False, + copy=False, + ) + final_select_expr = sge.Select().select(sge.Star()).from_(union_expr.subquery()) + final_select_expr.set("with", sge.With(expressions=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=sql.identifier(old_name), - alias=sql.identifier(new_name), + this=expr, + alias=sge.to_identifier(id, quoted=self.quoted), ) - for old_name, new_name in output_aliases + for id, expr in selected_cols ] - final_select_expr = ( - sge.Select().select(*selections).from_(union_expr.subquery()) + + 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 ) - return cls.from_expr(expr=final_select_expr, uid_gen=uid_gen) def join( self, @@ -332,8 +305,19 @@ def join( joins_nulls: bool = True, ) -> SQLGlotIR: """Joins the current query with another SQLGlotIR instance.""" - left_from = self.expr.as_from_item() - right_from = right.expr.as_from_item() + 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_ctes = left_select.args.pop("with", []) + right_ctes = right_select.args.pop("with", []) + merged_ctes = [*left_ctes, *right_ctes] join_on = _and( tuple( @@ -342,68 +326,15 @@ def join( ) join_type_str = join_type if join_type != "outer" else "full outer" - return SQLGlotIR.from_func( - lambda select: select.from_(left_from).join( - right_from, on=join_on, join_type=join_type_str - ), - 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_from = self.expr.as_from_item() - - new_column: sge.Expression - if joins_nulls: - force_float_domain = False - if ( - conditions[0].dtype == dtypes.FLOAT_DTYPE - or conditions[1].dtype == dtypes.FLOAT_DTYPE - ): - force_float_domain = True - left_expr1, left_expr2 = _value_to_non_null_identity( - conditions[0], force_float_domain - ) - right_expr1, right_expr2 = _value_to_non_null_identity( - conditions[1], force_float_domain - ) - - # Use EXISTS for better performance. - # We use COALESCE on both sides in the WHERE clause as requested. - new_column = sge.Exists( - this=sge.Select() - .select(sge.convert(1)) - .from_(right.expr.as_from_item()) - .where( - sge.and_( - sge.EQ(this=left_expr1, expression=right_expr1), - sge.EQ(this=left_expr2, expression=right_expr2), - ) - ) - ) - else: - new_column = sge.func( - "COALESCE", - sge.In( - this=conditions[0].expr, - expressions=[right._as_subquery()], - ), - sql.literal(False, dtypes.BOOL_DTYPE), - ) - - new_column = sge.Alias( - this=new_column, - alias=sql.identifier(indicator_col), + 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("with", sge.With(expressions=merged_ctes)) - new_expr = sge.Select().select(sge.Star(), new_column).from_(left_from) - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) def explode( self, @@ -420,13 +351,22 @@ def explode( 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=sge.func("RAND"), - expression=sql.literal(fraction, dtypes.FLOAT_DTYPE), + this=uuid_col, + expression=_literal(fraction, dtypes.FLOAT_DTYPE), ) - new_expr = self.expr.as_select_all().where(condition, append=False) - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + 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, @@ -444,12 +384,20 @@ def aggregate( aggregations_expr = [ sge.Alias( this=expr, - alias=sql.identifier(id), + alias=sge.to_identifier(id, quoted=self.quoted), ) for id, expr in aggregations ] - new_expr = self.expr.select(*[*by_cols, *aggregations_expr]).group_by(*by_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.group_by(*by_cols).select( + *[*by_cols, *aggregations_expr], append=False + ) condition = _and( tuple( @@ -459,66 +407,59 @@ def aggregate( ) if condition is not None: new_expr = new_expr.where(condition, append=False) - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + return SQLGlotIR(expr=new_expr, uid_gen=self.uid_gen) - def with_ctes( + def window( self, - ctes: tuple[tuple[str, SQLGlotIR], ...], + window_op: sge.Expression, + output_column_id: str, ) -> SQLGlotIR: - sge_ctes = [ - sge.CTE( - this=cte.expr.as_select_all(), - alias=sql.identifier(cte_name), - ) - for cte_name, cte in ctes - ] - select_expr = _set_query_ctes(self.expr.as_select_all(), sge_ctes) - return SQLGlotIR.from_expr(expr=select_expr, uid_gen=self.uid_gen) + return self.project(((output_column_id, window_op),)) - def resample( + def insert( self, - right: SQLGlotIR, - array_col_name: str, - start_expr: sge.Expression, - stop_expr: sge.Expression, - step_expr: sge.Expression, - ) -> SQLGlotIR: - generate_array = sge.func( - "GENERATE_ARRAY", - start_expr, - stop_expr, - step_expr, - ) - - unnested_column_alias = sql.identifier( - next(self.uid_gen.get_uid_stream("bfcol_")) - ) - unnest_expr = sge.Unnest( - expressions=[generate_array], - alias=sge.TableAlias(columns=[unnested_column_alias]), + 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 ) - final_col_id = sql.identifier(array_col_name) - - # Build final expression by joining everything directly in a single SELECT - new_expr = ( - sge.Select() - .select(unnested_column_alias.as_(final_col_id)) - .from_(self.expr.as_from_item()) - .join(right.expr.as_from_item(), join_type="cross") - .join(unnest_expr, join_type="cross") + 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 ) - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + 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 = sql.identifier(offsets_col) if offsets_col else None - column = sql.identifier(column_name) - unnested_column_alias = sql.identifier( - next(self.uid_gen.get_uid_stream("bfcol_")) + 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], @@ -527,9 +468,18 @@ def _explode_single_column( ) selection = sge.Star(replace=[unnested_column_alias.as_(column)]) - # Use LEFT JOIN to preserve rows when unnesting empty arrays. - new_expr = self.expr.select(selection).join(unnest_expr, join_type="LEFT") - return SQLGlotIR.from_expr(expr=new_expr, uid_gen=self.uid_gen) + # 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, @@ -537,19 +487,27 @@ def _explode_multiple_columns( offsets_col: typing.Optional[str], ) -> SQLGlotIR: """Helper method to handle the case of exploding multiple columns.""" - offset = sql.identifier(offsets_col) if offsets_col else None - columns = [sql.identifier(column_name) for column_name in column_names] + 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", column) - 1 for column in columns] + 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 = sql.identifier( - next(self.uid_gen.get_uid_stream("bfcol_")) + unnested_offset_alias = sge.to_identifier( + next(self.uid_gen.get_uid_stream("bfcol_")), quoted=self.quoted ) unnest_expr = sge.Unnest( expressions=[generate_array], @@ -567,13 +525,83 @@ def _explode_multiple_columns( for column in columns ] ) - # Use LEFT JOIN to preserve rows when unnesting empty arrays. - new_expr = self.expr.select(selection).join(unnest_expr, join_type="LEFT") - return SQLGlotIR.from_expr(expr=new_expr, 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 + ), + ) + 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() + existing_ctes = select_expr.args.pop("with", []) + new_cte = sge.CTE( + this=select_expr, + alias=cte_name, + ) + new_with_clause = sge.With(expressions=[*existing_ctes, new_cte]) + new_select_expr = sge.Select().select(sge.Star()).from_(sge.Table(this=cte_name)) + new_select_expr.set("with", new_with_clause) + return new_select_expr + + +def _literal(value: typing.Any, dtype: dtypes.Dtype) -> sge.Expression: + sqlglot_type = sgt.SQLGlotType.from_bigframes_dtype(dtype) + if value is None: + return _cast(sge.Null(), sqlglot_type) + elif dtype == dtypes.BYTES_DTYPE: + return _cast(str(value), sqlglot_type) + elif dtypes.is_time_like(dtype): + 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.JSON_DTYPE: + return sge.ParseJSON(this=sge.convert(str(value))) + elif dtype == dtypes.TIMEDELTA_DTYPE: + return sge.convert(utils.timedelta_to_micros(value)) + elif 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) + else: + if isinstance(value, np.generic): + value = value.item() + return sge.convert(value) - def _as_subquery(self) -> sge.Subquery: - # Sometimes explicitly need a subquery, e.g. for IN expressions. - return self.expr.as_select_all().subquery() + +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]: @@ -606,71 +634,74 @@ def _join_condition( joins_nulls: If True, generates complex logic to handle nulls/NaNs. Otherwise, uses a simple equality check where appropriate. """ - if not joins_nulls: + 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) - force_float_domain = False - if left.dtype == dtypes.FLOAT_DTYPE or right.dtype == dtypes.FLOAT_DTYPE: - force_float_domain = True - left_expr1, left_expr2 = _value_to_non_null_identity(left, force_float_domain) - right_expr1, right_expr2 = _value_to_non_null_identity(right, force_float_domain) + 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_expr1, expression=right_expr1), - expression=sge.EQ(this=left_expr2, expression=right_expr2), + this=sge.EQ(this=left_0, expression=right_0), + expression=sge.EQ(this=left_1, expression=right_1), ) -def _value_to_non_null_identity( - value: typed_expr.TypedExpr, force_float_domain: bool = False -) -> tuple[sge.Expression, sge.Expression]: - # normal_value -> (normal_value, normal_value) - # null_value -> (0, 1) - # nan_value -> (2, 3) - if dtypes.is_numeric(value.dtype, include_bool=False): - dtype = dtypes.FLOAT_DTYPE if force_float_domain else value.dtype - expr1 = sge.func( - "COALESCE", value.expr, sql.literal(0.0 if force_float_domain else 0, dtype) - ) - expr2 = sge.func( - "COALESCE", value.expr, sql.literal(1.0 if force_float_domain else 1, dtype) - ) - if value.dtype == dtypes.FLOAT_DTYPE: - expr1 = sge.If( - this=sge.IsNan(this=value.expr), - true=sql.literal(2.0, value.dtype), - false=expr1, - ) - expr2 = sge.If( - this=sge.IsNan(this=value.expr), - true=sql.literal(3, value.dtype), - false=expr2, - ) - else: # general case, convert to string and coalesce - expr1 = sge.func( - "COALESCE", - sql.cast(value.expr, "STRING"), - sql.literal("0", dtypes.STRING_DTYPE), - ) - expr2 = sge.func( - "COALESCE", - sql.cast(value.expr, "STRING"), - sql.literal("1", dtypes.STRING_DTYPE), - ) - return expr1, expr2 - - -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 _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), + ) diff --git a/bigframes/core/compile/sqlglot/sqlglot_types.py b/bigframes/core/compile/sqlglot/sqlglot_types.py index d22373b303f..5b0f70077d9 100644 --- a/bigframes/core/compile/sqlglot/sqlglot_types.py +++ b/bigframes/core/compile/sqlglot/sqlglot_types.py @@ -17,65 +17,70 @@ import typing import bigframes_vendored.constants as constants -import bigframes_vendored.sqlglot as sg 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) +class SQLGlotType: + @classmethod + def from_bigframes_dtype( + cls, + 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 ) - inner_fields.append(f"{key} {dtype}") - return "STRUCT<{}>".format(", ".join(inner_fields)) + return ( + f"ARRAY<{SQLGlotType.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 = SQLGlotType.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}" - ) + raise ValueError( + f"Unsupported type for {bigframes_dtype}. {constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/core/eval.py b/bigframes/core/eval.py index aba0f836b7a..82add992589 100644 --- a/bigframes/core/eval.py +++ b/bigframes/core/eval.py @@ -53,10 +53,7 @@ def eval(df: dataframe.DataFrame, expr: str, target: Optional[dataframe.DataFram } # 3 Levels: user -> logging wrapper -> dataframe -> eval helper (this) return vendored_pandas_eval.eval( - expr=expr, - level=3, - target=target, - resolvers=(index_resolver, column_resolver), # type: ignore + expr=expr, level=3, target=target, resolvers=(index_resolver, column_resolver) # type: ignore ) diff --git a/bigframes/core/events.py b/bigframes/core/events.py deleted file mode 100644 index d6cef860f6d..00000000000 --- a/bigframes/core/events.py +++ /dev/null @@ -1,299 +0,0 @@ -# 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 asyncio -import concurrent.futures -import dataclasses -import datetime -import threading -import uuid -from typing import Any, Callable, Literal, Optional, Set - -import google.cloud.bigquery._job_helpers -import google.cloud.bigquery.job.query -import google.cloud.bigquery.table - -import bigframes.session.executor - -_DEFAULT: Literal["default"] = "default" - -ProgressBarType = Literal["default", "auto", "notebook", "terminal"] | None -QueryPlanType = list[google.cloud.bigquery.job.query.QueryPlanEntry] | None - - -class Subscriber: - def __init__( - self, - callback: Callable[[EventEnvelope], 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( - EventEnvelope( - 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() - self._executor: concurrent.futures.Executor = ( - concurrent.futures.ThreadPoolExecutor() - ) - - def subscribe( - self, - callback: Callable[[EventEnvelope], 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, envelope: EventEnvelope | Event): - if not isinstance(envelope, EventEnvelope): - envelope = EventEnvelope(event=envelope) - with self._subscribers_lock: - for subscriber in self._subscribers: - subscriber(envelope) - - async def publish_async(self, envelope: EventEnvelope | Event): - if not isinstance(envelope, EventEnvelope): - envelope = EventEnvelope(event=envelope) - with self._subscribers_lock: - subscribers_snapshot = list(self._subscribers) - loop = asyncio.get_running_loop() - tasks = [ - loop.run_in_executor(self._executor, subscriber, envelope) - for subscriber in subscribers_snapshot - ] - return await asyncio.gather(*tasks, return_exceptions=True) - - -class Event: - pass - - -@dataclasses.dataclass(frozen=True) -class EventEnvelope: - """An envelope that wraps an execution event with metadata and display options. - - Attributes: - event: - The actual execution event details (e.g., ExecutionStarted, BigQuerySentEvent). - progress_bar: - Specifies the style of progress bar to display during execution. - cell_execution_count: - The 1-indexed IPython/Jupyter notebook cell execution number (e.g. the 'x' in 'In [x]'). - This is NOT a job count, but rather the sequential number of the cell execution in the - current notebook session, used to group and filter execution history on a per-cell basis. - """ - - event: Event - progress_bar: ProgressBarType = _DEFAULT - cell_execution_count: Optional[int] = None - - -@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: bigframes.session.executor.ExecuteResult | None = 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: str | None = None - location: str | None = None - job_id: str | None = None - request_id: str | None = 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: str | None = None - location: str | None = None - job_id: str | None = None - request_id: str | None = 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: str | None = None - location: str | None = None - job_id: str | None = None - statement_type: str | None = None - state: str | None = None - query_plan: QueryPlanType = None - created: datetime.datetime | None = None - started: datetime.datetime | None = None - ended: datetime.datetime | None = 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: str | None = None - location: str | None = None - query_id: str | None = None - job_id: str | None = None - destination: google.cloud.bigquery.table.TableReference | None = None - total_rows: int | None = None - total_bytes_processed: int | None = None - slot_millis: int | None = None - created: datetime.datetime | None = None - started: datetime.datetime | None = None - ended: datetime.datetime | None = 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/explode.py b/bigframes/core/explode.py index ddd290b0f84..142536a931c 100644 --- a/bigframes/core/explode.py +++ b/bigframes/core/explode.py @@ -14,7 +14,7 @@ """Utility functions for implementing 'explode' functions.""" -from typing import Sequence, Union, cast +from typing import cast, Sequence, Union import bigframes.core.blocks as blocks import bigframes.core.utils as utils diff --git a/bigframes/core/expression.py b/bigframes/core/expression.py index 6c27dfc120b..0e94193bd32 100644 --- a/bigframes/core/expression.py +++ b/bigframes/core/expression.py @@ -19,17 +19,15 @@ import functools import itertools import typing -from typing import Callable, Generator, Hashable, Mapping, TypeVar, Union +from typing import Callable, Generator, Mapping, TypeVar, Union import pandas as pd -import bigframes.core.identifiers as ids -import bigframes.operations from bigframes import dtypes from bigframes.core import field - -if typing.TYPE_CHECKING: - import bigframes.operations +import bigframes.core.identifiers as ids +import bigframes.operations +import bigframes.operations.aggregations as agg_ops def const( @@ -42,11 +40,122 @@ def deref(name: str) -> DerefOp: return DerefOp(ids.ColumnId(name)) -def free_var(id: Hashable) -> UnboundVariableExpression: +def free_var(id: str) -> UnboundVariableExpression: return UnboundVariableExpression(id) -T = TypeVar("T") +@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_fields: Mapping[ids.ColumnId, field.Field] + ) -> 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_fields: Mapping[ids.ColumnId, field.Field] + ) -> 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 + arg: Union[DerefOp, ScalarConstantExpression] + + def output_type( + self, input_fields: Mapping[ids.ColumnId, field.Field] + ) -> dtypes.ExpressionType: + # TODO(b/419300717) Remove resolutions once defers are cleaned up. + resolved_expr = bind_schema_fields(self.arg, input_fields) + assert resolved_expr.is_resolved + + return self.op.output_type(resolved_expr.output_type) + + @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_fields: Mapping[ids.ColumnId, field.Field] + ) -> dtypes.ExpressionType: + # TODO(b/419300717) Remove resolutions once defers are cleaned up. + left_resolved_expr = bind_schema_fields(self.left, input_fields) + assert left_resolved_expr.is_resolved + right_resolved_expr = bind_schema_fields(self.right, input_fields) + assert right_resolved_expr.is_resolved + + return self.op.output_type( + left_resolved_expr.output_type, left_resolved_expr.output_type + ) + + @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 + ), + ) + + TExpression = TypeVar("TExpression", bound="Expression") @@ -55,7 +164,7 @@ class Expression(abc.ABC): """An expression represents a computation taking N scalar inputs and producing a single output scalar.""" @property - def free_variables(self) -> typing.Tuple[Hashable, ...]: + def free_variables(self) -> typing.Tuple[str, ...]: return () @property @@ -74,7 +183,8 @@ def nullable(self) -> bool: @property @abc.abstractmethod - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: ... + def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: + ... def remap_column_refs( self: TExpression, @@ -88,7 +198,8 @@ def remap_column_refs( @property @abc.abstractmethod - def is_const(self) -> bool: ... + def is_const(self) -> bool: + ... @property @abc.abstractmethod @@ -100,7 +211,8 @@ def is_resolved(self) -> bool: @property @abc.abstractmethod - def output_type(self) -> dtypes.ExpressionType: ... + def output_type(self) -> dtypes.ExpressionType: + ... @abc.abstractmethod def bind_refs( @@ -116,9 +228,7 @@ def bind_refs( @abc.abstractmethod def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, + self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False ) -> Expression: """Replace variables with expression given in `bindings`. @@ -139,26 +249,15 @@ 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 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: @@ -194,9 +293,7 @@ def output_type(self) -> dtypes.ExpressionType: return self.dtype def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, + self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False ) -> Expression: return self @@ -231,10 +328,10 @@ def transform_children(self, t: Callable[[Expression], Expression]) -> Expressio class UnboundVariableExpression(Expression): """A variable expression representing an unbound variable.""" - id: Hashable + id: str @property - def free_variables(self) -> typing.Tuple[Hashable, ...]: + def free_variables(self) -> typing.Tuple[str, ...]: return (self.id,) @property @@ -261,9 +358,7 @@ def bind_refs( return self def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, + self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False ) -> Expression: if self.id in bindings.keys(): return bindings[self.id] @@ -311,9 +406,7 @@ def output_type(self) -> dtypes.ExpressionType: raise ValueError(f"Type of variable {self.id} has not been fixed.") def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, + self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False ) -> Expression: return self @@ -364,56 +457,6 @@ def output_type(self) -> dtypes.ExpressionType: return self.dtype -@dataclasses.dataclass(frozen=True) -class OmittedArg(Expression): - """Represents an omitted optional arg used calling a function.""" - - @property - def free_variables(self) -> typing.Tuple[Hashable, ...]: - return () - - @property - def is_const(self) -> bool: - return True - - @property - def column_references(self) -> typing.Tuple[ids.ColumnId, ...]: - return () - - @property - def is_resolved(self): - return True # vacuously - - @property - def output_type(self) -> dtypes.ExpressionType: - return None - - def bind_refs( - self, - bindings: Mapping[ids.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> OmittedArg: - return self - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> Expression: - return self - - @property - def is_bijective(self) -> bool: - return True - - @property - def is_identity(self) -> bool: - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - return self - - @dataclasses.dataclass(frozen=True) class OpExpression(Expression): """An expression representing a scalar operation applied to 1 or more argument sub-expressions.""" @@ -432,7 +475,7 @@ def column_references( ) @property - def free_variables(self) -> typing.Tuple[Hashable, ...]: + def free_variables(self) -> typing.Tuple[str, ...]: return tuple( itertools.chain.from_iterable(map(lambda x: x.free_variables, self.inputs)) ) @@ -467,9 +510,7 @@ def output_type(self) -> dtypes.ExpressionType: return self.op.output_type(*input_types) def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, + self, bindings: Mapping[str, Expression], allow_partial_bindings: bool = False ) -> OpExpression: return OpExpression( self.op, diff --git a/bigframes/core/expression_factoring.py b/bigframes/core/expression_factoring.py deleted file mode 100644 index 22f1433c8a4..00000000000 --- a/bigframes/core/expression_factoring.py +++ /dev/null @@ -1,467 +0,0 @@ -# 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, - Dict, - Generator, - Hashable, - Iterable, - Iterator, - Mapping, - Optional, - Sequence, - Tuple, - TypeVar, - cast, -) - -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, - cast( - Mapping[expression.Expression, expression.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/global_session.py b/bigframes/core/global_session.py index a38280e6447..4698e4c4c52 100644 --- a/bigframes/core/global_session.py +++ b/bigframes/core/global_session.py @@ -14,19 +14,16 @@ """Utilities for managing a default, globally available Session object.""" -from __future__ import annotations - import threading import traceback +from typing import Callable, Optional, TypeVar import warnings -from typing import TYPE_CHECKING, Callable, Iterable, Optional, TypeVar import google.auth.exceptions +import bigframes._config import bigframes.exceptions as bfe - -if TYPE_CHECKING: - import bigframes.session +import bigframes.session _global_session: Optional[bigframes.session.Session] = None _global_session_lock = threading.Lock() @@ -59,9 +56,6 @@ 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: @@ -94,10 +88,6 @@ 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: @@ -124,22 +114,6 @@ def with_default_session(func_: Callable[..., _T], *args, **kwargs) -> _T: return func_(get_global_session(), *args, **kwargs) -def execution_history( - *, - events: Optional[Iterable[bigframes.core.events.Event]] = None, - job_ids: Optional[Iterable[str]] = None, - all_cells: bool = True, -) -> "bigframes.session._ExecutionHistory": - import bigframes.session - - return with_default_session( - bigframes.session.Session.execution_history, - events=events, - job_ids=job_ids, - all_cells=all_cells, - ) - - class _GlobalSessionContext: """ Context manager for testing that sets global session. diff --git a/bigframes/core/googlesql.py b/bigframes/core/googlesql.py deleted file mode 100644 index 8869fbff3ef..00000000000 --- a/bigframes/core/googlesql.py +++ /dev/null @@ -1,116 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Utilities for working with GoogleSqlScalarOps.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Optional, Union - -import pandas as pd - -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.core.global_session as global_session -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes.operations import googlesql - -if TYPE_CHECKING: - import bigframes.session - - -def _is_pandas_series(arg: Any) -> bool: - return isinstance(arg, pd.Series) - - -def _find_session(*args: Any) -> Optional[bigframes.session.Session]: - import bigframes.core.indexes as indexes - import bigframes.dataframe as dataframe - - for arg in args: - if isinstance(arg, (series.Series, dataframe.DataFrame, indexes.Index)): - return arg._session - return None - - -def _get_session(*args: Any) -> bigframes.session.Session: - session = _find_session(*args) - if session is not None: - return session - return global_session.get_global_session() - - -def apply_googlesql_scalar_op( - op: googlesql.GoogleSqlScalarOp, - *args: Any, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Applies a GoogleSQL scalar operator to the given arguments. - - Handles a mix of Series, Expression, and literal inputs. - - Args: - op (googlesql.GoogleSqlScalarOp): - The operator to apply. - *args (Any): - The arguments to apply the operator to. - - Returns: - bigframes.pandas.Series | bigframes.core.col.Expression: - The result of the operation. If any of ``args`` is a Series, returns - a Series. Otherwise, returns an Expression. - """ - has_pandas_series = any(_is_pandas_series(arg) for arg in args) - - if has_pandas_series: - session = _get_session(*args) - args = tuple( - session.read_pandas(arg) if _is_pandas_series(arg) else arg for arg in args - ) - - # Find the first Series to use for alignment - first_series = None - for arg in args: - if isinstance(arg, series.Series): - first_series = arg - break - - if first_series is not None: - processed_args: list[Union[bigframes.core.col.Expression, series.Series]] = [] - block = first_series._block - for arg in args: - if isinstance(arg, bigframes.core.col.Expression): - block, col_id = block.project_expr(bigframes.core.col._as_bf_expr(arg)) - processed_args.append(series.Series(block.select_column(col_id))) - elif arg is sentinels.Sentinel.ARGUMENT_DEFAULT: - processed_args.append(bigframes.core.col.Expression(ex.OmittedArg())) - else: - processed_args.append(arg) - - # Apply the n-ary op. _apply_nary_op handles alignment of Series and literals. - result = first_series._apply_nary_op(op, processed_args, ignore_self=True) - result.name = None - return result - - # No Series, return an Expression - expr_args = [] - for arg in args: - if isinstance(arg, bigframes.core.col.Expression): - expr_args.append(bigframes.core.col._as_bf_expr(arg)) - elif arg is sentinels.Sentinel.ARGUMENT_DEFAULT: - expr_args.append(ex.OmittedArg()) - else: - expr_args.append(ex.const(arg)) - - return bigframes.core.col.Expression(ex.OpExpression(op, tuple(expr_args))) diff --git a/bigframes/core/graphs.py b/bigframes/core/graphs.py deleted file mode 100644 index b7ce80e3cf0..00000000000 --- a/bigframes/core/graphs.py +++ /dev/null @@ -1,76 +0,0 @@ -# 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/aggs.py b/bigframes/core/groupby/aggs.py index 9d8b957d547..26257cc9b63 100644 --- a/bigframes/core/groupby/aggs.py +++ b/bigframes/core/groupby/aggs.py @@ -14,13 +14,13 @@ from __future__ import annotations -from bigframes.core import agg_expressions, expression +from bigframes.core import expression from bigframes.operations import aggregations as agg_ops -def agg(input: str, op: agg_ops.AggregateOp) -> agg_expressions.Aggregation: +def agg(input: str, op: agg_ops.AggregateOp) -> expression.Aggregation: if isinstance(op, agg_ops.UnaryAggregateOp): - return agg_expressions.UnaryAggregation(op, expression.deref(input)) + return expression.UnaryAggregation(op, expression.deref(input)) else: assert isinstance(op, agg_ops.NullaryAggregateOp) - return agg_expressions.NullaryAggregation(op) + return expression.NullaryAggregation(op) diff --git a/bigframes/core/groupby/dataframe_group_by.py b/bigframes/core/groupby/dataframe_group_by.py index 7cc61d43a02..e4e4b313f9f 100644 --- a/bigframes/core/groupby/dataframe_group_by.py +++ b/bigframes/core/groupby/dataframe_group_by.py @@ -16,38 +16,35 @@ import datetime import typing -from typing import Iterable, Literal, Optional, Sequence, Tuple, Union +from typing import 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 expression as ex +from bigframes.core import log_adapter import bigframes.core.block_transforms as block_ops -import bigframes.core.block_transforms as block_transforms import bigframes.core.blocks as blocks +from bigframes.core.groupby import aggs, 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 -from bigframes import session -from bigframes._tools import docs -from bigframes.core import agg_expressions -from bigframes.core import expression as ex -from bigframes.core.groupby import aggs, group_by, series_group_by -from bigframes.core.logging import log_adapter -from bigframes.core.window import rolling @log_adapter.class_logger -@docs.inherit_docs(vendored_pandas_groupby.DataFrameGroupBy) -class DataFrameGroupBy: +class DataFrameGroupBy(vendored_pandas_groupby.DataFrameGroupBy): + __doc__ = vendored_pandas_groupby.GroupBy.__doc__ + def __init__( self, block: blocks.Block, @@ -56,7 +53,6 @@ def __init__( 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 @@ -67,9 +63,6 @@ def __init__( ) } 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 @@ -155,36 +148,8 @@ def head(self, n: int = 5) -> df.DataFrame: ) ) - 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()], + agg_block, _ = self._block.aggregate_size( by_column_ids=self._by_col_ids, dropna=self._dropna, ) @@ -215,11 +180,7 @@ def median(self, numeric_only: bool = False, *, exact: bool = True) -> df.DataFr 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, + self, method="average", ascending: bool = True, na_option: str = "keep" ) -> df.DataFrame: return df.DataFrame( block_ops.rank( @@ -229,7 +190,6 @@ def rank( ascending, grouping_cols=tuple(self._by_col_ids), columns=tuple(self._selected_cols), - pct=pct, ) ) @@ -281,76 +241,6 @@ def var( 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, *, @@ -385,9 +275,9 @@ def first(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame agg_ops.FirstNonNullOp(), window_spec=window_spec, ) - block = block.aggregate( - by_column_ids=self._by_col_ids, - aggregations=tuple( + block, _ = block.aggregate( + self._by_col_ids, + tuple( aggs.agg(firsts_id, agg_ops.AnyValueOp()) for firsts_id in firsts_ids ), dropna=self._dropna, @@ -407,11 +297,9 @@ def last(self, numeric_only: bool = False, min_count: int = -1) -> df.DataFrame: 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 - ), + block, _ = block.aggregate( + self._by_col_ids, + tuple(aggs.agg(lasts_id, agg_ops.AnyValueOp()) for lasts_id in lasts_ids), dropna=self._dropna, column_labels=index, ) @@ -438,12 +326,12 @@ def cumcount(self, ascending: bool = True) -> series.Series: grouping_keys=tuple(self._by_col_ids) ) ) - block, result_ids = self._block.apply_analytic( - [agg_expressions.NullaryAggregation(agg_ops.size_op)], + block, result_id = self._block.apply_analytic( + ex.NullaryAggregation(agg_ops.size_op), window=window_spec, - result_labels=[None], + result_label=None, ) - result = series.Series(block.select_columns(result_ids)) - 1 + result = series.Series(block.select_column(result_id)) - 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() @@ -572,53 +460,25 @@ def expanding(self, min_periods: int = 1) -> windows.Window: def agg(self, func=None, **kwargs) -> typing.Union[df.DataFrame, series.Series]: if func: - if utils.is_dict_like(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: - return self.size() if func == "size" else self._agg_func(func) + raise NotImplementedError( + f"Aggregate with {func} not supported. {constants.FEEDBACK_LINK}" + ) else: return self._agg_named(**kwargs) - def transform(self, func, *args, **kwargs) -> df.DataFrame: - if block_transforms.is_transpiler_eligible(func): - window_spec = window_specs.unbound(grouping_keys=tuple(self._by_col_ids)) - target_cols, labels = self._aggregated_columns() - exprs = [] - for col_id in target_cols: - expr, _ = block_transforms.compile_column_udf( - self._block, - func, - col_id, - args=args, - kwargs=kwargs, - window_spec=window_spec, - ) - exprs.append(expr) - - block = self._block.project_block_exprs( - exprs, - labels=labels, - drop=True, - ) - return df.DataFrame(block) - - raise NotImplementedError( - "DataFrameGroupBy.transform is only supported when experiments.enable_python_transpiler is True and a transpiler-compatible python function is provided." - ) - - def _agg_func(self, func) -> df.DataFrame: + def _agg_string(self, func: str) -> df.DataFrame: ids, labels = self._aggregated_columns() - aggregations = [] - for col_id in ids: - if block_transforms.is_transpiler_eligible(func): - expr, _ = block_transforms.compile_column_udf(self._block, func, col_id) - aggregations.append(expr) - else: - aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(func)[0])) - - agg_block = self._block.aggregate( + aggregations = [ + aggs.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, @@ -628,9 +488,8 @@ def _agg_func(self, func) -> df.DataFrame: return dataframe if self._as_index else self._convert_index(dataframe) def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: - aggregations: typing.List[ex.Expression] = [] + aggregations: typing.List[ex.Aggregation] = [] column_labels = [] - function_labels = [] want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) @@ -640,19 +499,9 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: funcs_for_id if utils.is_list_like(funcs_for_id) else [funcs_for_id] ) for f in func_list: - if block_transforms.is_transpiler_eligible(f): - expr, name = block_transforms.compile_column_udf( - self._block, f, col_id - ) - aggregations.append(expr) - column_labels.append(label) - function_labels.append(name) - else: - 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( + aggregations.append(aggs.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, @@ -661,7 +510,10 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: agg_block = agg_block.with_column_labels( utils.combine_indices( pd.Index(column_labels), - pd.Index(function_labels), + pd.Index( + typing.cast(agg_ops.AggregateOp, agg.op).name + for agg in aggregations + ), ) ) else: @@ -671,37 +523,22 @@ def _agg_dict(self, func: typing.Mapping) -> df.DataFrame: def _agg_list(self, func: typing.Sequence) -> df.DataFrame: ids, labels = self._aggregated_columns() - aggregations = [] - fn_labels = [] - - for f in func: - if block_transforms.is_transpiler_eligible(f): - fn_labels.append(getattr(f, "__name__", "")) - else: - fn_labels.append(agg_ops.lookup_agg_func(f)[1]) - - for col_id in ids: - for f in func: - if block_transforms.is_transpiler_eligible(f): - expr, _ = block_transforms.compile_column_udf( - self._block, f, col_id - ) - aggregations.append(expr) - else: - aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(f)[0])) + aggregations = [ + aggs.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) + (fn_lbl,) + tuple(label) + (f,) for label in labels.to_frame(index=False).to_numpy() - for fn_lbl in fn_labels + for f in func ] else: # Single-level index - column_labels = [ - (label, fn_lbl) for label in labels for fn_lbl in fn_labels - ] + column_labels = [(label, f) for label in labels for f in func] - agg_block = self._block.aggregate( + agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -725,9 +562,9 @@ def _agg_named(self, **kwargs) -> df.DataFrame: 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])) + aggregations.append(aggs.agg(col_id, agg_ops.lookup_agg_func(v[1]))) column_labels.append(k) - agg_block = self._block.aggregate( + agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, @@ -784,7 +621,7 @@ def _aggregate_all( ) -> 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( + result_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, column_labels=labels, @@ -803,26 +640,14 @@ def _apply_window_op( window_spec = window or window_specs.cumulative_rows( grouping_keys=tuple(self._by_col_ids) ) - columns, labels = self._aggregated_columns(numeric_only=numeric_only) + 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.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) + result = df.DataFrame(block.select_columns(result_ids)) + return result def _resolve_label(self, label: blocks.Label) -> str: """Resolve label to column id.""" diff --git a/bigframes/core/groupby/group_by.py b/bigframes/core/groupby/group_by.py deleted file mode 100644 index 34786e4fd88..00000000000 --- a/bigframes/core/groupby/group_by.py +++ /dev/null @@ -1,91 +0,0 @@ -# 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 - -import bigframes.enums -import bigframes.operations as ops -from bigframes.core import blocks -from bigframes.core import expression as ex - - -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 index fb7845f36d2..7a8bdcb6cf5 100644 --- a/bigframes/core/groupby/series_group_by.py +++ b/bigframes/core/groupby/series_group_by.py @@ -16,37 +16,35 @@ import datetime import typing -from typing import Iterable, Literal, Sequence, Tuple, Union +from typing import Literal, Sequence, 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.block_transforms as block_transforms import bigframes.core.blocks as blocks +from bigframes.core.groupby import aggs 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 -from bigframes import session -from bigframes._tools import docs -from bigframes.core import expression as ex -from bigframes.core.groupby import aggs, group_by -from bigframes.core.logging import log_adapter -from bigframes.core.window import rolling @log_adapter.class_logger -@docs.inherit_docs(vendored_pandas_groupby.SeriesGroupBy) class SeriesGroupBy(vendored_pandas_groupby.SeriesGroupBy): + __doc__ = vendored_pandas_groupby.GroupBy.__doc__ + def __init__( self, block: blocks.Block, @@ -54,8 +52,6 @@ def __init__( 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 @@ -64,10 +60,6 @@ def __init__( 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 @@ -83,36 +75,6 @@ def head(self, n: int = 5) -> series.Series: ) ) - 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) @@ -138,11 +100,7 @@ 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, + self, method="average", ascending: bool = True, na_option: str = "keep" ) -> series.Series: return series.Series( block_ops.rank( @@ -152,7 +110,6 @@ def rank( ascending, grouping_cols=tuple(self._by_col_ids), columns=(self._value_column,), - pct=pct, ) ) @@ -190,8 +147,7 @@ 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()], + agg_block, _ = self._block.aggregate_size( by_column_ids=self._by_col_ids, dropna=self._dropna, ) @@ -224,9 +180,9 @@ def first(self, numeric_only: bool = False, min_count: int = -1) -> series.Serie agg_ops.FirstNonNullOp(), window_spec=window_spec, ) - block = block.aggregate( - (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + block, _ = block.aggregate( self._by_col_ids, + (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), dropna=self._dropna, ) return series.Series(block.with_column_labels([self._value_name])) @@ -248,9 +204,9 @@ def last(self, numeric_only: bool = False, min_count: int = -1) -> series.Series agg_ops.LastNonNullOp(), window_spec=window_spec, ) - block = block.aggregate( - (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), + block, _ = block.aggregate( self._by_col_ids, + (aggs.agg(firsts_id, agg_ops.AnyValueOp()),), dropna=self._dropna, ) return series.Series(block.with_column_labels([self._value_name])) @@ -259,66 +215,35 @@ def prod(self, *args) -> series.Series: return self._aggregate(agg_ops.product_op) def agg(self, func=None) -> typing.Union[df.DataFrame, series.Series]: - if utils.is_dict_like(func): + column_names: list[str] = [] + if isinstance(func, str): + aggregations = [aggs.agg(self._value_column, agg_ops.lookup_agg_func(func))] + column_names = [func] + elif utils.is_list_like(func): + aggregations = [ + aggs.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}" ) - is_single_func = not utils.is_list_like(func) - if is_single_func: - func = [func] - - aggregations = [] - column_labels = [] - for f in func: - if block_transforms.is_transpiler_eligible(f): - expr, name = block_transforms.compile_column_udf( - self._block, f, self._value_column - ) - aggregations.append(expr) - column_labels.append(self._value_name if is_single_func else name) - else: - agg_op, label = agg_ops.lookup_agg_func(f) - aggregations.append(aggs.agg(self._value_column, agg_op)) - column_labels.append(label if not is_single_func else self._value_name) - - agg_block = self._block.aggregate( + + agg_block, _ = self._block.aggregate( by_column_ids=self._by_col_ids, aggregations=aggregations, dropna=self._dropna, ) - if column_labels: - agg_block = agg_block.with_column_labels(column_labels) + 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) + if len(aggregations) > 1: + return df.DataFrame(agg_block) + return series.Series(agg_block) aggregate = agg - def transform(self, func, *args, **kwargs) -> series.Series: - if block_transforms.is_transpiler_eligible(func): - window_spec = window_specs.unbound(grouping_keys=tuple(self._by_col_ids)) - expr, _ = block_transforms.compile_column_udf( - self._block, - func, - self._value_column, - args=args, - kwargs=kwargs, - window_spec=window_spec, - ) - - block = self._block.project_block_exprs( - [expr], - labels=[self._value_name], - drop=True, - ) - return series.Series(block) - - raise NotImplementedError( - "SeriesGroupBy.transform is only supported when experiments.enable_python_transpiler is True and a transpiler-compatible python function is provided." - ) - def value_counts( self, normalize: bool = False, @@ -374,6 +299,7 @@ def cumcount(self, *args, **kwargs) -> series.Series: self._apply_window_op( agg_ops.SizeUnaryOp(), discard_name=True, + never_skip_nulls=True, ) - 1 ) @@ -447,9 +373,9 @@ def expanding(self, min_periods: int = 1) -> windows.Window: ) def _aggregate(self, aggregate_op: agg_ops.UnaryAggregateOp) -> series.Series: - result_block = self._block.aggregate( - (aggs.agg(self._value_column, aggregate_op),), + result_block, _ = self._block.aggregate( self._by_col_ids, + (aggs.agg(self._value_column, aggregate_op),), dropna=self._dropna, ) @@ -460,6 +386,7 @@ def _apply_window_op( op: agg_ops.UnaryWindowOp, discard_name=False, window: typing.Optional[window_specs.WindowSpec] = None, + never_skip_nulls: bool = False, ) -> series.Series: """Apply window op to groupby. Defaults to grouped cumulative window.""" window_spec = window or window_specs.cumulative_rows( @@ -472,15 +399,6 @@ def _apply_window_op( op, result_label=label, window_spec=window_spec, + never_skip_nulls=never_skip_nulls, ) - 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/indexers.py b/bigframes/core/indexers.py index faf76f7d4f0..c60e40880b7 100644 --- a/bigframes/core/indexers.py +++ b/bigframes/core/indexers.py @@ -14,25 +14,19 @@ from __future__ import annotations -import numbers import typing +from typing import Tuple, Union import warnings -from typing import Any, Sequence, Tuple, Union, cast import bigframes_vendored.constants as constants import bigframes_vendored.ibis.common.exceptions as ibis_exceptions -import numpy as np import pandas as pd -import pyarrow as pa -import pyarrow.types # type: ignore import bigframes.core.blocks -import bigframes.core.col import bigframes.core.expression as ex import bigframes.core.guid as guid import bigframes.core.indexes as indexes import bigframes.core.scalar -import bigframes.core.validations as validations import bigframes.core.window_spec as windows import bigframes.dataframe import bigframes.dtypes @@ -42,17 +36,10 @@ if typing.TYPE_CHECKING: LocSingleKey = Union[ - bigframes.series.Series, - indexes.Index, - slice, - bigframes.core.scalar.Scalar, - bigframes.core.col.Expression, + bigframes.series.Series, indexes.Index, slice, bigframes.core.scalar.Scalar ] -_DATAFRAME_ILOC_ERROR = "Only DataFrame.iloc[:, col_indexer] = value is supported." - - class LocSeriesIndexer: def __init__(self, series: bigframes.series.Series): self._series = series @@ -110,9 +97,6 @@ def __getitem__( Other key types are not yet supported. """ - if not _is_noop_slice(key): - validations.enforce_ordered(self._series, "iloc") - return _iloc_getitem_series_or_dataframe(self._series, key) @@ -121,9 +105,9 @@ def __init__(self, series: bigframes.series.Series): self._series = series def __getitem__(self, key: int) -> bigframes.core.scalar.Scalar: - if not _is_integer_scalar(key): + if not isinstance(key, int): raise ValueError("Series iAt based indexing can only have integer indexers") - return self._series.iloc[_to_python_int(key)] + return self._series.iloc[key] class AtSeriesIndexer: @@ -155,14 +139,16 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): @typing.overload def __getitem__( self, key: LocSingleKey - ) -> Union[bigframes.dataframe.DataFrame, pd.Series]: ... + ) -> Union[bigframes.dataframe.DataFrame, pd.Series]: + ... # Technically this is wrong since we can have duplicate column labels, but # this is expected to be rare. @typing.overload def __getitem__( self, key: Tuple[LocSingleKey, str] - ) -> Union[bigframes.series.Series, bigframes.core.scalar.Scalar]: ... + ) -> Union[bigframes.series.Series, bigframes.core.scalar.Scalar]: + ... def __getitem__(self, key): # TODO(tbergeron): Pandas will try both splitting 2-tuple into row, index or as 2-part @@ -197,7 +183,14 @@ def __setitem__( key: Tuple[slice, str], value: bigframes.dataframe.SingleItemValue, ): - if isinstance(key, tuple) and len(key) == 2 and _is_noop_slice(key[0]): + if ( + isinstance(key, tuple) + and len(key) == 2 + and isinstance(key[0], slice) + and (key[0].start is None or key[0].start == 0) + and (key[0].step is None or key[0].step == 1) + and key[0].stop is None + ): # TODO(swast): Support setting multiple columns with key[1] as a list # of labels and value as a DataFrame. df = self._dataframe.assign(**{key[1]: value}) @@ -248,41 +241,8 @@ def __getitem__(self, key) -> Union[bigframes.dataframe.DataFrame, pd.Series]: Other key types are not yet supported. """ - requires_ordering = True - if isinstance(key, tuple): - if len(key) > 0: - row_indexer = key[0] - if _is_noop_slice(row_indexer): - requires_ordering = False - elif _is_noop_slice(key): - requires_ordering = False - - if requires_ordering: - validations.enforce_ordered(self._dataframe, "iloc") - return _iloc_getitem_series_or_dataframe(self._dataframe, key) - def __setitem__( - self, - key: Tuple[ - slice, Union[int, typing.Sequence[int], slice, typing.Sequence[bool]] - ], - value: Union[ - bigframes.dataframe.SingleItemValue, bigframes.dataframe.DataFrame - ], - ): - if not (isinstance(key, tuple) and len(key) == 2): - raise NotImplementedError(_DATAFRAME_ILOC_ERROR) - - row_indexer, col_indexer = key - - if not _is_noop_slice(row_indexer): - raise NotImplementedError(_DATAFRAME_ILOC_ERROR) - - col_offsets = _iloc_col_indexer_to_offsets(self._dataframe, col_indexer) - df = self._dataframe._assign_multi_items_by_offsets(col_offsets, value) - self._dataframe._set_block(df._get_block()) - class IatDataFrameIndexer: def __init__(self, dataframe: bigframes.dataframe.DataFrame): @@ -291,21 +251,19 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): def __getitem__(self, key: tuple) -> bigframes.core.scalar.Scalar: error_message = "DataFrame.iat should be indexed by a tuple of exactly 2 ints" # we raise TypeError or ValueError under the same conditions that pandas does - if _is_integer_scalar(key): + if isinstance(key, int): raise TypeError(error_message) if not isinstance(key, tuple): raise ValueError(error_message) - key_values_are_ints = [_is_integer_scalar(key_value) for key_value in key] + key_values_are_ints = [isinstance(key_value, int) for key_value in key] if not all(key_values_are_ints): raise ValueError(error_message) if len(key) != 2: raise TypeError(error_message) - row_idx = _to_python_int(key[0]) - col_idx = _to_python_int(key[1]) block: bigframes.core.blocks.Block = self._dataframe._block - column_block = block.select_columns([block.value_columns[col_idx]]) + column_block = block.select_columns([block.value_columns[key[1]]]) column = bigframes.series.Series(column_block) - return column.iloc[row_idx] + return column.iloc[key[0]] class AtDataFrameIndexer: @@ -322,26 +280,18 @@ def __getitem__( return self._dataframe.loc[key] -def _is_noop_slice(key: Any) -> bool: - """Return True if key is a slice selecting all elements in the original order.""" - return ( - isinstance(key, slice) - and (key.start is None or key.start == 0) - and (key.step is None or key.step == 1) - and key.stop is None - ) - - @typing.overload def _loc_getitem_series_or_dataframe( series_or_dataframe: bigframes.series.Series, key -) -> Union[bigframes.core.scalar.Scalar, bigframes.series.Series]: ... +) -> Union[bigframes.core.scalar.Scalar, bigframes.series.Series]: + ... @typing.overload def _loc_getitem_series_or_dataframe( series_or_dataframe: bigframes.dataframe.DataFrame, key -) -> Union[bigframes.dataframe.DataFrame, pd.Series]: ... +) -> Union[bigframes.dataframe.DataFrame, pd.Series]: + ... def _loc_getitem_series_or_dataframe( @@ -353,23 +303,12 @@ def _loc_getitem_series_or_dataframe( pd.Series, bigframes.core.scalar.Scalar, ]: - if _is_noop_slice(key): - return series_or_dataframe.copy() - if isinstance(key, slice): + if (key.start is None) and (key.stop is None) and (key.step is None): + return series_or_dataframe.copy() raise NotImplementedError( f"loc does not yet support indexing with a slice. {constants.FEEDBACK_LINK}" ) - - if isinstance(key, bigframes.core.col.Expression): - label_to_col_ref = { - label: ex.deref(id) - for id, label in series_or_dataframe._block.col_id_to_label.items() - } - resolved_expr = key._value.bind_variables(label_to_col_ref) - result = series_or_dataframe.copy() - result._set_block(series_or_dataframe._block.filter(resolved_expr)) - return result if callable(key): raise NotImplementedError( f"loc does not yet support indexing with a callable. {constants.FEEDBACK_LINK}" @@ -414,7 +353,8 @@ def _perform_loc_list_join( series_or_dataframe: bigframes.series.Series, keys_index: indexes.Index, drop_levels: bool = False, -) -> bigframes.series.Series: ... +) -> bigframes.series.Series: + ... @typing.overload @@ -422,7 +362,8 @@ def _perform_loc_list_join( series_or_dataframe: bigframes.dataframe.DataFrame, keys_index: indexes.Index, drop_levels: bool = False, -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... def _perform_loc_list_join( @@ -477,129 +418,18 @@ def _struct_accessor_check_and_warn( warnings.warn(msg, stacklevel=7, category=bfe.BadIndexerKeyWarning) -def _to_python_int(value: Any) -> int: - if isinstance(value, pa.Scalar): - return int(value.as_py()) - return int(value) - - -def _iloc_clip_to_offset(index: Any, length: int, name: str) -> int: - """Support negative values for offsets.""" - if not _is_integer_scalar(index): - raise TypeError(f"got unexpected {type(index)} for {name}") - offset = _to_python_int(index) - if offset < 0: - offset += length - - if offset < 0 or offset >= length: - raise IndexError(f"{name} {index} is out-of-bounds") - - return offset - - -def _is_integer_scalar(value: Any) -> bool: - return not ( - isinstance(value, bool) - or isinstance(value, np.bool_) - or (isinstance(value, pa.Scalar) and pyarrow.types.is_boolean(value.type)) - ) and ( - isinstance(value, numbers.Integral) - or (isinstance(value, pa.Scalar) and pyarrow.types.is_integer(value.type)) - ) - - -def _is_boolean_scalar(value: Any) -> bool: - return ( - isinstance(value, bool) - or isinstance(value, np.bool_) - or (isinstance(value, pa.Scalar) and pyarrow.types.is_boolean(value.type)) - ) - - -def _truth_val(value: Any) -> bool: - if value is None or value is pd.NA or pd.isna(value): - return False - if isinstance(value, pa.Scalar): - return bool(value.as_py()) if value.is_valid else False - return bool(value) - - -def _is_boolean_indexer(indexer: Any) -> bool: - if hasattr(indexer, "dtype") and pd.api.types.is_bool_dtype(indexer.dtype): - return True - if ( - hasattr(indexer, "type") - and isinstance(indexer.type, pa.DataType) - and pyarrow.types.is_boolean(indexer.type) - ): - return True - if pd.api.types.is_list_like(indexer): - lst = ( - list(indexer) - if not isinstance(indexer, (bigframes.series.Series, indexes.Index)) - else list(indexer.to_pandas()) - ) - if len(lst) > 0 and all( - _is_boolean_scalar(x) or (x is None) or (x is pd.NA) or pd.isna(x) - for x in lst - ): - return any(_is_boolean_scalar(x) for x in lst) - return False - - -def _iloc_col_indexer_to_offsets( - df: bigframes.dataframe.DataFrame, col_indexer: Any -) -> Sequence[int]: - """Convert col_indexer from one of the many pandas-compatible formats to a list of offsets.""" - n_cols = len(df.columns) - - if _is_integer_scalar(col_indexer): - col_offset = _to_python_int(col_indexer) - return [ - _iloc_clip_to_offset( - col_offset, n_cols, "single positional iloc column indexer" - ) - ] - - elif isinstance(col_indexer, slice): - return list(range(*col_indexer.indices(n_cols))) - - elif _is_boolean_indexer(col_indexer): - col_indexer_list = list(col_indexer) - if len(col_indexer_list) != n_cols: - raise ValueError( - f"Boolean iloc column indexer has wrong length: {len(col_indexer_list)} instead of {n_cols}" - ) - return [i for i, val in enumerate(col_indexer_list) if _truth_val(val)] - - elif pd.api.types.is_list_like(col_indexer): - col_indexer_list = list(col_indexer) - return [ - _iloc_clip_to_offset(idx, n_cols, "iloc column indexer") - for idx in col_indexer_list - ] - - raise TypeError(f"got unexpected {type(col_indexer)} for iloc column indexer") - - -def _iloc_df_from_column_offsets( - df: bigframes.dataframe.DataFrame, key: Sequence[int] -) -> bigframes.dataframe.DataFrame: - block = df._block - selected_ids = tuple(block.value_columns[offset] for offset in key) - return bigframes.dataframe.DataFrame(block.select_columns(selected_ids)) - - @typing.overload def _iloc_getitem_series_or_dataframe( series_or_dataframe: bigframes.series.Series, key -) -> Union[bigframes.series.Series, bigframes.core.scalar.Scalar]: ... +) -> Union[bigframes.series.Series, bigframes.core.scalar.Scalar]: + ... @typing.overload def _iloc_getitem_series_or_dataframe( series_or_dataframe: bigframes.dataframe.DataFrame, key -) -> Union[bigframes.dataframe.DataFrame, pd.Series, bigframes.core.scalar.Scalar]: ... +) -> Union[bigframes.dataframe.DataFrame, pd.Series, bigframes.core.scalar.Scalar]: + ... def _iloc_getitem_series_or_dataframe( @@ -611,10 +441,9 @@ def _iloc_getitem_series_or_dataframe( bigframes.core.scalar.Scalar, pd.Series, ]: - if _is_integer_scalar(key): - key_int = _to_python_int(key) - stop_key = key_int + 1 if key_int != -1 else None - internal_slice_result = series_or_dataframe._slice(key_int, stop_key, 1) + if isinstance(key, int): + stop_key = key + 1 if key != -1 else None + internal_slice_result = series_or_dataframe._slice(key, stop_key, 1) result_pd_df = internal_slice_result.to_pandas() if result_pd_df.empty: raise IndexError("single positional indexer is out-of-bounds") @@ -635,22 +464,14 @@ def _iloc_getitem_series_or_dataframe( # len(key) == 2 df = typing.cast(bigframes.dataframe.DataFrame, series_or_dataframe) - if _is_integer_scalar(key[0]) and _is_integer_scalar(key[1]): + if isinstance(key[1], int): return df.iat[key] - - row_indexer, column_indexer = key - column_offsets = _iloc_col_indexer_to_offsets(df, column_indexer) - df_subset = _iloc_df_from_column_offsets(df, column_offsets) - - if _is_integer_scalar(column_indexer): - selected_columns = cast( - Union[bigframes.dataframe.DataFrame, bigframes.series.Series], - df_subset[df_subset.columns[0]], - ) - else: - selected_columns = df_subset - - return _iloc_getitem_series_or_dataframe(selected_columns, row_indexer) + 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( @@ -658,26 +479,6 @@ def _iloc_getitem_series_or_dataframe( series_or_dataframe.iloc[0:0], ) - if _is_boolean_indexer(key): - key_list = ( - list(key) - if not isinstance(key, (bigframes.series.Series, indexes.Index)) - else list(key.to_pandas()) - ) - n_rows = len(series_or_dataframe) - if len(key_list) != n_rows: - raise IndexError( - f"Boolean index has wrong length: {len(key_list)} instead of {n_rows}" - ) - key = [i for i, val in enumerate(key_list) if _truth_val(val)] - if len(key) == 0: - return typing.cast( - Union[bigframes.dataframe.DataFrame, bigframes.series.Series], - series_or_dataframe.iloc[0:0], - ) - else: - key = [_to_python_int(k) for k in list(key)] - # Check if both positive index and negative index are necessary if isinstance(key, (bigframes.series.Series, indexes.Index)): # Avoid data download diff --git a/bigframes/core/indexes/base.py b/bigframes/core/indexes/base.py index 32279d36c9a..e022b3f1519 100644 --- a/bigframes/core/indexes/base.py +++ b/bigframes/core/indexes/base.py @@ -18,7 +18,7 @@ import functools import typing -from typing import Hashable, Literal, Optional, Sequence, Union, cast, overload +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 @@ -26,7 +26,7 @@ import numpy as np import pandas -import bigframes.core.agg_expressions as ex_types +from bigframes import dtypes import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks import bigframes.core.expression as ex @@ -38,18 +38,14 @@ import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops import bigframes.series -import bigframes.session.execution_spec as ex_spec -from bigframes import dtypes -from bigframes._tools import docs if typing.TYPE_CHECKING: import bigframes.dataframe - import bigframes.operations.strings import bigframes.series -@docs.inherit_docs(vendored_pandas_index.Index) -class Index: +class Index(vendored_pandas_index.Index): + __doc__ = vendored_pandas_index.Index.__doc__ _query_job = None _block: blocks.Block _linked_frame: Union[ @@ -173,16 +169,12 @@ def shape(self) -> typing.Tuple[int]: @property def dtype(self): - 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 + return self._block.index.dtypes[0] if self.nlevels == 1 else np.dtype("O") @property def dtypes(self) -> pandas.Series: - dtypes = self._block.index.dtypes - bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) return pandas.Series( - data=dtypes, + data=self._block.index.dtypes, index=typing.cast(typing.Tuple, self._block.index.names), ) @@ -211,6 +203,7 @@ def is_monotonic_increasing(self) -> bool: @property @validations.requires_ordering() def is_monotonic_decreasing(self) -> bool: + return typing.cast( bool, self._block.is_monotonic_decreasing(self._block.index_columns), @@ -288,33 +281,22 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: 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_agg = ex.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() - ) + count_scalar = self._block.session._executor.execute( + count_result + ).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)) + min_agg = ex.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() - ) + position_scalar = self._block.session._executor.execute( + position_result + ).to_py_scalar() return int(position_scalar) # Handle multiple matches based on index monotonicity @@ -325,43 +307,33 @@ def get_loc(self, key) -> typing.Union[int, slice, "bigframes.series.Series"]: # 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) - mask_block = mask_block.with_column_labels([None]) result_series = bigframes.series.Series(mask_block) return result_series.astype("boolean") - def _get_monotonic_slice( - self, filtered_block, offsets_id: __builtins__.str - ) -> slice: + def _get_monotonic_slice(self, filtered_block, offsets_id: 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)), + ex.UnaryAggregation(agg_ops.min_op, ex.deref(offsets_id)), "min_pos", ), ( - ex_types.UnaryAggregation(agg_ops.max_op, ex.deref(offsets_id)), + ex.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() - ) + result_df = self._block.session._executor.execute(combined_result).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: + def __repr__(self) -> str: # Protect against errors with uninitialized Series. See: # https://github.com/googleapis/python-bigquery-dataframes/issues/728 if not hasattr(self, "_block"): @@ -373,7 +345,9 @@ def __repr__(self) -> __builtins__.str: # metadata, like we do with DataFrame. opts = bigframes.options.display max_results = opts.max_rows - if opts.repr_mode == "deferred": + # anywdiget mode uses the same display logic as the "deferred" mode + # for faster execution + if opts.repr_mode in ("deferred", "anywidget"): _, dry_run_query_job = self._block._compute_dry_run() return formatter.repr_query_job(dry_run_query_job) @@ -399,16 +373,9 @@ 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=str(name), session=self._session - ) + return bigframes.series.Series(data=self, index=self, name=name) else: - return bigframes.series.Series( - data=self, - index=Index(index, session=self._session), - name=str(name), - session=self._session, - ) + return bigframes.series.Series(data=self, index=Index(index), name=name) def get_level_values(self, level) -> Index: level_n = level if isinstance(level, int) else self.names.index(level) @@ -433,7 +400,6 @@ def sort_values( *, inplace: bool = False, ascending: bool = True, - kind: str | None = None, na_position: str = "last", ) -> Index: if na_position not in ["first", "last"]: @@ -446,8 +412,7 @@ def sort_values( else order.descending_over(column, na_last) for column in index_columns ] - is_stable = (kind or constants.DEFAULT_SORT_KIND) in constants.STABLE_SORT_KINDS - return Index(self._block.order_by(ordering, stable=is_stable)) + return Index(self._block.order_by(ordering)) def astype( self, @@ -541,7 +506,8 @@ def fillna(self, value=None) -> Index: def rename( self, name: Union[blocks.Label, Sequence[blocks.Label]], - ) -> Index: ... + ) -> Index: + ... @overload def rename( @@ -549,7 +515,8 @@ def rename( name: Union[blocks.Label, Sequence[blocks.Label]], *, inplace: Literal[False], - ) -> Index: ... + ) -> Index: + ... @overload def rename( @@ -557,7 +524,8 @@ def rename( name: Union[blocks.Label, Sequence[blocks.Label]], *, inplace: Literal[True], - ) -> None: ... + ) -> None: + ... def rename( self, @@ -619,7 +587,9 @@ 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: __builtins__.str = "first") -> Index: + def drop_duplicates(self, *, keep: str = "first") -> Index: + if keep is not False: + validations.enforce_ordered(self, "drop_duplicates") block = block_ops.drop_duplicates(self._block, self._block.index_columns, keep) return Index(block) @@ -669,9 +639,6 @@ def __contains__(self, key) -> bool: 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, @@ -716,12 +683,14 @@ def to_pandas( # type: ignore[overload-overlap] *, allow_large_results: Optional[bool] = ..., dry_run: Literal[False] = ..., - ) -> pandas.Index: ... + ) -> pandas.Index: + ... @overload def to_pandas( self, *, allow_large_results: Optional[bool] = ..., dry_run: Literal[True] = ... - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def to_pandas( self, @@ -765,87 +734,13 @@ def to_numpy(self, dtype=None, *, allow_large_results=None, **kwargs) -> np.ndar __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 __bool__(self): - raise ValueError( - "Cannot convert Index into bool. Consider using .empty(), .item(), .any(), or .all() methods." - ) - 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 - - # last so as to not shadow __builtins__.str - @property - def str(self) -> bigframes.operations.strings.StringMethods: - import bigframes.operations.strings - - return bigframes.operations.strings.StringMethods(self) - def _should_create_datetime_index(block: blocks.Block) -> bool: if len(block.index.dtypes) != 1: diff --git a/bigframes/core/indexes/datetimes.py b/bigframes/core/indexes/datetimes.py index 763e44be095..23ad8b03b4d 100644 --- a/bigframes/core/indexes/datetimes.py +++ b/bigframes/core/indexes/datetimes.py @@ -20,14 +20,14 @@ datetimes as vendored_pandas_datetime_index, ) -from bigframes._tools import docs from bigframes.core import expression as ex from bigframes.core.indexes.base import Index from bigframes.operations import date_ops -@docs.inherit_docs(vendored_pandas_datetime_index.DatetimeIndex) -class DatetimeIndex(Index): +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 diff --git a/bigframes/core/indexes/multi.py b/bigframes/core/indexes/multi.py index 0b9681b55f6..182d1f101cf 100644 --- a/bigframes/core/indexes/multi.py +++ b/bigframes/core/indexes/multi.py @@ -14,34 +14,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Hashable, Iterable, Optional, Sequence, cast +from typing import cast, Hashable, Iterable, Sequence import bigframes_vendored.pandas.core.indexes.multi as vendored_pandas_multindex import pandas -from bigframes._tools import docs -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__ -@docs.inherit_docs(vendored_pandas_multindex.MultiIndex) -class MultiIndex(Index): @classmethod def from_tuples( cls, 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, session=session)) + return cast(MultiIndex, Index(pd_index)) @classmethod def from_arrays( @@ -49,67 +42,7 @@ 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, 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) + return cast(MultiIndex, Index(pd_index)) diff --git a/bigframes/core/interchange.py b/bigframes/core/interchange.py deleted file mode 100644 index 4dacedd0142..00000000000 --- a/bigframes/core/interchange.py +++ /dev/null @@ -1,155 +0,0 @@ -# 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 TYPE_CHECKING, Any, Dict, Iterable, Optional, Sequence - -import bigframes.enums -from bigframes.core import blocks - -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 c05bda7a7fb..958113dda38 100644 --- a/bigframes/core/local_data.py +++ b/bigframes/core/local_data.py @@ -21,19 +21,18 @@ import io import itertools import json +from typing import Any, Callable, cast, Generator, Iterable, Literal, Optional, Union import uuid -from typing import Any, Callable, Generator, Iterable, Literal, Optional, Union, cast import geopandas # type: ignore -import numpy 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 -from bigframes.core import identifiers, pyarrow_utils @dataclasses.dataclass(frozen=True) @@ -84,39 +83,20 @@ def from_pandas(cls, dataframe: pd.DataFrame) -> ManagedArrowTable: 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 from_pyarrow(self, table: pa.Table) -> ManagedArrowTable: + 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, @@ -125,21 +105,12 @@ def to_arrow( geo_format: Literal["wkb", "wkt"] = "wkt", duration_type: Literal["int", "duration"] = "duration", json_type: Literal["string"] = "string", - sample_rate: Optional[float] = None, - 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" - data = self.data - - # This exists for symmetry with remote sources, but sampling local data like this shouldn't really happen - if sample_rate is not None: - to_take = numpy.random.rand(data.num_rows) < sample_rate - data = data.filter(to_take) - - batches = data.to_batches(max_chunksize=max_chunksize) + batches = self.data.to_batches() schema = self.data.schema if duration_type == "int": schema = _schema_durations_to_ints(schema) @@ -154,9 +125,6 @@ def to_arrow( else: return schema, batches - def is_nullable(self, column_id: identifiers.ColumnId) -> bool: - return self.data.column(column_id.name).null_count > 0 - def to_pyarrow_table( self, *, @@ -248,11 +216,10 @@ def iter_array( 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 - ), + lambda x: ((x.days * 3600 * 24) + x.seconds) * 1_000_000 + + x.microseconds + if x is not None + else x, values, ) else: @@ -267,16 +234,9 @@ def _( 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)) + for (start, end) in _pairwise(array.offsets): + arr_size = end.as_py() - start.as_py() + yield list(itertools.islice(value_generator, arr_size)) @iter_array.register def _( @@ -288,15 +248,8 @@ def _( 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 row_values in zip(*sub_generators.values()): + yield {key: value for key, value in zip(keys, row_values)} for batch in table.to_batches(): sub_generators: dict[str, Generator[Any, None, None]] = {} @@ -324,10 +277,7 @@ def _adapt_pandas_series( ) 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) + return _adapt_arrow_array(pa.array(series)) except pa.ArrowInvalid as e: if series.dtype == np.dtype("O"): try: @@ -424,7 +374,7 @@ def _get_managed_storage_type(dtype: bigframes.dtypes.Dtype) -> pa.DataType: def _recursive_map_types( - f: Callable[[pa.DataType], pa.DataType], + f: Callable[[pa.DataType], pa.DataType] ) -> Callable[[pa.DataType], pa.DataType]: @functools.wraps(f) def recursive_f(type: pa.DataType) -> pa.DataType: @@ -499,9 +449,7 @@ def _append_offsets( ) -> 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() - ) + offsets = pa.array(range(offset, offset + 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())), @@ -521,3 +469,16 @@ 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 ) + + +def _pairwise(iterable): + do_yield = False + a = None + b = None + for item in iterable: + a = b + b = item + if do_yield: + yield (a, b) + else: + do_yield = True diff --git a/bigframes/core/log_adapter.py b/bigframes/core/log_adapter.py new file mode 100644 index 00000000000..6021c7075a8 --- /dev/null +++ b/bigframes/core/log_adapter.py @@ -0,0 +1,271 @@ +# 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 functools +import inspect +import threading +from typing import List, Optional + +from google.cloud import bigquery +import pandas + +_lock = threading.Lock() + +# The limit is 64 (https://cloud.google.com/bigquery/docs/labels-intro#requirements), +# but leave a few spare for internal labels to be added. +# See internal issue 386825477. +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__"] + +# Stack to track method calls +_call_stack: List = [] + + +def submit_pandas_labels( + bq_client: Optional[bigquery.Client], + base_name: str, + method_name: str, + args=(), + kwargs={}, + task: str = PANDAS_API_TRACKING_TASK, +): + """ + Submits usage of API to BigQuery using a simulated failed query. + + This function is designed to capture and log details about the usage of pandas methods, + including class and method names, the count of positional arguments, and any keyword + arguments that match the method's signature. To avoid incurring costs, it simulates a + query execution using a query with syntax errors. + + Args: + bq_client (bigquery.Client): The client used to interact with BigQuery. + 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. + task (str): The specific task type for the logging event: + - 'PANDAS_API_TRACKING_TASK': Indicates that the unimplemented feature is a method. + - 'PANDAS_PARAM_TRACKING_TASK': Indicates that the unimplemented feature is a + parameter of a method. + """ + if bq_client is None or ( + method_name.startswith("_") and not method_name.startswith("__") + ): + return + + labels_dict = { + "task": task, + "class_name": base_name.lower(), + "method_name": method_name.lower(), + "args_count": len(args), + } + + # 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(base, method_name): + method = getattr(base, method_name) + else: + return + + if kwargs: + # Iterate through the keyword arguments and add them to the labels dictionary if they + # are parameters that are implemented in pandas and the maximum label count has not been reached. + signature = inspect.signature(method) + param_names = [param.name for param in signature.parameters.values()] + + idx = 0 + for key in kwargs.keys(): + if len(labels_dict) >= MAX_LABELS_COUNT: + break + if key in param_names: + labels_dict[f"kwargs_{idx}"] = key.lower() + idx += 1 + + # If this log is for tracking unimplemented parameters and no keyword arguments were + # provided, skip logging. + if len(labels_dict) == 4 and task == PANDAS_PARAM_TRACKING_TASK: + return + + # Run a query with syntax error to avoid cost. + query = "SELECT COUNT(x FROM data_table—" + job_config = bigquery.QueryJobConfig(labels=labels_dict) + bq_client.query(query, job_config=job_config) + + +def class_logger(decorated_cls=None): + """Decorator that adds logging functionality to each method of the class.""" + + 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( + cls, + attr_name, + property_logger(attr_value), + ) + 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, /, *, custom_base_name: Optional[str] = None): + """Decorator that adds logging functionality to a method.""" + + @functools.wraps(method) + def wrapper(*args, **kwargs): + api_method_name = getattr(method, LOG_OVERRIDE_NAME, method.__name__) + 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:]) + ) + else: + base_name = custom_base_name + + full_method_name = f"{base_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(*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 + + +def property_logger(prop): + """Decorator that adds logging functionality to a property.""" + + def shared_wrapper(prop): + @functools.wraps(prop) + def wrapped(*args, **kwargs): + 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) + + _call_stack.append(full_property_name) + try: + return prop(*args, **kwargs) + finally: + _call_stack.pop() + + return wrapped + + # Apply the wrapper to the getter, setter, and deleter + return property( + shared_wrapper(prop.fget), + shared_wrapper(prop.fset) if prop.fset else None, + shared_wrapper(prop.fdel) if prop.fdel else None, + ) + + +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): + 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.replace("<", "").replace(">", "")) + # 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): + global _lock + with _lock: + previous_api_methods = list(_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 + + +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 diff --git a/bigframes/core/logging/__init__.py b/bigframes/core/logging/__init__.py deleted file mode 100644 index 5d06124efce..00000000000 --- a/bigframes/core/logging/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.core.logging import data_types, log_adapter - -__all__ = ["log_adapter", "data_types"] diff --git a/bigframes/core/logging/data_types.py b/bigframes/core/logging/data_types.py deleted file mode 100644 index 3cb65a5c501..00000000000 --- a/bigframes/core/logging/data_types.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 bigframes import dtypes -from bigframes.core import agg_expressions, bigframe_node, expression, nodes -from bigframes.core.rewrite import schema_binding - -IGNORED_NODES = ( - nodes.SelectionNode, - nodes.ReadLocalNode, - nodes.ReadTableNode, - nodes.ConcatNode, - nodes.RandomSampleNode, - nodes.FromRangeNode, - nodes.PromoteOffsetsNode, - nodes.ReversedNode, - nodes.SliceNode, - nodes.ResultNode, -) - - -def encode_type_refs(root: bigframe_node.BigFrameNode) -> str: - return f"{root.reduce_up(_encode_type_refs_from_node):x}" - - -def _encode_type_refs_from_node( - node: bigframe_node.BigFrameNode, child_results: tuple[int, ...] -) -> int: - child_result = functools.reduce(lambda x, y: x | y, child_results, 0) - - curr_result = 0 - if isinstance(node, nodes.FilterNode): - curr_result = _encode_type_refs_from_expr(node.predicate, node.child) - elif isinstance(node, nodes.ProjectionNode): - for assignment in node.assignments: - expr = assignment[0] - if isinstance(expr, (expression.DerefOp)): - # Ignore direct assignments in projection nodes. - continue - curr_result = curr_result | _encode_type_refs_from_expr( - assignment[0], node.child - ) - elif isinstance(node, nodes.OrderByNode): - for by in node.by: - curr_result = curr_result | _encode_type_refs_from_expr( - by.scalar_expression, node.child - ) - elif isinstance(node, nodes.JoinNode): - for left, right in node.conditions: - curr_result = ( - curr_result - | _encode_type_refs_from_expr(left, node.left_child) - | _encode_type_refs_from_expr(right, node.right_child) - ) - elif isinstance(node, nodes.InNode): - curr_result = _encode_type_refs_from_expr(node.left_col, node.left_child) - elif isinstance(node, nodes.AggregateNode): - for agg, _ in node.aggregations: - curr_result = curr_result | _encode_type_refs_from_expr(agg, node.child) - elif isinstance(node, nodes.WindowOpNode): - for grouping_key in node.window_spec.grouping_keys: - curr_result = curr_result | _encode_type_refs_from_expr( - grouping_key, node.child - ) - for ordering_expr in node.window_spec.ordering: - curr_result = curr_result | _encode_type_refs_from_expr( - ordering_expr.scalar_expression, node.child - ) - for col_def in node.agg_exprs: - curr_result = curr_result | _encode_type_refs_from_expr( - col_def.expression, node.child - ) - elif isinstance(node, nodes.ExplodeNode): - for col_id in node.column_ids: - curr_result = curr_result | _encode_type_refs_from_expr(col_id, node.child) - elif isinstance(node, IGNORED_NODES): - # Do nothing - pass - else: - # For unseen nodes, do not raise errors as this is the logging path, but - # we should cover those nodes either in the branches above, or place them - # in the IGNORED_NODES collection. - pass - - return child_result | curr_result - - -def _encode_type_refs_from_expr( - expr: expression.Expression, child_node: bigframe_node.BigFrameNode -) -> int: - # TODO(b/409387790): Remove this branch once SQLGlot compiler fully replaces Ibis compiler - if not expr.is_resolved: - if isinstance(expr, agg_expressions.Aggregation): - expr = schema_binding._bind_schema_to_aggregation_expr(expr, child_node) - else: - expr = expression.bind_schema_fields(expr, child_node.field_by_id) - - result = _get_dtype_mask(expr.output_type) - for child_expr in expr.children: - result = result | _encode_type_refs_from_expr(child_expr, child_node) - - return result - - -def _get_dtype_mask(dtype: dtypes.Dtype | None) -> int: - if dtype is None: - # If the dtype is not given, ignore - return 0 - if dtype == dtypes.INT_DTYPE: - return 1 << 1 - if dtype == dtypes.FLOAT_DTYPE: - return 1 << 2 - if dtype == dtypes.BOOL_DTYPE: - return 1 << 3 - if dtype == dtypes.STRING_DTYPE: - return 1 << 4 - if dtype == dtypes.BYTES_DTYPE: - return 1 << 5 - if dtype == dtypes.DATE_DTYPE: - return 1 << 6 - if dtype == dtypes.TIME_DTYPE: - return 1 << 7 - if dtype == dtypes.DATETIME_DTYPE: - return 1 << 8 - if dtype == dtypes.TIMESTAMP_DTYPE: - return 1 << 9 - if dtype == dtypes.TIMEDELTA_DTYPE: - return 1 << 10 - if dtype == dtypes.NUMERIC_DTYPE: - return 1 << 11 - if dtype == dtypes.BIGNUMERIC_DTYPE: - return 1 << 12 - if dtype == dtypes.GEO_DTYPE: - return 1 << 13 - if dtype == dtypes.JSON_DTYPE: - return 1 << 14 - - if dtypes.is_struct_like(dtype): - mask = 1 << 15 - if dtype == dtypes.OBJ_REF_DTYPE: - # obj_ref is a special struct type for multi-modal data. - # It should be double counted as both "struct" and its own type. - mask = mask | (1 << 17) - return mask - - if dtypes.is_array_like(dtype): - return 1 << 16 - - # If an unknown datat type is present, mark it with the least significant bit. - return 1 << 0 diff --git a/bigframes/core/logging/log_adapter.py b/bigframes/core/logging/log_adapter.py deleted file mode 100644 index 83d300a708b..00000000000 --- a/bigframes/core/logging/log_adapter.py +++ /dev/null @@ -1,338 +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 functools -import inspect -import threading -from typing import List, Optional - -import pandas -from google.cloud import bigquery - -_lock = threading.Lock() - -# The limit is 64 (https://cloud.google.com/bigquery/docs/labels-intro#requirements), -# but leave a few spare for internal labels to be added. -# See internal issue 386825477. -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__"] - -# Stack to track method calls -_call_stack: List = [] - - -def submit_pandas_labels( - bq_client: Optional[bigquery.Client], - base_name: str, - method_name: str, - args=(), - kwargs={}, - task: str = PANDAS_API_TRACKING_TASK, -): - """ - Submits usage of API to BigQuery using a simulated failed query. - - This function is designed to capture and log details about the usage of pandas methods, - including class and method names, the count of positional arguments, and any keyword - arguments that match the method's signature. To avoid incurring costs, it simulates a - query execution using a query with syntax errors. - - Args: - bq_client (bigquery.Client): The client used to interact with BigQuery. - 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. - task (str): The specific task type for the logging event: - - 'PANDAS_API_TRACKING_TASK': Indicates that the unimplemented feature is a method. - - 'PANDAS_PARAM_TRACKING_TASK': Indicates that the unimplemented feature is a - parameter of a method. - """ - if bq_client is None or ( - method_name.startswith("_") and not method_name.startswith("__") - ): - return - - labels_dict = { - "task": task, - "class_name": base_name.lower(), - "method_name": method_name.lower(), - "args_count": len(args), - } - - # 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(base, method_name): - method = getattr(base, method_name) - else: - return - - if kwargs: - # Iterate through the keyword arguments and add them to the labels dictionary if they - # are parameters that are implemented in pandas and the maximum label count has not been reached. - signature = inspect.signature(method) - param_names = [param.name for param in signature.parameters.values()] - - idx = 0 - for key in kwargs.keys(): - if len(labels_dict) >= MAX_LABELS_COUNT: - break - if key in param_names: - labels_dict[f"kwargs_{idx}"] = key.lower() - idx += 1 - - # If this log is for tracking unimplemented parameters and no keyword arguments were - # provided, skip logging. - if len(labels_dict) == 4 and task == PANDAS_PARAM_TRACKING_TASK: - return - - # Run a query with syntax error to avoid cost. - query = "SELECT COUNT(x FROM data_table—" - job_config = bigquery.QueryJobConfig(labels=labels_dict) - bq_client.query(query, job_config=job_config) - - -def class_logger(decorated_cls=None): - """Decorator that adds logging functionality to each method of the class.""" - - 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( - cls, - attr_name, - property_logger(attr_value), - ) - 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=None, /, *, custom_base_name: Optional[str] = None): - """Decorator that adds logging functionality to a method.""" - - 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:]) - ) - else: - base_name = custom_base_name - - full_method_name = f"{base_name.lower()}-{api_method_name}" - _call_stack.append(full_method_name) - - try: - # Track directly called methods - if len(_call_stack) == 1: - session = _find_session(*args, **kwargs) - add_api_method(full_method_name, session=session) - - 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(prop): - @functools.wraps(prop) - def wrapped(*args, **kwargs): - 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()}" - - _call_stack.append(full_property_name) - try: - if len(_call_stack) == 1: - session = _find_session(*args, **kwargs) - add_api_method(full_property_name, session=session) - - return prop(*args, **kwargs) - finally: - _call_stack.pop() - - return wrapped - - # Apply the wrapper to the getter, setter, and deleter - return property( - shared_wrapper(prop.fget), - shared_wrapper(prop.fset) if prop.fset else None, - shared_wrapper(prop.fdel) if prop.fdel else None, - ) - - -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 - - clean_method_name = api_method_name.replace("<", "").replace(">", "") - - 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: - 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 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 isinstance( - getattr(session, "_api_methods", None), list - ) - - -def _find_session(*args, **kwargs): - # This function cannot import Session at the top level because Session - # imports log_adapter. - from bigframes.session import Session - - for arg in args: - if isinstance(arg, Session) and _is_session_initialized(arg): - return arg - if hasattr(arg, "__dict__") and "_block" in arg.__dict__: - session = getattr(arg, "_session", None) - if 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 e88a78fae5c..cf6e8a7e5c4 100644 --- a/bigframes/core/nodes.py +++ b/bigframes/core/nodes.py @@ -16,30 +16,34 @@ import abc import dataclasses +import datetime import functools import itertools import typing from typing import ( AbstractSet, Callable, + cast, Iterable, Mapping, Optional, Sequence, Tuple, - cast, ) +import google.cloud.bigquery as bq + +from bigframes.core import identifiers, local_data, sequences +from bigframes.core.bigframe_node import BigFrameNode, COLUMN_SET import bigframes.core.expression as ex +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 -from bigframes.core import agg_expressions, bq_data, identifiers, local_data, sequences -from bigframes.core.bigframe_node import COLUMN_SET, BigFrameNode -from bigframes.core.field import Field -from bigframes.core.ordering import OrderingExpression, RowOrdering if typing.TYPE_CHECKING: + import bigframes.core.ordering as orderings import bigframes.session @@ -47,12 +51,6 @@ 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. @@ -68,14 +66,17 @@ class AdditiveNode: @property @abc.abstractmethod - def added_fields(self) -> Tuple[Field, ...]: ... + def added_fields(self) -> Tuple[Field, ...]: + ... @property @abc.abstractmethod - def additive_base(self) -> BigFrameNode: ... + def additive_base(self) -> BigFrameNode: + ... @abc.abstractmethod - def replace_additive_base(self, BigFrameNode) -> BigFrameNode: ... + def replace_additive_base(self, BigFrameNode) -> BigFrameNode: + ... @dataclasses.dataclass(frozen=True, eq=False) @@ -202,12 +203,13 @@ class InNode(BigFrameNode, AdditiveNode): left_child: BigFrameNode right_child: BigFrameNode left_col: ex.DerefOp + right_col: ex.DerefOp indicator_col: identifiers.ColumnId - # For matching left_col to right_child[0], if true, nulls match nulls, if false, nulls don't match nulls - nulls_equal: bool = True def _validate(self): - assert len(self.right_child.fields) == 1 + assert not ( + set(self.left_child.ids) & set(self.right_child.ids) + ), "Join ids collide" @property def row_preserving(self) -> bool: @@ -260,11 +262,7 @@ def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: @property def referenced_ids(self) -> COLUMN_SET: - return frozenset( - { - self.left_col.id, - } - ) + return frozenset({self.left_col.id, self.right_col.id}) @property def additive_base(self) -> BigFrameNode: @@ -272,11 +270,13 @@ def additive_base(self) -> BigFrameNode: @property def joins_nulls(self) -> bool: - return self.nulls_equal + 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 + return left_nullable or right_nullable @property def _node_expressions(self): - return (self.left_col,) + return (self.left_col, self.right_col) def replace_additive_base(self, node: BigFrameNode): return dataclasses.replace(self, left_child=node) @@ -300,12 +300,7 @@ 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 - ), - ) # type: ignore + 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 @dataclasses.dataclass(frozen=True, eq=False) @@ -314,15 +309,12 @@ class JoinNode(BigFrameNode): right_child: BigFrameNode conditions: typing.Tuple[typing.Tuple[ex.DerefOp, ex.DerefOp], ...] type: typing.Literal["inner", "outer", "left", "right", "cross"] - # choose to treat nulls as equal or not for purposes of the join - # pandas treats nulls as equal, sql does not - nulls_equal: bool propogate_order: bool def _validate(self): - assert not (set(self.left_child.ids) & set(self.right_child.ids)), ( - "Join ids collide" - ) + assert not ( + set(self.left_child.ids) & set(self.right_child.ids) + ), "Join ids collide" @property def row_preserving(self) -> bool: @@ -356,7 +348,13 @@ def fields(self) -> Sequence[Field]: @property def joins_nulls(self) -> bool: - return self.nulls_equal + for left_ref, right_ref in self.conditions: + if ( + self.left_child.field_by_id[left_ref.id].nullable + and self.right_child.field_by_id[right_ref.id].nullable + ): + return True + return False @functools.cached_property def variables_introduced(self) -> int: @@ -593,13 +591,14 @@ 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.source_id) + return ScanItem(id, self.dtype, self.source_id) def with_source_id(self, source_id: str) -> ScanItem: - return ScanItem(self.id, source_id) + return ScanItem(self.id, self.dtype, source_id) @dataclasses.dataclass(frozen=True) @@ -654,7 +653,7 @@ def remap_source_ids( def append( self, source_id: str, dtype: bigframes.dtypes.Dtype, id: identifiers.ColumnId ) -> ScanList: - return ScanList((*self.items, ScanItem(id, source_id))) + return ScanList((*self.items, ScanItem(id, dtype, source_id))) @dataclasses.dataclass(frozen=True, eq=False) @@ -670,16 +669,8 @@ class ReadLocalNode(LeafNode): @property def fields(self) -> Sequence[Field]: fields = tuple( - Field( - col_id, - self.local_data_source.schema.get_type(source_id), - nullable=self.local_data_source.is_nullable( - identifiers.ColumnId(source_id) - ), - ) - for col_id, source_id in self.scan_list.items + Field(col_id, dtype) for col_id, dtype, _ in self.scan_list.items ) - if self.offsets_col is not None: return tuple( itertools.chain( @@ -727,7 +718,7 @@ def remap_vars( ) -> ReadLocalNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.source_id) + ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) for item in self.scan_list.items ) ) @@ -746,9 +737,64 @@ 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() + 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), + ) + + 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. + """ + + 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 + n_rows: Optional[int] = None + + +## Put ordering in here or just add order_by node above? @dataclasses.dataclass(frozen=True, eq=False) class ReadTableNode(LeafNode): - source: bq_data.BigqueryDataSource + source: BigqueryDataSource # Subset of physical schema column # Mapping of table schema ids to bfet id. scan_list: ScanList @@ -772,12 +818,8 @@ def session(self): @property 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 + 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 ) @property @@ -823,7 +865,9 @@ def variables_introduced(self) -> int: @property def row_count(self) -> typing.Optional[int]: - return self.source.n_rows + if self.source.sql_predicate is None and self.source.table.is_physically_stored: + return self.source.n_rows + return None @property def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: @@ -834,7 +878,7 @@ def remap_vars( ) -> ReadTableNode: new_scan_list = ScanList( tuple( - ScanItem(mappings.get(item.id, item.id), item.source_id) + ScanItem(mappings.get(item.id, item.id), item.dtype, item.source_id) for item in self.scan_list.items ) ) @@ -845,16 +889,17 @@ def remap_refs( ) -> ReadTableNode: return self - def pull_out_order(self): + def with_order_cols(self): # Maybe the ordering should be required to always be in the scan list, and then we won't need this? if self.source.ordering is None: - return self, RowOrdering() + return self, orderings.RowOrdering() order_cols = {col.sql for col in self.source.ordering.referenced_columns} scan_cols = {col.source_id for col in self.scan_list.items} 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 @@ -862,18 +907,10 @@ def pull_out_order(self): ] new_scan_list = ScanList(items=(*self.scan_list.items, *new_scan_cols)) new_order = self.source.ordering.remap_column_refs( - { - identifiers.ColumnId(item.source_id): item.id - for item in new_scan_list.items - }, + {identifiers.ColumnId(item.source_id): item.id for item in new_scan_cols}, allow_partial_bindings=True, ) - new_node = dataclasses.replace( - self, - scan_list=new_scan_list, - source=self.source.with_ordering(RowOrdering()), - ) - return new_node, new_order + return dataclasses.replace(self, scan_list=new_scan_list), new_order @dataclasses.dataclass(frozen=True, eq=False) @@ -998,8 +1035,7 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class OrderByNode(UnaryNode): by: Tuple[OrderingExpression, ...] - stable: bool = True - # This is an optimization, if true, can discard previous orderings, even if doing a stable sort + # This is an optimization, if true, can discard previous orderings. # might be a total ordering even if false is_total_order: bool = False @@ -1209,7 +1245,6 @@ def _validate(self): for expression, _ in self.assignments: # throws TypeError if invalid _ = 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) @@ -1302,9 +1337,7 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class AggregateNode(UnaryNode): - aggregations: typing.Tuple[ - typing.Tuple[agg_expressions.Aggregation, identifiers.ColumnId], ... - ] + aggregations: typing.Tuple[typing.Tuple[ex.Aggregation, identifiers.ColumnId], ...] by_column_ids: typing.Tuple[ex.DerefOp, ...] = tuple([]) order_by: Tuple[OrderingExpression, ...] = () dropna: bool = True @@ -1327,7 +1360,9 @@ def fields(self) -> Sequence[Field]: agg_items = ( Field( id, - ex.bind_schema_fields(agg, self.child.field_by_id).output_type, + bigframes.dtypes.dtype_for_etype( + agg.output_type(self.child.field_by_id) + ), nullable=True, ) for agg, id in self.aggregations @@ -1402,25 +1437,19 @@ def remap_refs( @dataclasses.dataclass(frozen=True, eq=False) class WindowOpNode(UnaryNode, AdditiveNode): - agg_exprs: tuple[ColumnDef, ...] # must be analytic/aggregation op + expression: ex.Aggregation 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 - 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 + assert ( + not self.window_spec.is_row_bounded + ) or self.expression.op.implicitly_inherits_order + assert all(ref in self.child.ids for ref in self.expression.column_references) @property def non_local(self) -> bool: @@ -1428,7 +1457,7 @@ def non_local(self) -> bool: @property def fields(self) -> Sequence[Field]: - return sequences.ChainedSequence(self.child.fields, self.added_fields) + return sequences.ChainedSequence(self.child.fields, (self.added_field,)) @property def variables_introduced(self) -> int: @@ -1436,54 +1465,50 @@ def variables_introduced(self) -> int: @property def added_fields(self) -> Tuple[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 - ) + return (self.added_field,) @property def relation_ops_created(self) -> int: - return 2 + # 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 @property def row_count(self) -> Optional[int]: return self.child.row_count + @functools.cached_property + def added_field(self) -> Field: + input_fields = self.child.field_by_id + # TODO: Determine if output could be non-null + return Field( + self.output_name, + bigframes.dtypes.dtype_for_etype(self.expression.output_type(input_fields)), + ) + @property def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return tuple(field.id for field in self.added_fields) + return (self.output_name,) @property def consumed_ids(self) -> COLUMN_SET: - return frozenset(self.ids) + return frozenset( + set(self.ids).difference([self.output_name]).union(self.referenced_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(ids_for_aggs) + .union(self.expression.column_references) .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? - 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 - ) + op_inherits_order = ( + not self.expression.op.order_independent + ) and self.expression.op.implicitly_inherits_order # 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 @@ -1494,10 +1519,7 @@ def additive_base(self) -> BigFrameNode: @property def _node_expressions(self): - return ( - *(cdef.expression for cdef in self.agg_exprs), - *self.window_spec.expressions, - ) + return (self.expression, *self.window_spec.expressions) def replace_additive_base(self, node: BigFrameNode) -> WindowOpNode: return dataclasses.replace(self, child=node) @@ -1506,11 +1528,7 @@ def remap_vars( self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] ) -> WindowOpNode: return dataclasses.replace( - self, - agg_exprs=tuple( - ColumnDef(cdef.expression, mappings.get(cdef.id, cdef.id)) - for cdef in self.agg_exprs - ), + self, output_name=mappings.get(self.output_name, self.output_name) ) def remap_refs( @@ -1518,14 +1536,8 @@ def remap_refs( ) -> WindowOpNode: return dataclasses.replace( self, - agg_exprs=tuple( - ColumnDef( - cdef.expression.remap_column_refs( - mappings, allow_partial_bindings=True - ), - cdef.id, - ) - for cdef in self.agg_exprs + expression=self.expression.remap_column_refs( + mappings, allow_partial_bindings=True ), window_spec=self.window_spec.remap_column_refs( mappings, allow_partial_bindings=True @@ -1662,7 +1674,7 @@ class ResultNode(UnaryNode): # TODO: CTE definitions def _validate(self): - for ref, _ in self.output_cols: + for ref, name in self.output_cols: assert ref.id in self.child.ids @property @@ -1720,39 +1732,6 @@ def _node_expressions(self): return tuple(ref for ref, _ in self.output_cols) -@dataclasses.dataclass(frozen=True, eq=False) -class CteNode(UnaryNode): - """ - Semantically a no-op, used to indicate shared subtrees and act as optimization boundary. - """ - - @property - def fields(self) -> Sequence[Field]: - return self.child.fields - - @property - def variables_introduced(self) -> int: - return 0 - - @property - def row_count(self) -> Optional[int]: - return self.child.row_count - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return () - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> CteNode: - return self - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> CteNode: - return self - - # Tree operators def top_down( root: BigFrameNode, diff --git a/bigframes/core/ordered_sets.py b/bigframes/core/ordered_sets.py deleted file mode 100644 index b09c0ce8e0d..00000000000 --- a/bigframes/core/ordered_sets.py +++ /dev/null @@ -1,139 +0,0 @@ -# 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 7ad8b6f567a..2fc7573b219 100644 --- a/bigframes/core/ordering.py +++ b/bigframes/core/ordering.py @@ -14,10 +14,10 @@ from __future__ import annotations -import typing from dataclasses import dataclass, field from enum import Enum -from typing import Callable, Mapping, Optional, Sequence, Set, Union +import typing +from typing import Mapping, Optional, Sequence, Set, Union import bigframes.core.expression as expression import bigframes.core.identifiers as ids @@ -82,15 +82,6 @@ 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) @@ -341,13 +332,15 @@ def remap_column_refs( def join( self, other: TotalOrdering, - ) -> TotalOrdering: ... + ) -> TotalOrdering: + ... @typing.overload def join( self, other: RowOrdering, - ) -> RowOrdering: ... + ) -> RowOrdering: + ... def join( self, diff --git a/bigframes/core/pruning.py b/bigframes/core/pruning.py index f98b8eb5d58..2542c8b6f00 100644 --- a/bigframes/core/pruning.py +++ b/bigframes/core/pruning.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import TYPE_CHECKING, Set +from typing import Set, TYPE_CHECKING import bigframes.core.expression as ex import bigframes.core.identifiers as ids diff --git a/bigframes/core/py_expressions.py b/bigframes/core/py_expressions.py deleted file mode 100644 index da937677050..00000000000 --- a/bigframes/core/py_expressions.py +++ /dev/null @@ -1,595 +0,0 @@ -# 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 itertools -import operator -from types import ModuleType -from typing import Callable, Hashable, Mapping, Optional, Tuple - -import bigframes.core.agg_expressions as agg_exprs -import bigframes.operations.aggregations as agg_ops -import bigframes.operations.python_op_maps as python_op_maps -from bigframes import dtypes -from bigframes.core import identifiers -from bigframes.core import window_spec as window_specs -from bigframes.core.expression import ( - Expression, - OpExpression, - ScalarConstantExpression, - UnboundVariableExpression, - const, - deref, -) -from bigframes.operations import ( - NUMPY_TO_BINOP, - NUMPY_TO_OP, - ScalarOp, - generic_ops, - numeric_ops, -) - -_CALLABLE_TO_OP = { - **NUMPY_TO_OP, - **NUMPY_TO_BINOP, -} - -_BUILTIN_CALLABLES = { - str: generic_ops.AsTypeOp(dtypes.STRING_DTYPE), - abs: numeric_ops.abs_op, -} - - -@dataclasses.dataclass(frozen=True) -class GetAttr(Expression): - input: Expression - attr: str - - @property - def column_references( - self, - ) -> Tuple[identifiers.ColumnId, ...]: - return self.input.column_references - - @property - def free_variables(self) -> tuple[Hashable, ...]: - return self.input.free_variables - - @property - def is_const(self) -> bool: - return False - - @property - def children(self): - return (self.input,) - - @property - def nullable(self) -> bool: - return True - - @property - def is_resolved(self) -> bool: - return False - - @property - def output_type(self) -> dtypes.ExpressionType: - raise ValueError(f"Type of expression {self} has not been fixed.") - - @property - def is_bijective(self) -> bool: - # TODO: Mark individual functions as bijective? - return False - - @property - def deterministic(self) -> bool: - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - new_input = t(self.input) - if new_input != self.input: - return dataclasses.replace(self, input=new_input) - return self - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> GetAttr: - return GetAttr( - self.input.bind_variables( - bindings, allow_partial_bindings=allow_partial_bindings - ), - self.attr, - ) - - def bind_refs( - self, - bindings: Mapping[identifiers.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> GetAttr: - return GetAttr( - self.input.bind_refs( - bindings, allow_partial_bindings=allow_partial_bindings - ), - self.attr, - ) - - -@dataclasses.dataclass(frozen=True) -class GetItem(Expression): - input: Expression - key: Expression - - @property - def column_references(self) -> Tuple[identifiers.ColumnId, ...]: - return self.input.column_references + self.key.column_references - - @property - def free_variables(self) -> tuple[Hashable, ...]: - return self.input.free_variables + self.key.free_variables - - @property - def is_const(self) -> bool: - return False - - @property - def children(self): - return (self.input, self.key) - - @property - def nullable(self) -> bool: - return True - - @property - def is_resolved(self) -> bool: - return False - - @property - def output_type(self) -> dtypes.ExpressionType: - raise ValueError(f"Type of expression {self} has not been fixed.") - - @property - def is_bijective(self) -> bool: - return False - - @property - def deterministic(self) -> bool: - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - new_input = t(self.input) - new_key = t(self.key) - if new_input != self.input or new_key != self.key: - return dataclasses.replace(self, input=new_input, key=new_key) - return self - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> GetItem: - return GetItem( - self.input.bind_variables( - bindings, allow_partial_bindings=allow_partial_bindings - ), - self.key.bind_variables( - bindings, allow_partial_bindings=allow_partial_bindings - ), - ) - - def bind_refs( - self, - bindings: Mapping[identifiers.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> GetItem: - return GetItem( - self.input.bind_refs( - bindings, allow_partial_bindings=allow_partial_bindings - ), - self.key.bind_refs(bindings, allow_partial_bindings=allow_partial_bindings), - ) - - -@dataclasses.dataclass(frozen=True) -class Module(Expression): - """An expression representing a module reference.""" - - module: ModuleType - - @property - def is_const(self) -> bool: - return True - - @property - def column_references(self) -> Tuple[identifiers.ColumnId, ...]: - return () - - @property - def nullable(self) -> bool: - return True # type: ignore - - @property - def is_resolved(self) -> bool: - return False - - @property - def output_type(self) -> dtypes.ExpressionType: - raise ValueError("Module expression does not have a type.") - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> Expression: - return self - - def bind_refs( - self, - bindings: Mapping[identifiers.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> Module: - return self - - @property - def is_bijective(self) -> bool: - # () <-> value - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - return self - - -@dataclasses.dataclass(frozen=True) -class PyObject(Expression): - """An expression representing a module reference.""" - - value: Hashable - - @property - def is_const(self) -> bool: - return True - - @property - def column_references(self) -> Tuple[identifiers.ColumnId, ...]: - return () - - @property - def nullable(self) -> bool: - return True # type: ignore - - @property - def is_resolved(self) -> bool: - return False - - @property - def output_type(self) -> dtypes.ExpressionType: - raise ValueError("PyObject expression does not have a type.") - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> Expression: - return self - - def bind_refs( - self, - bindings: Mapping[identifiers.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> PyObject: - return self - - @property - def is_bijective(self) -> bool: - # () <-> value - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - return self - - -@dataclasses.dataclass(frozen=True) -class Call(Expression): - """An expression representing a scalar constant.""" - - # TODO: Further constrain? - callable: Expression - inputs: Tuple[Expression, ...] - - @property - def column_references( - self, - ) -> Tuple[identifiers.ColumnId, ...]: - return tuple( - itertools.chain.from_iterable( - map(lambda x: x.column_references, self.children) - ) - ) - - @property - def free_variables(self) -> tuple[Hashable, ...]: - return tuple( - itertools.chain.from_iterable( - map(lambda x: x.free_variables, self.children) - ) - ) - - @property - def is_const(self) -> bool: - return False - - @property - def children(self): - return (self.callable, *self.inputs) - - @property - def nullable(self) -> bool: - return True - - @property - def is_resolved(self) -> bool: - return False - - @property - def output_type(self) -> dtypes.ExpressionType: - raise ValueError(f"Type of expression {self} has not been fixed.") - - @property - def is_bijective(self) -> bool: - # TODO: Mark individual functions as bijective? - return False - - @property - def deterministic(self) -> bool: - return True - - def transform_children(self, t: Callable[[Expression], Expression]) -> Expression: - return dataclasses.replace( - self, - callable=t(self.callable), - inputs=tuple(t(input) for input in self.inputs), - ) - - def bind_variables( - self, - bindings: Mapping[Hashable, Expression], - allow_partial_bindings: bool = False, - ) -> Call: - return Call( - callable=self.callable.bind_variables( - bindings, allow_partial_bindings=allow_partial_bindings - ), - inputs=tuple( - input.bind_variables( - bindings, allow_partial_bindings=allow_partial_bindings - ) - for input in self.inputs - ), - ) - - def bind_refs( - self, - bindings: Mapping[identifiers.ColumnId, Expression], - allow_partial_bindings: bool = False, - ) -> Call: - return Call( - callable=self.callable.bind_refs( - bindings, allow_partial_bindings=allow_partial_bindings - ), - inputs=tuple( - input.bind_refs(bindings, allow_partial_bindings=allow_partial_bindings) - for input in self.inputs - ), - ) - - -# TODO: Mode that resolves free variable attrs as columns -def resolve_py_exprs( - expression: Expression, - series_arg: Optional[str] = None, - series_attrs: Mapping[Hashable, str] | None = None, - col_series_args: Mapping[str, str] | None = None, - window_spec: window_specs.WindowSpec | None = None, -) -> Expression: - """ - Replace all PyObject, attribute, item, and call expressions bottom-up. - - This function translates unresolved python expressions (like GetAttr, GetItem, - Call, PyObject) into resolved BigQuery expressions (like OpExpression, DerefOp, - Aggregation, ScalarConstantExpression) by binding them to the specified context. - - Args: - expression: The unresolved python expression to translate. - series_arg: The name of the parameter representing the row (for row-wise UDFs like - apply axis=1) or the DataFrame group (for DataFrameGroupBy.apply). - series_attrs: A mapping of attribute/item names to column IDs for the series_arg. - When GetAttr(series_arg, attr) or GetItem(series_arg, key) is encountered, - it is resolved to deref(column_id). - col_series_args: A mapping of parameter names to column IDs for parameters that - represent a single Series/column directly (for SeriesGroupBy.apply). When - UnboundVariableExpression(arg_name) is encountered and arg_name is in - col_series_args, it is resolved directly to deref(column_id). - window_spec: Optional window spec. When provided, aggregations inside calls will - be converted to WindowExpression using this spec. - - Returns: - The resolved BigQuery Expression. - """ - - def resolve_expr_if_call(expr: Expression) -> Expression: - if isinstance(expr, Call): - return resolve_call(expr, window_spec=window_spec) - return expr - - def resolve_attrs(expr: Expression) -> Expression: - if isinstance(expr, GetAttr): - return _resolve_getattr(expr, series_arg, series_attrs) - if isinstance(expr, GetItem): - return _resolve_getitem(expr, series_arg, series_attrs, col_series_args) - return expr - - def resolve_series_var(expr: Expression) -> Expression: - if ( - col_series_args is not None - and isinstance(expr, UnboundVariableExpression) - and isinstance(expr.id, str) - and expr.id in col_series_args - ): - return deref(col_series_args[expr.id]) - return expr - - def resolve_pyobjs(expr: Expression) -> Expression: - if isinstance(expr, PyObject): - return const(expr.value) - return expr - - wo_calls = expression.bottom_up(resolve_expr_if_call) - wo_attrs = wo_calls.bottom_up(resolve_attrs) - wo_vars = wo_attrs.bottom_up(resolve_series_var) - return wo_vars.bottom_up(resolve_pyobjs) - - -def _resolve_getattr( - expression: GetAttr, - series_arg: Optional[str], - series_attrs: Mapping[Hashable, str] | None, -) -> Expression: - if isinstance(expression.input, Module): - # resolves things like Math.pi - return PyObject(getattr(expression.input.module, expression.attr)) - # Resolve attribute access on the series/row argument - if ( - series_arg is not None - and series_attrs is not None - and isinstance(expression.input, UnboundVariableExpression) - and expression.input.id == series_arg - and expression.attr in series_attrs - ): - return deref(series_attrs[expression.attr]) - return expression - - -def _resolve_getitem( - expression: GetItem, - series_arg: Optional[str], - series_attrs: Mapping[Hashable, str] | None, - col_series_args: Mapping[str, str] | None, -) -> Expression: - # Resolve subscript/item access on the series/row argument - key_val = None - if isinstance(expression.key, PyObject): - key_val = expression.key.value - elif isinstance(expression.key, ScalarConstantExpression): - key_val = expression.key.value - - is_series_var = ( - series_arg is not None - and isinstance(expression.input, UnboundVariableExpression) - and expression.input.id == series_arg - ) - - if is_series_var and series_attrs is not None: - if key_val is None: - raise NotImplementedError("Dynamic column lookup is not supported.") - if key_val in series_attrs: - return deref(series_attrs[key_val]) - else: - raise KeyError(f"Column '{key_val}' not found.") - - is_columnar_var = ( - col_series_args is not None - and isinstance(expression.input, UnboundVariableExpression) - and expression.input.id in col_series_args - ) - - if is_columnar_var: - raise NotImplementedError( - "Subscripting a Series/column is not supported in this UDF context." - ) - - if key_val is not None: - if isinstance(key_val, (str, int)): - return OpExpression(generic_ops.GetItemOp(key_val), (expression.input,)) - else: - raise NotImplementedError( - f"Subscript key of type '{type(key_val).__name__}' is not supported." - ) - else: - return OpExpression( - generic_ops.DynamicGetItemOp(), (expression.input, expression.key) - ) - - -def resolve_call( - call: Call, window_spec: window_specs.WindowSpec | None = None -) -> Expression: - callable = call.callable - if isinstance(callable, GetAttr): - attr = callable.attr - if isinstance(callable.input, Module): - fn = getattr(callable.input.module, attr) - if fn in python_op_maps.PYTHON_TO_BIGFRAMES: - op = python_op_maps.PYTHON_TO_BIGFRAMES[fn] - return OpExpression(op, call.inputs) - if fn in _CALLABLE_TO_OP: - op = _CALLABLE_TO_OP[fn] - return OpExpression(op, call.inputs) - elif isinstance(callable.input, PyObject) and isinstance( - callable.input.value, type - ): - fn = getattr(callable.input.value, attr, None) - if fn in python_op_maps.PYTHON_TO_BIGFRAMES: - op = python_op_maps.PYTHON_TO_BIGFRAMES[fn] - return OpExpression(op, call.inputs) - else: - # Method call on an expression (e.g. df.col.sum() or s.mean()) - try: - agg_op, _ = agg_ops.lookup_agg_func(attr) - - if isinstance(agg_op, agg_ops.UnaryAggregateOp): - agg_expr: agg_exprs.Aggregation = agg_exprs.UnaryAggregation( - agg_op, callable.input - ) - if window_spec is not None: - return agg_exprs.WindowExpression(agg_expr, window_spec) - return agg_expr - elif isinstance(agg_op, agg_ops.NullaryAggregateOp): - agg_expr = agg_exprs.NullaryAggregation(agg_op) - if window_spec is not None: - return agg_exprs.WindowExpression(agg_expr, window_spec) - return agg_expr - except ValueError: - pass - - # Support common scalar method calls on Series/expressions - if (method_op := python_op_maps.SERIES_METHOD_TO_OP.get(attr)) is not None: - if isinstance(method_op, ScalarOp): - return OpExpression(method_op, (callable.input,)) - - elif isinstance(callable, PyObject): - if callable.value == operator.getitem: - return GetItem(call.inputs[0], call.inputs[1]) - if isinstance(callable.value, ScalarOp): - return OpExpression(callable.value, call.inputs) - if callable.value in python_op_maps.PYTHON_TO_BIGFRAMES: - op = python_op_maps.PYTHON_TO_BIGFRAMES[callable.value] # type: ignore - return OpExpression(op, call.inputs) - if callable.value in _BUILTIN_CALLABLES: - return OpExpression(_BUILTIN_CALLABLES[callable.value], call.inputs) - - raise NotImplementedError( - f"No implementation available for call expression: {call}" - ) diff --git a/bigframes/core/pyarrow_utils.py b/bigframes/core/pyarrow_utils.py index bdbb220b953..b9dc2ea2b39 100644 --- a/bigframes/core/pyarrow_utils.py +++ b/bigframes/core/pyarrow_utils.py @@ -84,13 +84,6 @@ def cast_batch(batch: pa.RecordBatch, schema: pa.Schema) -> pa.RecordBatch: ) -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]: diff --git a/bigframes/core/pyformat.py b/bigframes/core/pyformat.py index dfd91ba1ad0..eab86dc6293 100644 --- a/bigframes/core/pyformat.py +++ b/bigframes/core/pyformat.py @@ -21,15 +21,15 @@ import string import typing -from typing import Any, Optional, Tuple, Union +from typing import Any, Optional, Union import google.cloud.bigquery import pandas -import bigframes.core.local_data -import bigframes.session 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, @@ -39,11 +39,7 @@ def _table_to_sql(table: _BQ_TABLE_TYPES) -> str: - # BiglakeIcebergTable IDs have 4 parts. BigFrames packs catalog.namespace - # into the dataset_id. - dataset_parts = table.dataset_id.split(".") - dataset_sql = ".".join(f"`{part}`" for part in dataset_parts) - return f"`{table.project}`.{dataset_sql}.`{table.table_id}`" + return f"`{table.project}`.`{table.dataset_id}`.`{table.table_id}`" def _pandas_df_to_sql_dry_run(pd_df: pandas.DataFrame) -> str: @@ -93,7 +89,7 @@ def _field_to_template_value( dry_run: bool = False, ) -> str: """Convert value to something embeddable in a SQL string.""" - import bigframes.core.compile.sqlglot.sql as sql # Avoid circular imports + import bigframes.core.sql # Avoid circular imports import bigframes.dataframe # Avoid circular imports _validate_type(name, value) @@ -106,43 +102,22 @@ def _field_to_template_value( return _pandas_df_to_sql(value, session=session, dry_run=dry_run, name=name) if isinstance(value, bigframes.dataframe.DataFrame): - import bigframes.core.bq_data as bq_data - import bigframes.core.nodes as nodes - - # TODO(b/493608478): Remove this workaround for BigLake/Iceberg tables, - # which cannot currently be used in views, once a fix rolls out. - def is_biglake( - node: nodes.BigFrameNode, child_results: Tuple[bool, ...] - ) -> bool: - if isinstance(node, nodes.ReadTableNode): - return isinstance(node.source.table, bq_data.BiglakeIcebergTable) - return any(child_results) - - contains_biglake = value._block.expr.node.reduce_up(is_biglake) - - if contains_biglake: - sql_query, _, _ = value._to_sql_query(include_index=True) - return f"({sql_query})" - return _table_to_sql(value._to_placeholder_table(dry_run=dry_run)) - if isinstance(value, str): - return value - - return sql.to_sql(sql.literal(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 - import bigframes.dtypes # 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) - + bigframes.dtypes.SUPPORTED_LITERAL_TYPES + + typing.get_args(bigframes.core.sql.SIMPLE_LITERAL_TYPES) + (bigframes.dataframe.DataFrame,) + (pandas.DataFrame,) ) @@ -162,160 +137,6 @@ def _parse_fields(sql_template: str) -> list[str]: ] -def _is_escaped_open_brace(sql_template: str, idx: int, literal_char: str) -> bool: - """Checks if the character at idx in sql_template is an escaped open brace '{{'.""" - return sql_template[idx : idx + 2] == "{{" and literal_char == "{" - - -def _is_escaped_close_brace(sql_template: str, idx: int, literal_char: str) -> bool: - """Checks if the character at idx in sql_template is an escaped close brace '}}'.""" - return sql_template[idx : idx + 2] == "}}" and literal_char == "}" - - -def _consume_literal(sql_template: str, current_idx: int, literal_text: str) -> int: - """Advances current_idx past literal_text in sql_template, accounting for escaped braces. - - A **literal** (or literal text) is the static part of the template string that - does not contain formatting placeholders. The string.Formatter parser resolves - escaped braces ('{{' and '}}') into single braces ('{' and '}') in its output - literal_text. - - This function aligns the resolved literal_text back to the original - sql_template by consuming 2 characters from sql_template ('{{' or '}}') for - every single escaped brace character in literal_text, and 1 character for - everything else. - - Returns: - int: the advanced current_idx in sql_template. - """ - lit_idx = 0 - while lit_idx < len(literal_text): - if _is_escaped_open_brace(sql_template, current_idx, literal_text[lit_idx]): - current_idx += 2 - lit_idx += 1 - elif _is_escaped_close_brace(sql_template, current_idx, literal_text[lit_idx]): - current_idx += 2 - lit_idx += 1 - elif ( - current_idx < len(sql_template) - and sql_template[current_idx] == literal_text[lit_idx] - ): - current_idx += 1 - lit_idx += 1 - else: - raise RuntimeError( - "Internal error: failed to align parsed SQL template with original query. " - f"Expected {literal_text[lit_idx]!r} at position {current_idx} in template, " - f"but found {sql_template[current_idx : current_idx + 2]!r}." - ) - return current_idx - - -def _is_escaped_brace(sql_template: str, idx: int) -> bool: - """Checks if the template has an escaped brace ('{{' or '}}') at the given index.""" - return sql_template[idx : idx + 2] in ("{{", "}}") - - -def _advance_past_field(sql_template: str, current_idx: int) -> int: - """Advances current_idx past the format field starting at current_idx. - - A **field** (or replacement field) is a placeholder in the template enclosed - in braces (e.g., "{my_var}" or "{json_col: { "val": 1 } }"). - - This function assumes current_idx points to the opening '{' of a field. - It parses forward, tracking nested braces to find the matching closing '}' - that terminates the field, while ignoring escaped braces ('{{' and '}}') - which do not affect the nesting level. - - Returns: - int: the index immediately after the closing '}' of the field. - """ - assert sql_template[current_idx] == "{" - brace_count = 1 - current_idx += 1 # past '{' - - while brace_count > 0 and current_idx < len(sql_template): - if _is_escaped_brace(sql_template, current_idx): - current_idx += 2 - elif sql_template[current_idx] == "{": - brace_count += 1 - current_idx += 1 - elif sql_template[current_idx] == "}": - brace_count -= 1 - current_idx += 1 - else: - current_idx += 1 - - return current_idx - - -def _find_all_field_positions(sql_template: str) -> dict[tuple[str, int], int]: - """Finds the character positions of all fields in the sql_template. - - Returns: - dict: a dict mapping (field_name, occurrence_idx) to character index. - """ - formatter = string.Formatter() - current_idx = 0 - seen_counts: dict[str, int] = {} - positions: dict[tuple[str, int], int] = {} - - for literal_text, field_name, _, _ in formatter.parse(sql_template): - current_idx = _consume_literal(sql_template, current_idx, literal_text) - - if field_name is not None: - occurrence_idx = seen_counts.get(field_name, 0) - seen_counts[field_name] = occurrence_idx + 1 - - positions[(field_name, occurrence_idx)] = current_idx - - current_idx = _advance_past_field(sql_template, current_idx) - - return positions - - -def get_error_context_at_pos(sql_template: str, pos: int) -> str: - """Create a helpful 'pointer' to where the problematic position is - in the original SQL. - - This should make the error message a lot friendlier, by providing more - context towards the problematic syntax. - """ - if pos == -1: - return "" - - lines = sql_template.splitlines(keepends=True) - - char_count = 0 - target_line_idx = -1 - for i, line in enumerate(lines): - if char_count <= pos < char_count + len(line): - target_line_idx = i - break - char_count += len(line) - - if target_line_idx == -1: - return "" - - col_offset = pos - char_count - - context_lines = [] - start_line = max(0, target_line_idx - 2) - end_line = min(len(lines), target_line_idx + 3) - - for i in range(start_line, end_line): - line_num = i + 1 - line_content = lines[i].rstrip("\r\n") - if i == target_line_idx: - context_lines.append(f"{line_num:4d}: {line_content}") - indent = 6 + col_offset - context_lines.append(" " * indent + "^") - else: - context_lines.append(f"{line_num:4d}: {line_content}") - - return "\n".join(context_lines) - - def pyformat( sql_template: str, *, @@ -339,36 +160,13 @@ def pyformat( Raises: TypeError: if a referenced variable is not of a supported type. - ValueError: - if a referenced variable is not found (KeyError is caught and raised - as ValueError with context). + KeyError: if a referenced variable is not found. """ - try: - fields = _parse_fields(sql_template) - except ValueError as e: - raise ValueError( - "Failed to parse SQL template. " - "Did you mean to escape '{' and '}' by doubling them?\n" - f"Error details: {e}" - ) from e - - format_kwargs: dict[str, str] = {} - seen_counts: dict[str, int] = {} - for name in fields: - seen_counts[name] = seen_counts.get(name, 0) + 1 - try: - value = pyformat_args[name] - except KeyError as e: - positions = _find_all_field_positions(sql_template) - occurrence_idx = seen_counts[name] - 1 - pos = positions.get((name, occurrence_idx), -1) - context = get_error_context_at_pos(sql_template, pos) - raise ValueError( - f"Undetected variable {name!r} in SQL template. " - "Did you mean to escape '{' and '}' by doubling them?\n" - f"{context}" - ) from e + 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 ) diff --git a/bigframes/core/reshape/api.py b/bigframes/core/reshape/api.py index adb33427f94..56dbdae77eb 100644 --- a/bigframes/core/reshape/api.py +++ b/bigframes/core/reshape/api.py @@ -15,7 +15,6 @@ 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", "crosstab"] +__all__ = ["concat", "get_dummies", "merge", "cut", "qcut"] diff --git a/bigframes/core/reshape/concat.py b/bigframes/core/reshape/concat.py index cc81319ae68..a42488cbe80 100644 --- a/bigframes/core/reshape/concat.py +++ b/bigframes/core/reshape/concat.py @@ -31,7 +31,8 @@ def concat( axis: typing.Literal["index", 0] = ..., join=..., ignore_index=..., -) -> bigframes.series.Series: ... +) -> bigframes.series.Series: + ... @typing.overload @@ -41,7 +42,8 @@ def concat( axis: typing.Literal["index", 0] = ..., join=..., ignore_index=..., -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @typing.overload @@ -51,7 +53,8 @@ def concat( axis: typing.Literal["columns", 1], join=..., ignore_index=..., -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @typing.overload @@ -61,7 +64,8 @@ def concat( axis=..., join=..., ignore_index=..., -) -> Union[bigframes.dataframe.DataFrame, bigframes.series.Series]: ... +) -> Union[bigframes.dataframe.DataFrame, bigframes.series.Series]: + ... def concat( diff --git a/bigframes/core/reshape/merge.py b/bigframes/core/reshape/merge.py index 55e3abe0c6e..e1750d5c7a1 100644 --- a/bigframes/core/reshape/merge.py +++ b/bigframes/core/reshape/merge.py @@ -18,18 +18,20 @@ from __future__ import annotations -from typing import Literal, Sequence +import typing +from typing import Literal, Optional import bigframes_vendored.pandas.core.reshape.merge as vendored_pandas_merge -from bigframes_vendored import constants -from bigframes import dataframe, series -from bigframes.core import blocks, utils +# Avoid cirular imports. +if typing.TYPE_CHECKING: + import bigframes.dataframe + import bigframes.series def merge( - left: dataframe.DataFrame, - right: dataframe.DataFrame, + left: bigframes.dataframe.DataFrame, + right: bigframes.dataframe.DataFrame, how: Literal[ "inner", "left", @@ -37,60 +39,33 @@ def merge( "right", "cross", ] = "inner", - on: blocks.Label | Sequence[blocks.Label] | None = None, + 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, + left_on: Optional[str] = None, + right_on: Optional[str] = None, sort: bool = False, suffixes: tuple[str, str] = ("_x", "_y"), -) -> dataframe.DataFrame: +) -> bigframes.dataframe.DataFrame: left = _validate_operand(left) right = _validate_operand(right) - 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, + return left.merge( right, - on, + how=how, + 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: dataframe.DataFrame | series.Series, -) -> dataframe.DataFrame: + obj: bigframes.dataframe.DataFrame | bigframes.series.Series, +) -> bigframes.dataframe.DataFrame: import bigframes.dataframe import bigframes.series @@ -104,115 +79,3 @@ 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 deleted file mode 100644 index 082948728f6..00000000000 --- a/bigframes/core/reshape/pivot.py +++ /dev/null @@ -1,88 +0,0 @@ -# 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 TYPE_CHECKING, Optional - -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 61f869f2797..86ccf524087 100644 --- a/bigframes/core/reshape/tile.py +++ b/bigframes/core/reshape/tile.py @@ -15,13 +15,11 @@ from __future__ import annotations import typing -from typing import TYPE_CHECKING, Optional 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 @@ -32,12 +30,9 @@ import bigframes.operations.aggregations as agg_ops import bigframes.series -if TYPE_CHECKING: - import bigframes.session - def cut( - x, + x: bigframes.series.Series, bins: typing.Union[ int, pd.IntervalIndex, @@ -46,7 +41,6 @@ def cut( *, right: typing.Optional[bool] = True, labels: typing.Union[typing.Iterable[str], bool, None] = None, - session: Optional[bigframes.session.Session] = None, ) -> bigframes.series.Series: if ( labels is not None @@ -66,12 +60,9 @@ def cut( f"but found {type(list(labels)[0])}. {constants.FEEDBACK_LINK}" ) - if len(x) == 0: + if x.size == 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.") diff --git a/bigframes/core/rewrite/__init__.py b/bigframes/core/rewrite/__init__.py index ae4b142b1a4..4e5295ae9d3 100644 --- a/bigframes/core/rewrite/__init__.py +++ b/bigframes/core/rewrite/__init__.py @@ -12,14 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from bigframes.core.rewrite.as_sql import as_sql_nodes -from bigframes.core.rewrite.ctes import extract_ctes 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.nullity import simplify_join -from bigframes.core.rewrite.order import bake_order, defer_order, pull_out_order +from bigframes.core.rewrite.order import bake_order, defer_order from bigframes.core.rewrite.pruning import column_pruning from bigframes.core.rewrite.scan_reduction import ( try_reduce_to_local_scan, @@ -28,16 +25,9 @@ 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.udfs import lower_udfs -from bigframes.core.rewrite.windows import ( - pull_out_window_order, - rewrite_range_rolling, - simplify_complex_windows, -) +from bigframes.core.rewrite.windows import pull_out_window_order, rewrite_range_rolling __all__ = [ - "as_sql_nodes", - "extract_ctes", "legacy_join_as_projection", "try_row_join", "rewrite_slice", @@ -50,12 +40,8 @@ "rewrite_range_rolling", "try_reduce_to_table_scan", "bake_order", - "pull_out_order", "try_reduce_to_local_scan", "fold_row_counts", "pull_out_window_order", "defer_selection", - "simplify_complex_windows", - "lower_udfs", - "simplify_join", ] diff --git a/bigframes/core/rewrite/as_sql.py b/bigframes/core/rewrite/as_sql.py deleted file mode 100644 index eb823d1fed1..00000000000 --- a/bigframes/core/rewrite/as_sql.py +++ /dev/null @@ -1,308 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 itertools -from typing import Optional, Sequence, Union - -import bigframes.core.rewrite -from bigframes.core import ( - agg_expressions, - expression, - guid, - identifiers, - nodes, - ordering, - sql_nodes, -) - - -def _limit(select: sql_nodes.SqlSelectNode, limit: int) -> sql_nodes.SqlSelectNode: - new_limit = limit if select.limit is None else min([select.limit, limit]) - return dataclasses.replace(select, limit=new_limit) - - -def _try_sort( - select: sql_nodes.SqlSelectNode, sort_by: Sequence[ordering.OrderingExpression] -) -> Optional[sql_nodes.SqlSelectNode]: - new_order_exprs = [] - for sort_expr in sort_by: - new_expr = _try_bind( - sort_expr.scalar_expression, select.get_id_mapping(), analytic_allowed=False - ) - if new_expr is None: - return None - new_order_exprs.append( - dataclasses.replace(sort_expr, scalar_expression=new_expr) - ) - return dataclasses.replace(select, sorting=tuple(new_order_exprs)) - - -def _sort( - node: nodes.BigFrameNode, sort_by: Sequence[ordering.OrderingExpression] -) -> sql_nodes.SqlSelectNode: - if isinstance(node, sql_nodes.SqlSelectNode): - merged = _try_sort(node, sort_by) - if merged: - return merged - result = _try_sort(_create_noop_select(node), sort_by) - assert result is not None - return result - - -def _try_bind( - expr: expression.Expression, - bindings: dict[identifiers.ColumnId, expression.Expression], - analytic_allowed: bool = False, # means block binding to an analytic even if original is scalar -) -> Optional[expression.Expression]: - if not expr.is_scalar_expr or not analytic_allowed: - for ref in expr.column_references: - if ref in bindings and not bindings[ref].is_scalar_expr: - return None - return expr.bind_refs(bindings) - - -def _try_add_cdefs( - select: sql_nodes.SqlSelectNode, cdefs: Sequence[nodes.ColumnDef] -) -> Optional[sql_nodes.SqlSelectNode]: - # TODO: add up complexity measure while inlining refs - new_defs = [] - for cdef in cdefs: - cdef_expr = cdef.expression - merged_expr = _try_bind( - cdef_expr, select.get_id_mapping(), analytic_allowed=True - ) - if merged_expr is None: - return None - new_defs.append(nodes.ColumnDef(merged_expr, cdef.id)) - - return dataclasses.replace(select, selections=(*select.selections, *new_defs)) - - -def _add_cdefs( - node: nodes.BigFrameNode, cdefs: Sequence[nodes.ColumnDef] -) -> sql_nodes.SqlSelectNode: - if isinstance(node, sql_nodes.SqlSelectNode): - merged = _try_add_cdefs(node, cdefs) - if merged: - return merged - # Otherwise, wrap the child in a SELECT and add the columns - result = _try_add_cdefs(_create_noop_select(node), cdefs) - assert result is not None - return result - - -def _try_add_filter( - select: sql_nodes.SqlSelectNode, predicates: Sequence[expression.Expression] -) -> Optional[sql_nodes.SqlSelectNode]: - # Filter implicitly happens first, so merging it into ths select will modify non-scalar col expressions - if not all(cdef.expression.is_scalar_expr for cdef in select.selections): - return None - if not all( - sort_expr.scalar_expression.is_scalar_expr for sort_expr in select.sorting - ): - return None - # Constraint: filters can only be merged if they are scalar expression after binding - new_predicates = [] - # bind variables, merge predicates - for predicate in predicates: - merged_pred = _try_bind(predicate, select.get_id_mapping()) - if not merged_pred: - return None - new_predicates.append(merged_pred) - return dataclasses.replace(select, predicates=(*select.predicates, *new_predicates)) - - -def _add_filter( - node: nodes.BigFrameNode, predicates: Sequence[expression.Expression] -) -> sql_nodes.SqlSelectNode: - if isinstance(node, sql_nodes.SqlSelectNode): - result = _try_add_filter(node, predicates) - if result: - return result - new_node = _try_add_filter(_create_noop_select(node), predicates) - assert new_node is not None - return new_node - - -def _create_noop_select(node: nodes.BigFrameNode) -> sql_nodes.SqlSelectNode: - return sql_nodes.SqlSelectNode( - node, - selections=tuple( - nodes.ColumnDef(expression.ResolvedDerefOp.from_field(field), field.id) - for field in node.fields - ), - ) - - -def _try_remap_select_cols( - select: sql_nodes.SqlSelectNode, cols: Sequence[nodes.AliasedRef] -): - new_defs = [] - for aliased_ref in cols: - new_defs.append( - nodes.ColumnDef(select.get_id_mapping()[aliased_ref.ref.id], aliased_ref.id) - ) - - return dataclasses.replace(select, selections=tuple(new_defs)) - - -def _remap_select_cols(node: nodes.BigFrameNode, cols: Sequence[nodes.AliasedRef]): - if isinstance(node, sql_nodes.SqlSelectNode): - result = _try_remap_select_cols(node, cols) - if result: - return result - new_node = _try_remap_select_cols(_create_noop_select(node), cols) - assert new_node is not None - return new_node - - -def _get_added_cdefs(node: Union[nodes.ProjectionNode, nodes.WindowOpNode]): - # TODO: InNode - if isinstance(node, nodes.ProjectionNode): - return tuple(nodes.ColumnDef(expr, id) for expr, id in node.assignments) - if isinstance(node, nodes.WindowOpNode): - new_cdefs = [] - for cdef in node.agg_exprs: - assert isinstance(cdef.expression, agg_expressions.Aggregation) - window_expr = agg_expressions.WindowExpression( - cdef.expression, node.window_spec - ) - # TODO: we probably should do this as another step - rewritten_window_expr = bigframes.core.rewrite.simplify_complex_windows( - window_expr - ) - new_cdefs.append(nodes.ColumnDef(rewritten_window_expr, cdef.id)) - return tuple(new_cdefs) - else: - raise ValueError(f"Unexpected node type: {type(node)}") - - -def _as_sql_node(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - # case one, can be converted to select - if isinstance(node, nodes.ReadTableNode): - leaf = sql_nodes.SqlDataSource(source=node.source) - mappings = [ - nodes.AliasedRef(expression.deref(scan_item.source_id), scan_item.id) - for scan_item in node.scan_list.items - ] - return _remap_select_cols(leaf, mappings) - elif isinstance(node, (nodes.ProjectionNode, nodes.WindowOpNode)): - cdefs = _get_added_cdefs(node) - return _add_cdefs(node.child, cdefs) - elif isinstance(node, (nodes.SelectionNode)): - return _remap_select_cols(node.child, node.input_output_pairs) - elif isinstance(node, nodes.FilterNode): - return _add_filter(node.child, [node.predicate]) - elif isinstance(node, nodes.ResultNode): - result = node.child - if node.order_by is not None: - result = _sort(result, node.order_by.all_ordering_columns) - result = _remap_select_cols( - result, - [ - nodes.AliasedRef(ref, identifiers.ColumnId(name)) - for ref, name in node.output_cols - ], - ) - if node.limit is not None: - result = _limit(result, node.limit) # type: ignore - return result - else: - return node - - -# In the future, we will have sql nodes for each of these node types. -_LOGICAL_NODE_TYPES_TO_WRAP = ( - nodes.ReadLocalNode, - nodes.ExplodeNode, - nodes.InNode, - nodes.AggregateNode, - nodes.FromRangeNode, - nodes.ConcatNode, - sql_nodes.SqlSelectNode, -) - - -def _insert_cte_markers(root: nodes.BigFrameNode) -> nodes.BigFrameNode: - # important not to wrap nodes that are already wrapped - wrapped_nodes = set( - node.child for node in root.unique_nodes() if isinstance(node, nodes.CteNode) - ) - # don't wrap child nodes of ConcatNode - union_child_nodes = set( - itertools.chain.from_iterable( - node.child_nodes - for node in root.unique_nodes() - if isinstance(node, nodes.ConcatNode) - ) - ) - - def maybe_insert_cte_marker(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - if node == root: - return node - if ( - isinstance(node, _LOGICAL_NODE_TYPES_TO_WRAP) - and node not in wrapped_nodes - and node not in union_child_nodes - ): - wrapped_nodes.add(node) - return nodes.CteNode(node) - return node - - return root.top_down(maybe_insert_cte_marker) - - -def _extract_ctes_to_with_expr( - root: nodes.BigFrameNode, uid_gen: guid.SequentialUIDGenerator -) -> nodes.BigFrameNode: - topological_ctes = list( - filter( - lambda n: isinstance(n, nodes.CteNode), - root.iter_nodes_topo(), - ) - ) - cte_names = tuple( - next(uid_gen.get_uid_stream("bfcte_")) for _ in range(len(topological_ctes)) - ) - - if len(topological_ctes) == 0: - return root - - mapping = { - cte_node: sql_nodes.SqlCteRefNode(cte_name, tuple(cte_node.fields)) - for cte_node, cte_name in zip(topological_ctes, cte_names) - } - - # Replace all CTEs with CTE references and wrap the new root in a WITH clause - return sql_nodes.SqlWithCtesNode( - root.top_down(lambda x: mapping.get(x, x)), - cte_names, - tuple( - # Mypy loses context that cte_node is a CteNode with a child attribute, despite the isinstance filter above. - cte_node.child.top_down(lambda x: mapping.get(x, x)) # type: ignore[attr-defined] - for cte_node in topological_ctes - ), - ) - - -def as_sql_nodes( - root: nodes.BigFrameNode, uid_gen: guid.SequentialUIDGenerator -) -> nodes.BigFrameNode: - root = nodes.bottom_up(root, _as_sql_node) - # Insert CTE markers to indicate where we want to split the query. - root = _insert_cte_markers(root) - root = _extract_ctes_to_with_expr(root, uid_gen) - return root diff --git a/bigframes/core/rewrite/ctes.py b/bigframes/core/rewrite/ctes.py deleted file mode 100644 index a5afd19bb35..00000000000 --- a/bigframes/core/rewrite/ctes.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 collections import defaultdict - -from bigframes.core import nodes - - -def extract_ctes(root: nodes.BigFrameNode) -> nodes.BigFrameNode: - # identify candidates - node_parents: dict[nodes.BigFrameNode, int] = defaultdict(int) - for parent in root.unique_nodes(): - for child in parent.child_nodes: - node_parents[child] += 1 - - # everywhere a multi-parent node is referenced, wrap it in a CTE node - def insert_cte_markers(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - def _add_cte_if_needed(child: nodes.BigFrameNode) -> nodes.BigFrameNode: - if node_parents[child] > 1: - return nodes.CteNode(child) - return child - - if isinstance(node, nodes.CteNode): - # don't re-wrap CTE nodes - return node - - return node.transform_children(_add_cte_if_needed) - - return root.top_down(insert_cte_markers) diff --git a/bigframes/core/rewrite/fold_row_count.py b/bigframes/core/rewrite/fold_row_count.py index cc0b818fb96..583343d68a7 100644 --- a/bigframes/core/rewrite/fold_row_count.py +++ b/bigframes/core/rewrite/fold_row_count.py @@ -15,6 +15,7 @@ import pyarrow as pa +from bigframes import dtypes from bigframes.core import local_data, nodes from bigframes.operations import aggregations @@ -33,7 +34,10 @@ def fold_row_counts(node: nodes.BigFrameNode) -> nodes.BigFrameNode: 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) + tuple( + nodes.ScanItem(out_id, dtypes.INT_DTYPE, "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 7b1d1d9a512..0093e183b43 100644 --- a/bigframes/core/rewrite/identifiers.py +++ b/bigframes/core/rewrite/identifiers.py @@ -18,37 +18,7 @@ from bigframes.core import identifiers, nodes -def _create_mapping_operator( - id_def_remapping_by_node: dict[ - nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId] - ], - id_ref_remapping_by_node: dict[ - nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId] - ], -): - """ - Builds a remapping operator that uses predefined local remappings for ids. - - Args: - id_remapping_by_node: A mapping from nodes to their local remappings. - - Returns: - A remapping operator. - """ - - def _mapping_operator(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - # Step 1: Get the local remapping for the current node. - local_def_remaps = id_def_remapping_by_node[node] - local_ref_remaps = id_ref_remapping_by_node[node] - - result = node.remap_vars(local_def_remaps) - result = result.remap_refs(local_ref_remaps) - - return result - - return _mapping_operator - - +# TODO: May as well just outright remove selection nodes in this process. def remap_variables( root: nodes.BigFrameNode, id_generator: typing.Iterator[identifiers.ColumnId], @@ -56,62 +26,32 @@ def remap_variables( nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId], ]: - """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. + """Remaps `ColumnId`s in the BFET to produce deterministic and sequential UIDs. - 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. + Note: this will convert a DAG to a tree. """ - # step 1: defined remappings for each individual unique node - # step 2: top down traversal to apply remappings (mappings are value-based, so bottom-up doesn't work) - - id_def_remaps: dict[ - nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId] - ] = {} - id_ref_remaps: dict[ - nodes.BigFrameNode, dict[identifiers.ColumnId, identifiers.ColumnId] - ] = {} - for node in root.iter_nodes_topo(): # bottom up - local_def_remaps = { - col_id: next(id_generator) for col_id in node.node_defined_ids - } - id_def_remaps[node] = local_def_remaps + child_replacement_map = dict() + ref_mapping = dict() + # Sequential ids are assigned bottom-up left-to-right + 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] + ) - local_ref_remaps = {} + with_new_refs = with_new_children.remap_refs(ref_mapping) - # InNode is special case as ID scope inherited purely from left side - inheriting_nodes = ( - [node.child_nodes[0]] - if isinstance(node, nodes.InNode) - else node.child_nodes - ) - for child in inheriting_nodes: # inherit ref and def mappings from children - if not child.defines_namespace: # these nodes represent new id spaces - local_ref_remaps.update( - { - old_id: new_id - for old_id, new_id in id_ref_remaps[child].items() - if old_id in child.ids - } - ) - local_ref_remaps.update(id_def_remaps[child]) - id_ref_remaps[node] = local_ref_remaps + 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() - # have to do top down to preserve node identities return ( - root.top_down(_create_mapping_operator(id_def_remaps, id_ref_remaps)), - # Only used by unit tests - { - old_id: (id_def_remaps[root] | id_ref_remaps[root])[old_id] - for old_id in root.ids - }, + with_new_vars, + node_var_mapping + if root.defines_namespace + else (ref_mapping | node_var_mapping), ) diff --git a/bigframes/core/rewrite/implicit_align.py b/bigframes/core/rewrite/implicit_align.py index ebd48d82362..1989b1a5430 100644 --- a/bigframes/core/rewrite/implicit_align.py +++ b/bigframes/core/rewrite/implicit_align.py @@ -15,11 +15,15 @@ import dataclasses import itertools -from typing import Optional, Sequence, Set, Tuple +from typing import cast, 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 = ( @@ -152,6 +156,35 @@ 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/legacy_align.py b/bigframes/core/rewrite/legacy_align.py index 26ee71d2ec5..573a7026e45 100644 --- a/bigframes/core/rewrite/legacy_align.py +++ b/bigframes/core/rewrite/legacy_align.py @@ -252,9 +252,9 @@ def legacy_join_as_projection( # Most likely because join keys didn't match return None merged = left_side.merge(right_side, how, join_keys, mappings) - assert merged is not None, ( - "Couldn't merge nodes. This shouldn't happen. Please share full stacktrace with the BigQuery DataFrames team at bigframes-feedback@google.com." - ) + assert ( + merged is not None + ), "Couldn't merge nodes. This shouldn't happen. Please share full stacktrace with the BigQuery DataFrames team at bigframes-feedback@google.com." return merged.expand() else: return None diff --git a/bigframes/core/rewrite/nullity.py b/bigframes/core/rewrite/nullity.py deleted file mode 100644 index 6307b12ec27..00000000000 --- a/bigframes/core/rewrite/nullity.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.core import nodes - - -def simplify_join(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - """Simplify a join node by removing nullity checks.""" - # if join conditions are provably non-null, we can set nulls_equal=False - if isinstance(node, nodes.JoinNode): - # even better, we can always make nulls_equal false, but wrap the join keys in coalesce - # to handle nulls correctly, this is more granular than the current implementation - for left_ref, right_ref in node.conditions: - if ( - node.left_child.field_by_id[left_ref.id].nullable - and node.right_child.field_by_id[right_ref.id].nullable - ): - return node - return dataclasses.replace(node, nulls_equal=False) - elif isinstance(node, nodes.InNode): - if ( - node.left_child.field_by_id[node.left_col.id].nullable - and node.right_child.fields[0].nullable - ): - return node - return dataclasses.replace(node, nulls_equal=False) - else: - return node diff --git a/bigframes/core/rewrite/op_lowering.py b/bigframes/core/rewrite/op_lowering.py index 013fc48c06a..6473c3bf8a0 100644 --- a/bigframes/core/rewrite/op_lowering.py +++ b/bigframes/core/rewrite/op_lowering.py @@ -16,17 +16,19 @@ import abc from typing import Sequence -import bigframes.operations as ops 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]: ... + def op(self) -> type[ops.ScalarOp]: + ... @abc.abstractmethod - def lower(self, expr: expression.OpExpression) -> expression.Expression: ... + def lower(self, expr: expression.OpExpression) -> expression.Expression: + ... def lower_ops( diff --git a/bigframes/core/rewrite/order.py b/bigframes/core/rewrite/order.py index b61fca82182..5b5fb107534 100644 --- a/bigframes/core/rewrite/order.py +++ b/bigframes/core/rewrite/order.py @@ -15,10 +15,10 @@ import functools from typing import Mapping, Tuple +from bigframes.core import expression, identifiers import bigframes.core.nodes import bigframes.core.ordering import bigframes.core.window_spec -from bigframes.core import agg_expressions, expression, identifiers from bigframes.operations import aggregations as agg_ops @@ -47,15 +47,6 @@ def bake_order( return node -def pull_out_order( - node: bigframes.core.nodes.BigFrameNode, -) -> Tuple[bigframes.core.nodes.BigFrameNode, bigframes.core.ordering.RowOrdering]: - import bigframes.core.rewrite.slices - - node = node.bottom_up(bigframes.core.rewrite.slices.rewrite_slice) - return _pull_up_order(node, order_root=True) - - # Makes ordering explicit in window definitions def _pull_up_order( root: bigframes.core.nodes.BigFrameNode, @@ -80,8 +71,7 @@ def pull_up_order_inner( child_result, child_order = pull_up_order_inner(node.child) return child_result, child_order.with_reverse() elif isinstance(node, bigframes.core.nodes.OrderByNode): - # unstable sorts don't care about previous order, total orders override previous order - if (not node.stable) or node.is_total_order: + if node.is_total_order: new_node = remove_order(node.child) else: new_node, child_order = pull_up_order_inner(node.child) @@ -116,10 +106,6 @@ def pull_up_order_inner( ), ) ) - elif not node.stable: - new_order = bigframes.core.ordering.RowOrdering( - ordering_value_columns=tuple(new_by), - ) else: assert child_order new_order = child_order.with_ordering_columns(new_by) @@ -162,7 +148,7 @@ def pull_up_order_inner( ) elif isinstance(node, bigframes.core.nodes.ReadTableNode): if node.source.ordering is not None: - return node.pull_out_order() + return node.with_order_cols() else: # No defined ordering return node, bigframes.core.ordering.RowOrdering() @@ -181,13 +167,14 @@ def pull_up_order_inner( ) else: # Otherwise we need to generate offsets - agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) - col_def = bigframes.core.nodes.ColumnDef(agg, node.col_id) + agg = bigframes.core.expression.NullaryAggregation( + agg_ops.RowNumberOp() + ) window_spec = bigframes.core.window_spec.unbound( ordering=tuple(child_order.all_ordering_columns) ) new_offsets_node = bigframes.core.nodes.WindowOpNode( - child_result, (col_def,), window_spec + child_result, agg, window_spec, node.col_id ) return ( new_offsets_node, @@ -281,7 +268,7 @@ def pull_up_order_inner( offsets_id ) return new_explode, child_order.join(inner_order) - raise ValueError(f"Unexpected node type {type(node).__name__}") + raise ValueError(f"Unexpected node: {node}") def pull_order_concat( node: bigframes.core.nodes.ConcatNode, @@ -300,13 +287,14 @@ def pull_order_concat( new_source, ((order_expression.scalar_expression, offsets_id),) ) else: - agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) + agg = bigframes.core.expression.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, (col_def,), window_spec + new_source, agg, window_spec, offsets_id ) new_source = bigframes.core.nodes.ProjectionNode( new_source, ((bigframes.core.expression.const(i), table_id),) @@ -435,11 +423,9 @@ def remove_order_strict( def rewrite_promote_offsets( node: bigframes.core.nodes.PromoteOffsetsNode, ) -> bigframes.core.nodes.WindowOpNode: - agg = agg_expressions.NullaryAggregation(agg_ops.RowNumberOp()) + agg = bigframes.core.expression.NullaryAggregation(agg_ops.RowNumberOp()) window_spec = bigframes.core.window_spec.unbound() - return bigframes.core.nodes.WindowOpNode( - node.child, (bigframes.core.nodes.ColumnDef(agg, node.col_id),), window_spec - ) + return bigframes.core.nodes.WindowOpNode(node.child, agg, window_spec, node.col_id) def rename_cols( diff --git a/bigframes/core/rewrite/pruning.py b/bigframes/core/rewrite/pruning.py index 29744d66cd6..8a07f0b87eb 100644 --- a/bigframes/core/rewrite/pruning.py +++ b/bigframes/core/rewrite/pruning.py @@ -13,7 +13,6 @@ # limitations under the License. import dataclasses import functools -import itertools import typing from bigframes.core import identifiers, nodes @@ -52,9 +51,22 @@ def prune_columns(node: nodes.BigFrameNode): if isinstance(node, nodes.SelectionNode): result = prune_selection_child(node) elif isinstance(node, nodes.ResultNode): - result = node.replace_child(prune_node(node.child, node.consumed_ids)) + result = node.replace_child( + prune_node( + node.child, node.consumed_ids or frozenset(list(node.child.ids)[0:1]) + ) + ) elif isinstance(node, nodes.AggregateNode): - result = node.replace_child(prune_node(node.child, node.consumed_ids)) + result = node.replace_child( + prune_node( + node.child, node.consumed_ids or frozenset(list(node.child.ids)[0:1]) + ) + ) + elif isinstance(node, nodes.InNode): + result = dataclasses.replace( + node, + right_child=prune_node(node.right_child, frozenset([node.right_col.id])), + ) else: result = node return result @@ -67,7 +79,7 @@ def prune_selection_child( # Important to check this first if list(selection.ids) == list(child.ids): - if all(ref.ref.id == ref.id for ref in selection.input_output_pairs): + if (ref.ref.id == ref.id for ref in selection.input_output_pairs): # selection is no-op so just remove it entirely return child @@ -75,7 +87,6 @@ def prune_selection_child( return selection.remap_refs( {id: ref.id for ref, id in child.input_output_pairs} ).replace_child(child.child) - 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) @@ -138,13 +149,9 @@ def prune_node( if not (set(node.ids) - ids): return node else: - # 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(nodes.AliasedRef.identity(id) for id in ids_to_keep), + tuple(nodes.AliasedRef.identity(id) for id in node.ids if id in ids), ) @@ -163,7 +170,8 @@ def prune_aggregate( def prune_leaf( node: nodes.BigFrameNode, used_cols: typing.AbstractSet[identifiers.ColumnId], -): ... +): + ... @prune_leaf.register diff --git a/bigframes/core/rewrite/scan_reduction.py b/bigframes/core/rewrite/scan_reduction.py index da609c1ea1f..b0729337e7b 100644 --- a/bigframes/core/rewrite/scan_reduction.py +++ b/bigframes/core/rewrite/scan_reduction.py @@ -15,8 +15,8 @@ import functools from typing import Optional -import bigframes.core.rewrite.slices from bigframes.core import nodes +import bigframes.core.rewrite.slices def try_reduce_to_table_scan(root: nodes.BigFrameNode) -> Optional[nodes.ReadTableNode]: diff --git a/bigframes/core/rewrite/schema_binding.py b/bigframes/core/rewrite/schema_binding.py index 14755d34a41..cbecf830351 100644 --- a/bigframes/core/rewrite/schema_binding.py +++ b/bigframes/core/rewrite/schema_binding.py @@ -15,8 +15,9 @@ import dataclasses import typing -from bigframes.core import agg_expressions, bigframe_node, nodes, ordering +from bigframes.core import bigframe_node from bigframes.core import expression as ex +from bigframes.core import nodes, ordering def bind_schema_to_tree( @@ -70,6 +71,9 @@ def bind_schema_to_node( left_col=ex.ResolvedDerefOp.from_field( node.left_child.field_by_id[node.left_col.id] ), + right_col=ex.ResolvedDerefOp.from_field( + node.right_child.field_by_id[node.right_col.id] + ), ) if isinstance(node, nodes.AggregateNode): @@ -106,13 +110,7 @@ def bind_schema_to_node( ) 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 - ), + expression=_bind_schema_to_aggregation_expr(node.expression, node.child), window_spec=window_spec, ) @@ -120,16 +118,16 @@ def bind_schema_to_node( def _bind_schema_to_aggregation_expr( - aggregation: agg_expressions.Aggregation, + aggregation: ex.Aggregation, child: bigframe_node.BigFrameNode, -) -> agg_expressions.Aggregation: - assert isinstance(aggregation, agg_expressions.Aggregation), ( - f"Expected Aggregation, got {type(aggregation)}" - ) +) -> ex.Aggregation: + assert isinstance( + aggregation, ex.Aggregation + ), f"Expected Aggregation, got {type(aggregation)}" - if isinstance(aggregation, agg_expressions.UnaryAggregation): + if isinstance(aggregation, ex.UnaryAggregation): return typing.cast( - agg_expressions.Aggregation, + ex.Aggregation, dataclasses.replace( aggregation, arg=typing.cast( @@ -138,9 +136,9 @@ def _bind_schema_to_aggregation_expr( ), ), ) - elif isinstance(aggregation, agg_expressions.BinaryAggregation): + elif isinstance(aggregation, ex.BinaryAggregation): return typing.cast( - agg_expressions.Aggregation, + ex.Aggregation, dataclasses.replace( aggregation, left=typing.cast( diff --git a/bigframes/core/rewrite/select_pullup.py b/bigframes/core/rewrite/select_pullup.py index a15aba7663f..3a2de1238b2 100644 --- a/bigframes/core/rewrite/select_pullup.py +++ b/bigframes/core/rewrite/select_pullup.py @@ -13,10 +13,9 @@ # limitations under the License. import dataclasses -import functools from typing import cast -from bigframes.core import expression, identifiers, nodes +from bigframes.core import expression, nodes def defer_selection( @@ -27,19 +26,12 @@ def defer_selection( 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) - ) + return nodes.bottom_up(root, pull_up_select) -def pull_up_select( - node: nodes.BigFrameNode, prefer_source_names: bool -) -> nodes.BigFrameNode: +def pull_up_select(node: nodes.BigFrameNode) -> 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 + return node if isinstance(node, nodes.JoinNode): return pull_up_selects_under_join(node) if isinstance(node, nodes.ConcatNode): @@ -50,31 +42,6 @@ def pull_up_select( 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: - new_scan_list = nodes.ScanList.from_items( - [ - nodes.ScanItem( - identifiers.ColumnId(scan_item.source_id), scan_item.source_id - ) - for scan_item in node.scan_list.items - ] - ) - 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): diff --git a/bigframes/core/rewrite/timedeltas.py b/bigframes/core/rewrite/timedeltas.py index 7544963732e..ea8e608a84c 100644 --- a/bigframes/core/rewrite/timedeltas.py +++ b/bigframes/core/rewrite/timedeltas.py @@ -20,7 +20,6 @@ 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 @@ -64,13 +63,11 @@ def rewrite_timedelta_expressions(root: nodes.BigFrameNode) -> nodes.BigFrameNod if isinstance(root, nodes.WindowOpNode): return nodes.WindowOpNode( root.child, - tuple( - nodes.ColumnDef( - _rewrite_aggregation(cdef.expression, root.schema), cdef.id - ) - for cdef in root.agg_exprs - ), + _rewrite_aggregation(root.expression, root.schema), root.window_spec, + root.output_name, + root.never_skip_nulls, + root.skip_reproject_unsafe, ) if isinstance(root, nodes.AggregateNode): @@ -114,8 +111,6 @@ def _rewrite_expressions(expr: ex.Expression, schema: schema.ArraySchema) -> _Ty def _rewrite_scalar_constant_expr(expr: ex.ScalarConstantExpression) -> _TypedExpr: - 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) @@ -206,12 +201,12 @@ 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 == dtypes.TIMEDELTA_DTYPE and dtypes.is_numeric(right.dtype): - return _TypedExpr.create_op_expr( - ops.timedelta_floor_op, _TypedExpr.create_op_expr(ops.div_op, left, right) - ) + return _TypedExpr.create_op_expr(ops.timedelta_floor_op, result) - return _TypedExpr.create_op_expr(ops.floordiv_op, left, right) + return result def _rewrite_to_timedelta_op(op: ops.ToTimedeltaOp, arg: _TypedExpr): @@ -224,33 +219,33 @@ def _rewrite_to_timedelta_op(op: ops.ToTimedeltaOp, arg: _TypedExpr): @functools.cache def _rewrite_aggregation( - aggregation: ex_types.Aggregation, schema: schema.ArraySchema -) -> ex_types.Aggregation: - if not isinstance(aggregation, ex_types.UnaryAggregation): + aggregation: ex.Aggregation, schema: schema.ArraySchema +) -> ex.Aggregation: + if not isinstance(aggregation, ex.UnaryAggregation): return aggregation if isinstance(aggregation.arg, ex.DerefOp): input_type = schema.get_type(aggregation.arg.id.sql) else: - input_type = aggregation.arg.output_type + input_type = aggregation.arg.dtype if isinstance(aggregation.op, aggs.DiffOp): if dtypes.is_datetime_like(input_type): - return ex_types.UnaryAggregation( + return ex.UnaryAggregation( aggs.TimeSeriesDiffOp(aggregation.op.periods), aggregation.arg ) elif input_type == dtypes.DATE_DTYPE: - return ex_types.UnaryAggregation( + return ex.UnaryAggregation( aggs.DateSeriesDiffOp(aggregation.op.periods), aggregation.arg ) if isinstance(aggregation.op, aggs.StdOp) and input_type == dtypes.TIMEDELTA_DTYPE: - return ex_types.UnaryAggregation( + return ex.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( + return ex.UnaryAggregation( aggs.MeanOp(should_floor_result=True), aggregation.arg ) @@ -258,7 +253,7 @@ def _rewrite_aggregation( isinstance(aggregation.op, aggs.QuantileOp) and input_type == dtypes.TIMEDELTA_DTYPE ): - return ex_types.UnaryAggregation( + return ex.UnaryAggregation( aggs.QuantileOp(q=aggregation.op.q, should_floor_result=True), aggregation.arg, ) diff --git a/bigframes/core/rewrite/udfs.py b/bigframes/core/rewrite/udfs.py deleted file mode 100644 index 286a9d9d940..00000000000 --- a/bigframes/core/rewrite/udfs.py +++ /dev/null @@ -1,46 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 bigframes.functions.udf_def as udf_def -import bigframes.operations as ops -from bigframes.core import bigframe_node, expression -from bigframes.core.rewrite import op_lowering - - -@dataclasses.dataclass -class LowerRemoteFunctionRule(op_lowering.OpLoweringRule): - @property - def op(self) -> type[ops.ScalarOp]: - return ops.RemoteFunctionOp - - def lower(self, expr: expression.OpExpression) -> expression.Expression: - assert isinstance(expr.op, ops.RemoteFunctionOp) - func_def = expr.op.function_def - devirtualized_expr = ops.RemoteFunctionOp( - func_def.with_devirtualize(), - ).as_expr(*expr.children) - if isinstance(func_def.signature.output, udf_def.VirtualListTypeV1): - return func_def.signature.output.out_expr(devirtualized_expr) - else: - return devirtualized_expr - - -UDF_LOWERING_RULES = (LowerRemoteFunctionRule(),) - - -def lower_udfs(root: bigframe_node.BigFrameNode) -> bigframe_node.BigFrameNode: - return op_lowering.lower_ops(root, rules=UDF_LOWERING_RULES) diff --git a/bigframes/core/rewrite/windows.py b/bigframes/core/rewrite/windows.py index 4d271a072d4..6e9ba0dd3d0 100644 --- a/bigframes/core/rewrite/windows.py +++ b/bigframes/core/rewrite/windows.py @@ -15,72 +15,9 @@ from __future__ import annotations import dataclasses -import functools -import itertools -import bigframes.dtypes from bigframes import operations as ops -from bigframes.core import ( - agg_expressions, - expression, - guid, - identifiers, - nodes, - ordering, -) -from bigframes.operations import aggregations as agg_ops - - -def simplify_complex_windows( - window_expr: agg_expressions.WindowExpression, -) -> expression.Expression: - result_expr: expression.Expression = window_expr - agg_expr = window_expr.analytic_expr - window_spec = window_expr.window - clauses: list[tuple[expression.Expression, expression.Expression]] = [] - if window_spec.min_periods and len(agg_expr.inputs) > 0: - if not agg_expr.op.nulls_count_for_min_values: - is_observation = ops.notnull_op.as_expr() - - # Most operations do not count NULL values towards min_periods - per_col_does_count = ( - ops.notnull_op.as_expr(input) for input in agg_expr.inputs - ) - # All inputs must be non-null for observation to count - is_observation = functools.reduce( - 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( - agg_expressions.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 = ops.notnull_op.as_expr(agg_expr.inputs[0]) - observation_count_expr = agg_expressions.WindowExpression( - agg_ops.count_op.as_expr(is_observation), - window_spec, - ) - clauses.append( - ( - ops.lt_op.as_expr( - observation_count_expr, expression.const(window_spec.min_periods) - ), - expression.const(None), - ) - ) - if clauses: - case_inputs = [ - *itertools.chain.from_iterable(clauses), - expression.const(True), - result_expr, - ] - result_expr = ops.CaseWhenOp().as_expr(*case_inputs) - return result_expr +from bigframes.core import guid, identifiers, nodes, ordering def rewrite_range_rolling(node: nodes.BigFrameNode) -> nodes.BigFrameNode: diff --git a/bigframes/core/schema.py b/bigframes/core/schema.py index ab30b9bff14..b1a77d1259d 100644 --- a/bigframes/core/schema.py +++ b/bigframes/core/schema.py @@ -14,10 +14,10 @@ from __future__ import annotations +from dataclasses import dataclass import functools import typing -from dataclasses import dataclass -from typing import Dict, Optional, Sequence +from typing import Dict, List, Sequence import google.cloud.bigquery import pyarrow @@ -35,21 +35,31 @@ class SchemaItem: @dataclass(frozen=True) class ArraySchema: - items: tuple[SchemaItem, ...] + items: Sequence[SchemaItem] def __iter__(self): yield from self.items + @classmethod + def from_bq_table( + cls, + table: google.cloud.bigquery.Table, + column_type_overrides: typing.Optional[ + typing.Dict[str, bigframes.dtypes.Dtype] + ] = None, + ): + return ArraySchema.from_bq_schema( + table.schema, column_type_overrides=column_type_overrides + ) + @classmethod def from_bq_schema( cls, - schema: Sequence[google.cloud.bigquery.SchemaField], - column_type_overrides: Optional[Dict[str, bigframes.dtypes.Dtype]] = None, - columns: Optional[Sequence[str]] = None, + schema: List[google.cloud.bigquery.SchemaField], + column_type_overrides: typing.Optional[ + Dict[str, bigframes.dtypes.Dtype] + ] = None, ): - if columns: - lookup = {field.name: field for field in schema} - schema = [lookup[col] for col in columns] if column_type_overrides is None: column_type_overrides = {} items = tuple( @@ -80,16 +90,14 @@ def to_bigquery( for item in self.items ) - def to_pyarrow(self, use_storage_types: bool = False) -> pyarrow.Schema: + def to_pyarrow(self) -> 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, - type=pa_type, + pa_type, nullable=not pyarrow.types.is_list(pa_type), ) ) diff --git a/bigframes/core/sentinels.py b/bigframes/core/sentinels.py deleted file mode 100644 index ff9913f7c6f..00000000000 --- a/bigframes/core/sentinels.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Sentinel values used throughout BigFrames.""" - -from __future__ import annotations - -import enum - - -class Sentinel(enum.Enum): - """Default values used throughout BigFrames.""" - - """Default value for an optional argument. - - When a parameter is set to this, that parameter is explicitly omitted - from the SQL text. This allows for NULL (None in Python) to be explicitly - passed in to optional parameters. - """ - ARGUMENT_DEFAULT = enum.auto() diff --git a/bigframes/core/sql.py b/bigframes/core/sql.py new file mode 100644 index 00000000000..ccd2a16ddcd --- /dev/null +++ b/bigframes/core/sql.py @@ -0,0 +1,248 @@ +# 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 + +""" +Utility functions for SQL construction. +""" + +import datetime +import decimal +import json +import math +from typing import cast, Collection, Iterable, Mapping, Optional, TYPE_CHECKING, Union + +import shapely.geometry.base # type: ignore + +import bigframes.core.compile.googlesql as googlesql + +if TYPE_CHECKING: + import google.cloud.bigquery as bigquery + + 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: 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" + elif isinstance(value, str): + # Single quoting seems to work nicer with ibis than double quoting + return f"'{googlesql._escape_chars(value)}'" + elif isinstance(value, bytes): + return repr(value) + elif isinstance(value, (bool, int)): + return str(value) + elif isinstance(value, float): + # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#floating_point_literals + if math.isnan(value): + return 'CAST("nan" as FLOAT)' + if value == math.inf: + return 'CAST("+inf" as FLOAT)' + if value == -math.inf: + return 'CAST("-inf" as FLOAT)' + return str(value) + # Check datetime first as it is a subclass of date + elif isinstance(value, datetime.datetime): + if value.tzinfo is None: + return f"DATETIME('{value.isoformat()}')" + else: + return f"TIMESTAMP('{value.isoformat()}')" + elif isinstance(value, datetime.date): + return f"DATE('{value.isoformat()}')" + elif isinstance(value, datetime.time): + return f"TIME(DATETIME('1970-01-01 {value.isoformat()}'))" + 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}") + + +def multi_literal(*values: str): + literal_strings = [simple_literal(i) for i in values] + return "(" + ", ".join(literal_strings) + ")" + + +def cast_as_string(column_name: str) -> str: + """Return a string representing string casting of a column.""" + + return googlesql.Cast( + googlesql.ColumnExpression(column_name), googlesql.DataType.STRING + ).sql() + + +def to_json_string(column_name: str) -> str: + """Return a string representing JSON version of a column.""" + + return f"TO_JSON_STRING({googlesql.identifier(column_name)})" + + +def csv(values: Iterable[str]) -> str: + """Return a string of comma separated values.""" + return ", ".join(values) + + +def infix_op(opname: str, left_arg: str, right_arg: str): + # Maybe should add parentheses?? + return f"{left_arg} {opname} {right_arg}" + + +def is_distinct_sql(columns: Iterable[str], table_ref: bigquery.TableReference) -> str: + is_unique_sql = f"""WITH full_table AS ( + {googlesql.Select().from_(table_ref).select(columns).sql()} + ), + distinct_table AS ( + {googlesql.Select().from_(table_ref).select(columns, distinct=True).sql()} + ) + + SELECT (SELECT COUNT(*) FROM full_table) AS `total_count`, + (SELECT COUNT(*) FROM distinct_table) AS `distinct_count` + """ + return is_unique_sql + + +def ordering_clause( + ordering: Iterable[bigframes.core.ordering.OrderingExpression], +) -> str: + import bigframes.core.expression + + parts = [] + for col_ref in ordering: + asc_desc = "ASC" if col_ref.direction.is_ascending else "DESC" + null_clause = "NULLS LAST" if col_ref.na_last else "NULLS FIRST" + ordering_expr = col_ref.scalar_expression + # We don't know how to compile scalar expressions in isolation + if ordering_expr.is_const: + # Probably shouldn't have constants in ordering definition, but best to ignore if somehow they end up here. + continue + assert isinstance(ordering_expr, bigframes.core.expression.DerefOp) + part = f"`{ordering_expr.id.sql}` {asc_desc} {null_clause}" + parts.append(part) + return f"ORDER BY {' ,'.join(parts)}" + + +def create_vector_index_ddl( + *, + replace: bool, + index_name: str, + table_name: str, + column_name: str, + stored_column_names: Collection[str], + options: Mapping[str, Union[str | int | bool | float]] = {}, +) -> str: + """Encode the VECTOR INDEX statement for BigQuery Vector Search.""" + + if replace: + create = "CREATE OR REPLACE VECTOR INDEX " + else: + create = "CREATE VECTOR INDEX IF NOT EXISTS " + + if len(stored_column_names) > 0: + escaped_stored = [ + f"{googlesql.identifier(name)}" for name in stored_column_names + ] + storing = f"STORING({', '.join(escaped_stored)}) " + else: + storing = "" + + rendered_options = ", ".join( + [ + f"{option_name} = {simple_literal(option_value)}" + for option_name, option_value in options.items() + ] + ) + + return f""" + {create} {googlesql.identifier(index_name)} + ON {googlesql.identifier(table_name)}({googlesql.identifier(column_name)}) + {storing} + OPTIONS({rendered_options}); + """ + + +def create_vector_search_sql( + sql_string: str, + *, + base_table: str, + column_to_search: str, + query_column_to_search: Optional[str] = None, + top_k: Optional[int] = None, + distance_type: Optional[str] = None, + options: Optional[Mapping[str, Union[str | int | bool | float]]] = None, +) -> str: + """Encode the VECTOR SEARCH statement for BigQuery Vector Search.""" + + vector_search_args = [ + f"TABLE {googlesql.identifier(cast(str, base_table))}", + f"{simple_literal(column_to_search)}", + f"({sql_string})", + ] + + if query_column_to_search is not None: + vector_search_args.append( + f"query_column_to_search => {simple_literal(query_column_to_search)}" + ) + + if top_k is not None: + vector_search_args.append(f"top_k=> {simple_literal(top_k)}") + + if distance_type is not None: + vector_search_args.append(f"distance_type => {simple_literal(distance_type)}") + + if options is not None: + vector_search_args.append( + f"options => {simple_literal(json.dumps(options, indent=None))}" + ) + + args_str = ",\n".join(vector_search_args) + return f""" + SELECT + query.*, + base.*, + distance, + FROM VECTOR_SEARCH({args_str}) + """ diff --git a/bigframes/core/sql/__init__.py b/bigframes/core/sql/__init__.py deleted file mode 100644 index b28d5921695..00000000000 --- a/bigframes/core/sql/__init__.py +++ /dev/null @@ -1,244 +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. - -""" -Utility functions for SQL construction. -""" - -from __future__ import annotations - -import json -from typing import ( - TYPE_CHECKING, - Any, - Collection, - Iterable, - Mapping, - Optional, - Union, - cast, -) - -import bigframes_vendored.sqlglot.expressions as sge - -from bigframes.core.compile.sqlglot import sql - -if TYPE_CHECKING: - import google.cloud.bigquery as bigquery - - 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 - - -def identifier(name: str) -> str: - if len(name) > 256: - raise ValueError("Identifier must be less than 256 characters") - return f"`{escape_chars(name)}`" - - -def escape_chars(value: str): - """Escapes all special characters""" - # TODO: Reuse literal's escaping logic instead of re-implementing it here. - # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#string_and_bytes_literals - trans_table = str.maketrans( - { - "\a": r"\a", - "\b": r"\b", - "\f": r"\f", - "\n": r"\n", - "\r": r"\r", - "\t": r"\t", - "\v": r"\v", - "\\": r"\\", - "?": r"\?", - '"': r"\"", - "'": r"\'", - "`": r"\`", - } - ) - return value.translate(trans_table) - - -def multi_literal(*values: Any): - literal_strings = [sql.to_sql(sql.literal(i)) for i in values] - return "(" + ", ".join(literal_strings) + ")" - - -def cast_as_string(column_name: str) -> str: - """Return a string representing string casting of a column.""" - - return sge.Cast(this=sge.to_identifier(column_name, quoted=True), to="STRING").sql( - dialect="bigquery" - ) - - -def to_json_string(column_name: str) -> str: - """Return a string representing JSON version of a column.""" - - return f"TO_JSON_STRING({sql.to_sql(sql.identifier(column_name))})" - - -def csv(values: Iterable[str]) -> str: - """Return a string of comma separated values.""" - return ", ".join(values) - - -def infix_op(opname: str, left_arg: str, right_arg: str): - # Maybe should add parentheses?? - return f"{left_arg} {opname} {right_arg}" - - -def is_distinct_sql(columns: Iterable[str], table_ref: bigquery.TableReference) -> str: - table_expr = sge.Table( - this=sge.Identifier(this=table_ref.table_id, quoted=True), - db=sge.Identifier(this=table_ref.dataset_id, quoted=True), - catalog=sge.Identifier(this=table_ref.project, quoted=True), - ) - to_select = [sge.to_identifier(col, quoted=True) for col in columns] - - full_table_sql = ( - sge.Select().select(*to_select).from_(table_expr).sql(dialect="bigquery") - ) - distinct_table_sql = ( - sge.Select() - .select(*to_select) - .distinct() - .from_(table_expr) - .sql(dialect="bigquery") - ) - - is_unique_sql = f"""WITH full_table AS ( - {full_table_sql} - ), - distinct_table AS ( - {distinct_table_sql} - ) - - SELECT (SELECT COUNT(*) FROM full_table) AS `total_count`, - (SELECT COUNT(*) FROM distinct_table) AS `distinct_count` - """ - return is_unique_sql - - -def ordering_clause( - ordering: Iterable[bigframes.core.ordering.OrderingExpression], -) -> str: - import bigframes.core.expression - - parts = [] - for col_ref in ordering: - asc_desc = "ASC" if col_ref.direction.is_ascending else "DESC" - null_clause = "NULLS LAST" if col_ref.na_last else "NULLS FIRST" - ordering_expr = col_ref.scalar_expression - # We don't know how to compile scalar expressions in isolation - if ordering_expr.is_const: - # Probably shouldn't have constants in ordering definition, but best to ignore if somehow they end up here. - continue - assert isinstance(ordering_expr, bigframes.core.expression.DerefOp) - part = f"`{ordering_expr.id.sql}` {asc_desc} {null_clause}" - parts.append(part) - return f"ORDER BY {' ,'.join(parts)}" - - -def create_vector_index_ddl( - *, - replace: bool, - index_name: str, - table_name: str, - column_name: str, - stored_column_names: Collection[str], - options: Mapping[str, Union[str | int | bool | float]] = {}, -) -> str: - """Encode the VECTOR INDEX statement for BigQuery Vector Search.""" - - if replace: - create = "CREATE OR REPLACE VECTOR INDEX " - else: - create = "CREATE VECTOR INDEX IF NOT EXISTS " - - if len(stored_column_names) > 0: - escaped_stored = [ - f"{sql.to_sql(sql.identifier(name))}" for name in stored_column_names - ] - storing = f"STORING({', '.join(escaped_stored)}) " - else: - storing = "" - - rendered_options = ", ".join( - [ - f"{option_name} = {sql.to_sql(sql.literal(option_value))}" - for option_name, option_value in options.items() - ] - ) - - return f""" - {create} {sql.to_sql(sql.identifier(index_name))} - ON {sql.to_sql(sql.identifier(table_name))}({sql.to_sql(sql.identifier(column_name))}) - {storing} - OPTIONS({rendered_options}); - """ - - -def create_vector_search_sql( - sql_string: str, - *, - base_table: str, - column_to_search: str, - query_column_to_search: Optional[str] = None, - top_k: Optional[int] = None, - distance_type: Optional[str] = None, - options: Optional[Mapping[str, Union[str | int | bool | float]]] = None, -) -> str: - """Encode the VECTOR SEARCH statement for BigQuery Vector Search.""" - - vector_search_args = [ - f"TABLE {sql.to_sql(sql.identifier(cast(str, base_table)))}", - f"{sql.to_sql(sql.literal(column_to_search))}", - f"({sql_string})", - ] - - if query_column_to_search is not None: - vector_search_args.append( - f"query_column_to_search => {sql.to_sql(sql.literal(query_column_to_search))}" - ) - - if top_k is not None: - vector_search_args.append(f"top_k=> {sql.to_sql(sql.literal(top_k))}") - - if distance_type is not None: - vector_search_args.append( - f"distance_type => {sql.to_sql(sql.literal(distance_type))}" - ) - - if options is not None: - vector_search_args.append( - f"options => {sql.to_sql(sql.literal(json.dumps(options, indent=None)))}" - ) - - args_str = ",\n".join(vector_search_args) - return f""" - SELECT - query.*, - base.*, - distance, - FROM VECTOR_SEARCH({args_str}) - """ diff --git a/bigframes/core/sql/ml.py b/bigframes/core/sql/ml.py deleted file mode 100644 index 8d971e6c3e8..00000000000 --- a/bigframes/core/sql/ml.py +++ /dev/null @@ -1,309 +0,0 @@ -# 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, List, Mapping, Optional, Union - -import bigframes.core.col as col -from bigframes.core.compile.sqlglot import sql as sg_sql -from bigframes.core.compile.sqlglot.expression_compiler import expression_compiler - - -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, "col.Expression"]] - ] = 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}{sg_sql.to_sql(sg_sql.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 {sg_sql.to_sql(sg_sql.identifier(connection_name))}\n" - - # [OPTIONS(model_option_list)] - if options: - rendered_options = [] - for option_name, option_value in options.items(): - if isinstance(option_value, col.Expression): - sg_expr = expression_compiler.compile_expression(option_value._value) - rendered_val = sg_sql.to_sql(sg_expr) - elif 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 = sg_sql.to_sql(sg_sql.literal(list(option_value))) - else: - rendered_val = sg_sql.to_sql(sg_sql.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, Mapping[str, str], List[str], Mapping[str, Any]], - ], -) -> str: - if not struct_options: - return "" - return f", {sg_sql.to_sql(sg_sql.literal(struct_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 {sg_sql.to_sql(sg_sql.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: Dict[str, Union[str, int, float, bool]] = {} - 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 {sg_sql.to_sql(sg_sql.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 {sg_sql.to_sql(sg_sql.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: Dict[str, Union[str, int, float, bool]] = {} - if class_level_explain is not None: - struct_options["class_level_explain"] = class_level_explain - - sql = f"SELECT * FROM ML.GLOBAL_EXPLAIN(MODEL {sg_sql.to_sql(sg_sql.identifier(model_name))}" - sql += _build_struct_sql(struct_options) - sql += ")\n" - return sql - - -def transform( - model_name: str, - table: str, -) -> str: - """Encode the ML.TRANSFORM statement. - See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-transform for reference. - """ - sql = f"SELECT * FROM ML.TRANSFORM(MODEL {sg_sql.to_sql(sg_sql.identifier(model_name))}, ({table}))\n" - return sql - - -def generate_text( - model_name: str, - table: str, - *, - temperature: Optional[float] = None, - max_output_tokens: Optional[int] = None, - top_k: Optional[int] = None, - top_p: Optional[float] = None, - flatten_json_output: Optional[bool] = None, - stop_sequences: Optional[List[str]] = None, - ground_with_google_search: Optional[bool] = None, - request_type: Optional[str] = None, -) -> str: - """Encode the ML.GENERATE_TEXT statement. - See https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-generate-text for reference. - """ - struct_options: Dict[ - str, - Union[str, int, float, bool, Mapping[str, str], List[str], Mapping[str, Any]], - ] = {} - if temperature is not None: - struct_options["temperature"] = temperature - if max_output_tokens is not None: - struct_options["max_output_tokens"] = max_output_tokens - if top_k is not None: - struct_options["top_k"] = top_k - if top_p is not None: - struct_options["top_p"] = top_p - if flatten_json_output is not None: - struct_options["flatten_json_output"] = flatten_json_output - if stop_sequences is not None: - struct_options["stop_sequences"] = stop_sequences - if ground_with_google_search is not None: - struct_options["ground_with_google_search"] = ground_with_google_search - if request_type is not None: - struct_options["request_type"] = request_type - - sql = f"SELECT * FROM ML.GENERATE_TEXT(MODEL {sg_sql.to_sql(sg_sql.identifier(model_name))}, ({table})" - sql += _build_struct_sql(struct_options) - sql += ")\n" - return sql - - -def get_insights( - model_name: str, -) -> str: - """Encode the ML.GET_INSIGHTS statement. - See https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-get-insights for reference. - """ - sql = f"SELECT * FROM ML.GET_INSIGHTS(MODEL {sg_sql.to_sql(sg_sql.identifier(model_name))})\n" - return sql - - -def generate_embedding( - model_name: str, - table: str, - *, - flatten_json_output: Optional[bool] = None, - task_type: Optional[str] = None, - output_dimensionality: Optional[int] = None, -) -> str: - """Encode the ML.GENERATE_EMBEDDING statement. - See https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-generate-embedding for reference. - """ - struct_options: Dict[ - str, - Union[str, int, float, bool, Mapping[str, str], List[str], Mapping[str, Any]], - ] = {} - if flatten_json_output is not None: - struct_options["flatten_json_output"] = flatten_json_output - if task_type is not None: - struct_options["task_type"] = task_type - if output_dimensionality is not None: - struct_options["output_dimensionality"] = output_dimensionality - - sql = f"SELECT * FROM ML.GENERATE_EMBEDDING(MODEL {sg_sql.to_sql(sg_sql.identifier(model_name))}, ({table})" - sql += _build_struct_sql(struct_options) - sql += ")\n" - return sql diff --git a/bigframes/core/sql_nodes.py b/bigframes/core/sql_nodes.py deleted file mode 100644 index c7a05a082f2..00000000000 --- a/bigframes/core/sql_nodes.py +++ /dev/null @@ -1,300 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Callable, Mapping, Optional, Sequence, Tuple - -import bigframes.core.expression as ex -import bigframes.dtypes -from bigframes.core import bq_data, identifiers, nodes -from bigframes.core.ordering import OrderingExpression - -# SQL Nodes are generally terminal, so don't support rich transformation methods -# like remap_vars, remap_refs, etc. -# Still, fields should be defined on them, as typing info is still used for -# dispatching some operators in the emitter, and for validation. - - -# TODO: Join node, union node -@dataclasses.dataclass(frozen=True) -class SqlDataSource(nodes.LeafNode): - source: bq_data.BigqueryDataSource - - @functools.cached_property - def fields(self) -> Sequence[nodes.Field]: - return tuple( - nodes.Field( - identifiers.ColumnId(source_id), - self.source.schema.get_type(source_id), - self.source.table.schema_by_id[source_id].is_nullable, - ) - for source_id in self.source.schema.names - ) - - @property - def is_star_selection(self) -> bool: - return tuple(self.source.schema.names) == tuple( - field.name for field in self.source.table.physical_schema - ) - - @property - def variables_introduced(self) -> int: - # This operation only renames variables, doesn't actually create new ones - return 0 - - @property - def defines_namespace(self) -> bool: - return True - - @property - def explicitly_ordered(self) -> bool: - return False - - @property - def order_ambiguous(self) -> bool: - return True - - @property - def row_count(self) -> Optional[int]: - return self.source.n_rows - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return tuple(self.ids) - - @property - def consumed_ids(self): - return () - - @property - def _node_expressions(self): - return () - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlSelectNode: - raise NotImplementedError() - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlSelectNode: - raise NotImplementedError() # type: ignore - - -@dataclasses.dataclass(frozen=True) -class SqlWithCtesNode(nodes.BigFrameNode): - # def, name pairs - child: nodes.BigFrameNode - cte_names: tuple[str, ...] - cte_defs: tuple[nodes.BigFrameNode, ...] - - @property - def child_nodes(self) -> Sequence[nodes.BigFrameNode]: - return (self.child, *self.cte_defs) - - @property - def fields(self) -> Sequence[nodes.Field]: - return self.child.fields - - @property - def variables_introduced(self) -> int: - # This operation only renames variables, doesn't actually create new ones - return 0 - - @property - def defines_namespace(self) -> bool: - return True - - @property - def explicitly_ordered(self) -> bool: - return False - - @property - def order_ambiguous(self) -> bool: - return True - - @property - def row_count(self) -> Optional[int]: - return self.child.row_count - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return tuple(self.ids) - - @property - def consumed_ids(self): - return () - - @property - def _node_expressions(self): - return () - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlWithCtesNode: - raise NotImplementedError() - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlWithCtesNode: - raise NotImplementedError() # type: ignore - - def transform_children( - self, transform: Callable[[nodes.BigFrameNode], nodes.BigFrameNode] - ) -> SqlWithCtesNode: - return SqlWithCtesNode( - transform(self.child), - self.cte_names, - tuple(transform(cte) for cte in self.cte_defs), - ) - - -@dataclasses.dataclass(frozen=True) -class SqlCteRefNode(nodes.LeafNode): - cte_name: str - cte_schema: tuple[nodes.Field, ...] - - @property - def fields(self) -> Sequence[nodes.Field]: - return self.cte_schema - - @property - def variables_introduced(self) -> int: - # This operation only renames variables, doesn't actually create new ones - return 0 - - @property - def defines_namespace(self) -> bool: - return True - - @property - def explicitly_ordered(self) -> bool: - return False - - @property - def order_ambiguous(self) -> bool: - return True - - @property - def row_count(self) -> Optional[int]: - raise NotImplementedError() - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return tuple(self.ids) - - @property - def consumed_ids(self): - return () - - @property - def _node_expressions(self): - return () - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlCteRefNode: - raise NotImplementedError() - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlCteRefNode: - raise NotImplementedError() # type: ignore - - -@dataclasses.dataclass(frozen=True) -class SqlSelectNode(nodes.UnaryNode): - selections: tuple[nodes.ColumnDef, ...] = () - predicates: tuple[ex.Expression, ...] = () - sorting: tuple[OrderingExpression, ...] = () - limit: Optional[int] = None - - @functools.cached_property - def fields(self) -> Sequence[nodes.Field]: - fields = [] - for cdef in self.selections: - bound_expr = ex.bind_schema_fields(cdef.expression, self.child.field_by_id) - field = nodes.Field( - cdef.id, - 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 bound_expr.is_identity and not any( - self.child.field_by_id[id].nullable - for id in cdef.expression.column_references - ): - field = field.with_nonnull() - fields.append(field) - - return tuple(fields) - - @property - def variables_introduced(self) -> int: - # This operation only renames variables, doesn't actually create new ones - return 0 - - @property - def defines_namespace(self) -> bool: - return True - - @property - def row_count(self) -> Optional[int]: - if self.child.row_count is not None: - if self.limit is not None: - return min([self.limit, self.child.row_count]) - return self.child.row_count - - return None - - @property - def node_defined_ids(self) -> Tuple[identifiers.ColumnId, ...]: - return tuple(cdef.id for cdef in self.selections) - - @property - def consumed_ids(self): - raise NotImplementedError() - - @property - def _node_expressions(self): - raise NotImplementedError() - - @property - def is_star_selection(self) -> bool: - if tuple(self.ids) != tuple(self.child.ids): - return False - for cdef in self.selections: - if not isinstance(cdef.expression, ex.DerefOp): - return False - if cdef.expression.id != cdef.id: - return False - return True - - @functools.cache - def get_id_mapping(self) -> dict[identifiers.ColumnId, ex.Expression]: - return {cdef.id: cdef.expression for cdef in self.selections} - - def remap_vars( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlSelectNode: - raise NotImplementedError() - - def remap_refs( - self, mappings: Mapping[identifiers.ColumnId, identifiers.ColumnId] - ) -> SqlSelectNode: - raise NotImplementedError() # type: ignore diff --git a/bigframes/core/tools/datetimes.py b/bigframes/core/tools/datetimes.py index 0cdda67693d..7edf2fa2e44 100644 --- a/bigframes/core/tools/datetimes.py +++ b/bigframes/core/tools/datetimes.py @@ -12,11 +12,9 @@ # 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 date, datetime -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.tools.datetimes as vendored_pandas_datetimes @@ -27,9 +25,6 @@ import bigframes.operations as ops import bigframes.series -if TYPE_CHECKING: - import bigframes.session - def to_datetime( arg: Union[ @@ -42,7 +37,6 @@ 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, date)): return pd.to_datetime( @@ -58,19 +52,12 @@ def to_datetime( f"to datetime is not implemented. {constants.FEEDBACK_LINK}" ) - arg = bigframes.series.Series(arg, session=session) + arg = bigframes.series.Series(arg) - if ( - format - and unit - and arg.dtype in (bigframes.dtypes.INT_DTYPE, bigframes.dtypes.FLOAT_DTYPE) - ): # type: ignore + 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") - if unit and arg.dtype not in ( - bigframes.dtypes.INT_DTYPE, - bigframes.dtypes.FLOAT_DTYPE, - ): # type: ignore + if unit and arg.dtype not in (bigframes.dtypes.INT_DTYPE, bigframes.dtypes.FLOAT_DTYPE): # type: ignore raise NotImplementedError( f"Unit parameter is not supported for non-numerical input types. {constants.FEEDBACK_LINK}" ) diff --git a/bigframes/core/tree_properties.py b/bigframes/core/tree_properties.py index 225cfc2f437..baf4b12566b 100644 --- a/bigframes/core/tree_properties.py +++ b/bigframes/core/tree_properties.py @@ -15,13 +15,10 @@ import functools import itertools -from typing import TYPE_CHECKING, Callable, Dict, Optional, Sequence +from typing import Callable, Dict, Optional, Sequence import bigframes.core.nodes as nodes -if TYPE_CHECKING: - import bigframes.session.execution_cache as execution_cache - def is_trivially_executable(node: nodes.BigFrameNode) -> bool: if local_only(node): @@ -68,7 +65,7 @@ def select_cache_target( root: nodes.BigFrameNode, min_complexity: float, max_complexity: float, - cache: execution_cache.ExecutionCache, + cache: dict[nodes.BigFrameNode, nodes.BigFrameNode], heuristic: Callable[[int, int], float], ) -> Optional[nodes.BigFrameNode]: """Take tree, and return candidate nodes with (# of occurences, post-caching planning complexity). @@ -78,7 +75,7 @@ def select_cache_target( @functools.cache def _with_caching(subtree: nodes.BigFrameNode) -> nodes.BigFrameNode: - return cache.subsitute_cached_subplans(subtree) + return nodes.top_down(subtree, lambda x: cache.get(x, x)) def _combine_counts( left: Dict[nodes.BigFrameNode, int], right: Dict[nodes.BigFrameNode, int] @@ -109,7 +106,6 @@ def _node_counts_inner( if len(node_counts) == 0: raise ValueError("node counts should be non-zero") - # for each considered node, calculate heuristic value, and return node with max value return max( node_counts.keys(), key=lambda node: heuristic( diff --git a/bigframes/core/utils.py b/bigframes/core/utils.py index 641fbcc9ac4..dd37a352a7c 100644 --- a/bigframes/core/utils.py +++ b/bigframes/core/utils.py @@ -15,8 +15,8 @@ import functools import re import typing -import warnings from typing import Hashable, Iterable, List +import warnings import bigframes_vendored.pandas.io.common as vendored_pandas_io_common import numpy as np @@ -113,13 +113,13 @@ def get_standardized_ids( """ col_ids = [ UNNAMED_COLUMN_ID - if pd.isna(col_label) # type: ignore + if col_label is None else label_to_identifier(col_label, strict=strict) for col_label in col_labels ] idx_ids = [ UNNAMED_INDEX_ID - if pd.isna(idx_label) # type: ignore + if idx_label is None else label_to_identifier(idx_label, strict=strict) for idx_label in idx_labels ] @@ -234,7 +234,7 @@ def wrapper(*args, **kwargs): def timedelta_to_micros( - timedelta: typing.Union[pd.Timedelta, datetime.timedelta, np.timedelta64], + timedelta: typing.Union[pd.Timedelta, datetime.timedelta, np.timedelta64] ) -> int: if isinstance(timedelta, pd.Timedelta): # pd.Timedelta.value returns total nanoseconds. @@ -249,16 +249,3 @@ def timedelta_to_micros( ) * 1_000_000 + timedelta.microseconds raise TypeError(f"Unrecognized input type: {type(timedelta)}") - - -def get_ipython_execution_count() -> typing.Optional[int]: - """Returns the current IPython cell execution count if running in a notebook, else None.""" - try: - from IPython.core.interactiveshell import InteractiveShell - - if InteractiveShell.initialized(): - ipy = InteractiveShell.instance() - return getattr(ipy, "execution_count", None) - except (ImportError, NameError): - pass - return None diff --git a/bigframes/core/validations.py b/bigframes/core/validations.py index 84f802cd740..701752c9fc1 100644 --- a/bigframes/core/validations.py +++ b/bigframes/core/validations.py @@ -17,7 +17,7 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Optional, Protocol, Union +from typing import Optional, Protocol, TYPE_CHECKING, Union import bigframes_vendored.constants as constants @@ -27,20 +27,22 @@ from bigframes import Session from bigframes.core.blocks import Block from bigframes.dataframe import DataFrame - from bigframes.series import Series + from bigframes.operations.base import SeriesMethods class HasSession(Protocol): @property - def _session(self) -> Session: ... + def _session(self) -> Session: + ... @property - def _block(self) -> Block: ... + def _block(self) -> Block: + ... def requires_index(meth): @functools.wraps(meth) - def guarded_meth(df: Union[DataFrame, Series], *args, **kwargs): + def guarded_meth(df: Union[DataFrame, SeriesMethods], *args, **kwargs): df._throw_if_null_index(meth.__name__) return meth(df, *args, **kwargs) diff --git a/bigframes/core/window/rolling.py b/bigframes/core/window/rolling.py index a3660954dfb..a9c6dfdfa76 100644 --- a/bigframes/core/window/rolling.py +++ b/bigframes/core/window/rolling.py @@ -15,34 +15,29 @@ from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Literal, Mapping, Sequence, Union +import typing import bigframes_vendored.pandas.core.window.rolling as vendored_pandas_rolling import numpy import pandas -import bigframes.core.blocks as blocks -import bigframes.operations.aggregations as agg_ops from bigframes import dtypes -from bigframes._tools import docs -from bigframes.core import agg_expressions, ordering, utils, window_spec from bigframes.core import expression as ex -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter, ordering, window_spec +import bigframes.core.blocks as blocks from bigframes.core.window import ordering as window_ordering - -if TYPE_CHECKING: - import bigframes.dataframe as df - import bigframes.series as series +import bigframes.operations.aggregations as agg_ops @log_adapter.class_logger -@docs.inherit_docs(vendored_pandas_rolling.Window) -class Window: +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], + value_column_ids: typing.Sequence[str], drop_null_groups: bool = True, is_series: bool = False, skip_agg_column_id: str | None = None, @@ -57,166 +52,89 @@ def __init__( self._skip_agg_column_id = skip_agg_column_id def count(self): - return self._apply_aggregate_op(agg_ops.count_op) + return self._apply_aggregate(agg_ops.count_op) def sum(self): - return self._apply_aggregate_op(agg_ops.sum_op) + return self._apply_aggregate(agg_ops.sum_op) def mean(self): - return self._apply_aggregate_op(agg_ops.mean_op) + return self._apply_aggregate(agg_ops.mean_op) def var(self): - return self._apply_aggregate_op(agg_ops.var_op) + return self._apply_aggregate(agg_ops.var_op) def std(self): - return self._apply_aggregate_op(agg_ops.std_op) + return self._apply_aggregate(agg_ops.std_op) def max(self): - return self._apply_aggregate_op(agg_ops.max_op) + return self._apply_aggregate(agg_ops.max_op) def min(self): - return self._apply_aggregate_op(agg_ops.min_op) + return self._apply_aggregate(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 = [] + def _apply_aggregate( + self, + op: agg_ops.UnaryAggregateOp, + ): + agg_block = self._aggregate_block(op) - want_aggfunc_level = any(utils.is_list_like(aggs) for aggs in func.values()) + if self._is_series: + from bigframes.series import Series - 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), - ) + return Series(agg_block) else: - result_labels = pandas.Index(column_labels) + from bigframes.dataframe import DataFrame - return self._apply_aggs(aggregations, result_labels) + # Preserve column order. + column_labels = [ + self._block.col_id_to_label[col_id] for col_id in self._value_column_ids + ] + return DataFrame(agg_block)._reindex_columns(column_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 + def _aggregate_block(self, op: agg_ops.UnaryAggregateOp) -> blocks.Block: + agg_col_ids = [ + col_id + for col_id in self._value_column_ids + if col_id != self._skip_agg_column_id ] - - 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, + block, result_ids = self._block.multi_apply_window_op( + agg_col_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) - # 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 + *[col.id.name for col in self._window_spec.grouping_keys], *original_index_ids, ) block = block.set_index(col_ids=index_ids) + labels = [self._block.col_id_to_label[col] for col in agg_col_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 + result_ids = [self._skip_agg_column_id, *result_ids] + labels.insert(0, self._block.col_id_to_label[self._skip_agg_column_id]) - 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 + return block.select_columns(result_ids).with_column_labels(labels) def create_range_window( block: blocks.Block, window: pandas.Timedelta | numpy.timedelta64 | datetime.timedelta | str, *, - value_column_ids: Sequence[str] = tuple(), + value_column_ids: typing.Sequence[str] = tuple(), min_periods: int | None, on: str | None = None, - closed: Literal["right", "left", "both", "neither"], + closed: typing.Literal["right", "left", "both", "neither"], is_series: bool, - grouping_keys: Sequence[str] = tuple(), + grouping_keys: typing.Sequence[str] = tuple(), drop_null_groups: bool = True, ) -> Window: + if on is None: # Rolling on index index_dtypes = block.index.dtypes @@ -265,11 +183,3 @@ def create_range_window( 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 509dd954b9f..bef5fbea7ca 100644 --- a/bigframes/core/window_spec.py +++ b/bigframes/core/window_spec.py @@ -13,10 +13,10 @@ # limitations under the License. from __future__ import annotations +from dataclasses import dataclass, replace import datetime import itertools -from dataclasses import dataclass, replace -from typing import Callable, Literal, Mapping, Optional, Sequence, Set, Tuple, Union +from typing import Literal, Mapping, Optional, Sequence, Set, Tuple, Union import numpy as np import pandas as pd @@ -215,13 +215,13 @@ class WindowSpec: Specifies a window over which aggregate and analytic function may be applied. Attributes: - grouping_keys: A set of columns to group on + grouping_keys: A set of column ids 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.Expression, ...] = tuple() + grouping_keys: Tuple[ex.DerefOp, ...] = tuple() ordering: Tuple[orderings.OrderingExpression, ...] = tuple() bounds: Union[RowsWindowBounds, RangeWindowBounds, None] = None min_periods: int = 0 @@ -273,10 +273,7 @@ def all_referenced_columns(self) -> Set[ids.ColumnId]: ordering_vars = itertools.chain.from_iterable( item.scalar_expression.column_references for item in self.ordering ) - grouping_vars = itertools.chain.from_iterable( - item.column_references for item in self.grouping_keys - ) - return set(itertools.chain(grouping_vars, ordering_vars)) + return set(itertools.chain((i.id for i in self.grouping_keys), ordering_vars)) def without_order(self, force: bool = False) -> WindowSpec: """Removes ordering clause if ordering isn't required to define bounds.""" @@ -301,15 +298,3 @@ 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 1ddf858509f..85760d94bce 100644 --- a/bigframes/dataframe.py +++ b/bigframes/dataframe.py @@ -19,13 +19,13 @@ import datetime import inspect import itertools +import json import re import sys import textwrap +import traceback import typing -import warnings from typing import ( - TYPE_CHECKING, Any, Callable, Dict, @@ -35,32 +35,31 @@ Literal, Mapping, Optional, + overload, Sequence, Tuple, - TypeVar, Union, - cast, - overload, ) +import warnings import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.frame as vendored_pandas_frame import bigframes_vendored.pandas.pandas._typing as vendored_pandas_typing import google.api_core.exceptions import google.cloud.bigquery as bigquery -import google.cloud.bigquery.job -import google.cloud.bigquery.table import numpy import pandas +from pandas.api import extensions as pd_ext +import pandas.io.formats.format import pyarrow import tabulate -from pandas.api import extensions as pd_ext +import bigframes._config.display_options as display_options import bigframes.constants import bigframes.core +from bigframes.core import log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks -import bigframes.core.col import bigframes.core.convert import bigframes.core.explode import bigframes.core.expression as ex @@ -68,11 +67,11 @@ 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 @@ -80,37 +79,21 @@ import bigframes.functions import bigframes.operations as ops 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 -from bigframes._tools import docs -from bigframes.core import agg_expressions -from bigframes.core.logging import log_adapter -from bigframes.core.window import rolling -from bigframes.functions import function_typing -if TYPE_CHECKING: +if typing.TYPE_CHECKING: from _typeshed import SupportsRichComparison - import bigframes.extensions.bigframes.dataframe_accessor as bigquery_accessor import bigframes.session - SingleItemValue = Union[ - bigframes.series.Series, - int, - float, - str, - pandas.Timedelta, - Callable, - bigframes.core.col.Expression, - ] - MultiItemValue = Union[ - "DataFrame", Sequence[int | float | str | pandas.Timedelta | Callable] - ] - -U = TypeVar("U") + SingleItemValue = Union[bigframes.series.Series, int, float, str, Callable] + MultiItemValue = Union["DataFrame", Sequence[int | float | str | Callable]] + LevelType = typing.Hashable LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -124,8 +107,8 @@ # Inherits from pandas DataFrame so that we can use the same docstrings. @log_adapter.class_logger -@docs.inherit_docs(vendored_pandas_frame.DataFrame) -class DataFrame: +class DataFrame(vendored_pandas_frame.DataFrame): + __doc__ = vendored_pandas_frame.DataFrame.__doc__ # internal flag to disable cache at all _disable_cache_override: bool = False # Must be above 5000 for pandas to delegate to bigframes for binops @@ -145,7 +128,7 @@ def __init__( ): global bigframes - self._query_job: Optional[google.cloud.bigquery.job.QueryJob] = None + self._query_job: Optional[bigquery.QueryJob] = None if copy is not None and not copy: raise ValueError( @@ -314,6 +297,7 @@ def loc(self) -> indexers.LocDataFrameIndexer: return indexers.LocDataFrameIndexer(self) @property + @validations.requires_ordering() def iloc(self) -> indexers.ILocDataFrameIndexer: return indexers.ILocDataFrameIndexer(self) @@ -329,13 +313,11 @@ def at(self) -> indexers.AtDataFrameIndexer: @property def dtypes(self) -> pandas.Series: - dtypes = self._block.dtypes - bigframes.dtypes.warn_on_db_dtypes_json_dtype(dtypes) - return pandas.Series(data=dtypes, index=self._block.column_labels) + return pandas.Series(data=self._block.dtypes, index=self._block.column_labels) @property def columns(self) -> pandas.Index: - return self._block.column_labels + return self.dtypes.index @columns.setter def columns(self, labels: pandas.Index): @@ -346,10 +328,6 @@ def columns(self, labels: pandas.Index): def shape(self) -> Tuple[int, int]: return self._block.shape - @property - def axes(self) -> list: - return [self.index, self.columns] - @property def size(self) -> int: rows, cols = self.shape @@ -376,25 +354,6 @@ def bqclient(self) -> bigframes.Session: def _session(self) -> bigframes.Session: return self._get_block().expr.session - @property - def bigquery( - self, - ) -> bigquery_accessor.BigframesBigQueryDataFrameAccessor: - """ - Accessor for BigQuery functionality. - - Returns: - bigframes.extensions.core.dataframe_accessor.BigQueryDataFrameAccessor: - Accessor that exposes BigQuery functionality on a DataFrame, - with method names closer to SQL. - """ - # Import the accessor here to avoid circular imports. - import bigframes.extensions.bigframes.dataframe_accessor - - return bigframes.extensions.bigframes.dataframe_accessor.BigframesBigQueryDataFrameAccessor( - self - ) - @property def _has_index(self) -> bool: return len(self._block.index_columns) > 0 @@ -413,10 +372,7 @@ def __len__(self): rows, _ = self.shape return rows - def __bool__(self): - raise ValueError( - "Cannot convert dataframe into bool. Consider using .empty(), .any(), or .all() methods." - ) + __len__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__len__) def __iter__(self): return iter(self.columns) @@ -438,41 +394,17 @@ def astype( if errors not in ["raise", "null"]: raise ValueError("Arg 'error' must be one of 'raise' or 'null'") - if isinstance(dtype, dict): - for col in dtype: - if col not in self.columns: - raise KeyError( - f"Only Column Names are allowed in dtypes dict. '{col}' is not in the columns." - ) - safe_cast = errors == "null" - exprs: list[ex.Expression] = [] - for col_id, col_label in zip( - self._block.value_columns, self._block.column_labels - ): - from_type = self._block._column_type(col_id) - - if isinstance(dtype, dict): - if col_label not in dtype: - exprs.append(ex.deref(col_id)) - continue - to_type = bigframes.dtypes.bigframes_type(dtype[col_label]) - else: - to_type = bigframes.dtypes.bigframes_type(dtype) - - op: ops.UnaryOp - if to_type == bigframes.dtypes.JSON_DTYPE: - op = ops.ToJSON(safe=safe_cast) - elif from_type == bigframes.dtypes.JSON_DTYPE: - op = ops.JSONDecode(to_type=to_type, safe=safe_cast) - else: - op = ops.AsTypeOp(to_type=to_type, safe=safe_cast) + if isinstance(dtype, dict): + result = self.copy() + for col, to_type in dtype.items(): + result[col] = result[col].astype(to_type) + return result - exprs.append(op.as_expr(ex.deref(col_id))) + dtype = bigframes.dtypes.bigframes_type(dtype) - block = self._block.project_exprs(exprs, labels=self.columns, drop=True) - return DataFrame(block) + 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?""" @@ -481,9 +413,7 @@ def _should_sql_have_index(self) -> bool: self.index.name is not None or len(self.index.names) > 1 ) - def _to_placeholder_table( - self, dry_run: bool = False - ) -> google.cloud.bigquery.table.TableReference: + 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). """ @@ -533,11 +463,11 @@ def sql(self) -> str: ) from e @property - def query_job(self) -> Optional[google.cloud.bigquery.job.QueryJob]: + def query_job(self) -> Optional[bigquery.QueryJob]: """BigQuery job metadata for the most recent query. Returns: - None or google.cloud.bigquery.job.QueryJob: + None or google.cloud.bigquery.QueryJob: The most recent `QueryJob `_. """ @@ -556,6 +486,7 @@ 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, @@ -578,23 +509,12 @@ def info( obuf.write(f"{type(self)}\n") - if self._block.has_index: - index_type = "MultiIndex" if self.index.nlevels > 1 else "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") - - if n_columns == 0: - # We don't display any more information if the dataframe has no columns - obuf.write("Empty DataFrame\n") - return + # 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") dtype_strings = self.dtypes.astype("string") if show_all_columns: @@ -651,17 +571,14 @@ def select_dtypes(self, include=None, exclude=None) -> DataFrame: ) return DataFrame(self._block.select_columns(selected_columns)) - def _set_internal_query_job( - self, query_job: Optional[google.cloud.bigquery.job.QueryJob] - ): + def _set_internal_query_job(self, query_job: Optional[bigquery.QueryJob]): self._query_job = query_job def __getitem__( self, key: Union[ blocks.Label, - List[str], - List[blocks.Label], + Sequence[blocks.Label], # Index of column labels can be treated the same as a sequence of column labels. pandas.Index, bigframes.series.Series, @@ -670,30 +587,31 @@ def __getitem__( ): # No return type annotations (like pandas) as type cannot always be determined statically # NOTE: This implements the operations described in # https://pandas.pydata.org/docs/getting_started/intro_tutorials/03_subset_data.html - import bigframes.core.col - import bigframes.pandas - if isinstance(key, bigframes.pandas.Series): + if isinstance(key, bigframes.series.Series): return self._getitem_bool_series(key) if isinstance(key, slice): return self.iloc[key] - if isinstance(key, bigframes.core.col.Expression): - return self.loc[key] - - # TODO(tswast): Fix this pylance warning: Class overlaps "Hashable" - # unsafely and could produce a match at runtime - if isinstance(key, blocks.Label): + if isinstance(key, typing.Hashable): 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? - 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)]) + # Select a number of columns as DF. + key = key if utils.is_list_like(key) else [key] # type:ignore - 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] @@ -701,6 +619,8 @@ def _getitem_columns(self, key: Sequence[blocks.Label]) -> DataFrame: 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: @@ -730,12 +650,9 @@ def _getitem_bool_series(self, key: bigframes.series.Series) -> DataFrame: f"Only boolean series currently supported for indexing. {constants.FEEDBACK_LINK}" ) # TODO: enforce stricter alignment - ( - combined_index, - ( - get_column_left, - get_column_right, - ), + combined_index, ( + get_column_left, + get_column_right, ) = self._block.join(key._block, how="left") block = combined_index filter_col_id = get_column_right[key._value_column] @@ -753,7 +670,7 @@ def __getattr__(self, key: str): # https://github.com/googleapis/python-bigquery-dataframes/issues/728 # and # https://nedbatchelder.com/blog/201010/surprising_getattr_recursion.html - if "_block" not in self.__dict__ or key == "_block": + if key == "_block": raise AttributeError(key) if key in self._block.column_labels: @@ -818,7 +735,9 @@ def __repr__(self) -> str: opts = bigframes.options.display max_results = opts.max_rows - if opts.repr_mode == "deferred": + # anywdiget mode uses the same display logic as the "deferred" mode + # for faster execution + if opts.repr_mode in ("deferred", "anywidget"): return formatter.repr_query_job(self._compute_dry_run()) # TODO(swast): pass max_columns and get the true column count back. Maybe @@ -829,64 +748,156 @@ 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) - def _prepare_display_df(self) -> DataFrame: - """Process ObjectRef and JSON/nested JSON columns for display.""" - import bigframes.bigquery as bbq - - df = self - # Arrow/Pandas to_pandas_batches does not support raw JSON/nested JSON - # columns. Pre-serialize them to string format to bypass this limit. - # Using TO_JSON_STRING via SqlScalarOp handles complex nested STRUCT - # types correctly. Use the offset so that we can handle duplicate and - # non-string column names. - json_col_indexes = [ - col_index - for col_index, col in enumerate(df.columns) - if bigframes.dtypes.contains_db_dtypes_json_dtype(df[col].dtype) - ] - if json_col_indexes: - df.iloc[:, json_col_indexes] = cast( - DataFrame, - df.iloc[:, json_col_indexes].apply(bbq.to_json_string), # type: ignore + 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 ) - return df + if not self._has_index: + to_string_kwargs.update({"index": False}) + repr_string = pandas_df.to_string(**to_string_kwargs) - def _repr_mimebundle_(self, include=None, exclude=None): + # 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: """ - Custom display method for IPython/Jupyter environments. - This is called by IPython's display system when the object is displayed. + 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. """ - # 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 + opts = bigframes.options.display + max_results = opts.max_rows + if opts.repr_mode == "deferred": + return formatter.repr_query_job(self._compute_dry_run()) + + # Process blob columns first, regardless of display mode + self._cached() + df = self.copy() + if bigframes.options.display.blob_display: + blob_cols = [ + series_name + for series_name, series in df.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) + else: + blob_cols = [] + + if opts.repr_mode == "anywidget": + try: + from IPython.display import display as ipython_display + + from bigframes import display + + # Always create a new widget instance for each display call + # This ensures that each cell gets its own widget and prevents + # unintended sharing between cells + widget = display.TableWidget(df.copy()) + + ipython_display(widget) + return "" # Return empty string since we used display() + + except (AttributeError, ValueError, ImportError): + # Fallback if anywidget is not available + 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 deferred mode. Error: {traceback.format_exc()}" + ) + return formatter.repr_query_job(self._compute_dry_run()) + + # Continue with regular HTML rendering for non-anywidget modes + # 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.display.blob_display and blob_cols: + + def obj_ref_rt_to_html(obj_ref_rt) -> 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 bigframes.options.display.blob_display_width: + size_str = f' width="{bigframes.options.display.blob_display_width}"' + if bigframes.options.display.blob_display_height: + size_str = ( + size_str + + f' height="{bigframes.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"]}' + + 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 - return html.repr_mimebundle(self, include=include, exclude=exclude) + html_string += f"[{row_count} rows x {column_count} columns in total]" + return html_string def __delitem__(self, key: str): df = self.drop(columns=[key]) self._set_block(df._get_block()) def __setitem__( - self, - key: str | list[str] | pandas.Index, - value: SingleItemValue | MultiItemValue, + self, key: str | list[str], value: SingleItemValue | MultiItemValue ): - if isinstance(key, (list, pandas.Index)): + if isinstance(key, list): 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__) + def _apply_binop( self, other: float | int | bigframes.series.Series | DataFrame, @@ -997,39 +1008,53 @@ def eq(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: def __eq__(self, other) -> DataFrame: # type: ignore return self.eq(other) + __eq__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__eq__) + def ne(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: return self._apply_binop(other, ops.ne_op, axis=axis) def __ne__(self, other) -> DataFrame: # type: ignore return self.ne(other) + __ne__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__ne__) + def __invert__(self) -> DataFrame: return self._apply_unary_op(ops.invert_op) + __invert__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__invert__) + def le(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: return self._apply_binop(other, ops.le_op, axis=axis) def __le__(self, other) -> DataFrame: return self.le(other) + __le__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__le__) + def lt(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: return self._apply_binop(other, ops.lt_op, axis=axis) def __lt__(self, other) -> DataFrame: return self.lt(other) + __lt__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__lt__) + def ge(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: return self._apply_binop(other, ops.ge_op, axis=axis) def __ge__(self, other) -> DataFrame: return self.ge(other) + __ge__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__ge__) + def gt(self, other: typing.Any, axis: str | int = "columns") -> DataFrame: return self._apply_binop(other, ops.gt_op, axis=axis) def __gt__(self, other) -> DataFrame: return self.gt(other) + __gt__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__gt__) + def add( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1051,9 +1076,13 @@ def radd( def __add__(self, other) -> DataFrame: return self.add(other) + __add__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__add__) + def __radd__(self, other) -> DataFrame: return self.radd(other) + __radd__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__radd__) + def sub( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1062,10 +1091,13 @@ def sub( return self._apply_binop(other, ops.sub_op, axis=axis) subtract = sub + subtract.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.sub) def __sub__(self, other): return self.sub(other) + __sub__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__sub__) + def rsub( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1076,6 +1108,8 @@ def rsub( def __rsub__(self, other): return self.rsub(other) + __rsub__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__rsub__) + def mul( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1084,10 +1118,13 @@ def mul( return self._apply_binop(other, ops.mul_op, axis=axis) multiply = mul + multiply.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.mul) def __mul__(self, other): return self.mul(other) + __mul__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__mul__) + def rmul( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1098,6 +1135,8 @@ def rmul( def __rmul__(self, other): return self.rmul(other) + __rmul__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__rmul__) + def truediv( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1105,11 +1144,14 @@ def truediv( ) -> DataFrame: return self._apply_binop(other, ops.div_op, axis=axis) + truediv.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.truediv) div = divide = truediv def __truediv__(self, other): return self.truediv(other) + __truediv__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__truediv__) + def rtruediv( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1118,10 +1160,13 @@ def rtruediv( return self._apply_binop(other, ops.div_op, axis=axis, reverse=True) rdiv = rtruediv + rdiv.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.rtruediv) def __rtruediv__(self, other): return self.rtruediv(other) + __rtruediv__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__rtruediv__) + def floordiv( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1132,6 +1177,8 @@ def floordiv( def __floordiv__(self, other): return self.floordiv(other) + __floordiv__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__floordiv__) + def rfloordiv( self, other: float | int | bigframes.series.Series | DataFrame, @@ -1142,26 +1189,26 @@ def rfloordiv( def __rfloordiv__(self, other): return self.rfloordiv(other) - def mod( - self, - other: int | bigframes.series.Series | DataFrame, - axis: str | int = "columns", - ) -> DataFrame: # type: ignore + __rfloordiv__.__doc__ = inspect.getdoc( + vendored_pandas_frame.DataFrame.__rfloordiv__ + ) + + def mod(self, other: int | bigframes.series.Series | DataFrame, axis: str | int = "columns") -> DataFrame: # type: ignore return self._apply_binop(other, ops.mod_op, axis=axis) def __mod__(self, other): return self.mod(other) - def rmod( - self, - other: int | bigframes.series.Series | DataFrame, - axis: str | int = "columns", - ) -> DataFrame: # type: ignore + __mod__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__mod__) + + def rmod(self, other: int | bigframes.series.Series | DataFrame, axis: str | int = "columns") -> DataFrame: # type: ignore return self._apply_binop(other, ops.mod_op, axis=axis, reverse=True) def __rmod__(self, other): return self.rmod(other) + __rmod__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__rmod__) + def pow( self, other: int | bigframes.series.Series, axis: str | int = "columns" ) -> DataFrame: @@ -1170,6 +1217,8 @@ def pow( def __pow__(self, other): return self.pow(other) + __pow__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__pow__) + def rpow( self, other: int | bigframes.series.Series, axis: str | int = "columns" ) -> DataFrame: @@ -1178,19 +1227,27 @@ def rpow( def __rpow__(self, other): return self.rpow(other) + __rpow__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__rpow__) + def __and__(self, other: bool | int | bigframes.series.Series) -> DataFrame: return self._apply_binop(other, ops.and_op) + __and__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__and__) + __rand__ = __and__ def __or__(self, other: bool | int | bigframes.series.Series) -> DataFrame: return self._apply_binop(other, ops.or_op) + __or__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__or__) + __ror__ = __or__ def __xor__(self, other: bool | int | bigframes.series.Series) -> DataFrame: return self._apply_binop(other, ops.xor_op) + __xor__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__xor__) + __rxor__ = __xor__ def __pos__(self) -> DataFrame: @@ -1199,9 +1256,6 @@ 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) - def align( self, other: typing.Union[DataFrame, bigframes.series.Series], @@ -1307,9 +1361,7 @@ def _fast_stat_matrix(self, op: agg_ops.BinaryAggregateOp) -> DataFrame: block = frame._block aggregations = [ - agg_expressions.BinaryAggregation( - op, ex.deref(left_col), ex.deref(right_col) - ) + ex.BinaryAggregation(op, ex.deref(left_col), ex.deref(right_col)) for left_col in block.value_columns for right_col in block.value_columns ] @@ -1319,7 +1371,7 @@ def _fast_stat_matrix(self, op: agg_ops.BinaryAggregateOp) -> DataFrame: ) labels = utils.cross_indices(uniq_orig_columns, uniq_orig_columns) - block = block.aggregate(aggregations=aggregations, column_labels=labels) + 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 @@ -1574,9 +1626,9 @@ def corrwith( r_block.column_labels, how="outer" ).difference(labels) - block = block.aggregate( + block, _ = block.aggregate( aggregations=tuple( - agg_expressions.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) + ex.BinaryAggregation(agg_ops.CorrOp(), left_ex, right_ex) for left_ex, right_ex in expr_pairs ), column_labels=labels, @@ -1589,11 +1641,6 @@ 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, *, @@ -1636,7 +1683,8 @@ def to_pandas( # type: ignore[overload-overlap] ordered: bool = ..., dry_run: Literal[False] = ..., allow_large_results: Optional[bool] = ..., - ) -> pandas.DataFrame: ... + ) -> pandas.DataFrame: + ... @overload def to_pandas( @@ -1648,7 +1696,8 @@ def to_pandas( ordered: bool = ..., dry_run: Literal[True] = ..., allow_large_results: Optional[bool] = ..., - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def to_pandas( self, @@ -1664,6 +1713,8 @@ def to_pandas( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col': [4, 2, 2]}) Download the data from BigQuery and convert it into an in-memory pandas DataFrame. @@ -1768,8 +1819,7 @@ def to_pandas( ) if query_job: self._set_internal_query_job(query_job) - df.columns = self._block.column_labels - return df + return df.set_axis(self._block.column_labels, axis=1, copy=False) def to_pandas_batches( self, @@ -1777,8 +1827,7 @@ def to_pandas_batches( max_results: Optional[int] = None, *, allow_large_results: Optional[bool] = None, - cell_execution_count: Optional[int] = None, - ) -> blocks.PandasBatches: + ) -> Iterable[pandas.DataFrame]: """Stream DataFrame results to an iterable of pandas DataFrame. page_size and max_results determine the size and number of batches, @@ -1786,6 +1835,8 @@ def to_pandas_batches( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col': [4, 3, 2, 2, 3]}) Iterate through the results in batches, limiting the total rows yielded @@ -1826,29 +1877,13 @@ 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, - cell_execution_count=cell_execution_count, - ) - - def _to_pandas_batches( - self, - page_size: Optional[int] = None, - max_results: Optional[int] = None, - *, - allow_large_results: Optional[bool] = None, - cell_execution_count: Optional[int] = None, - ) -> blocks.PandasBatches: return self._block.to_pandas_batches( page_size=page_size, max_results=max_results, allow_large_results=allow_large_results, - cell_execution_count=cell_execution_count, ) - def _compute_dry_run(self) -> google.cloud.bigquery.job.QueryJob: + def _compute_dry_run(self) -> bigquery.QueryJob: _, query_job = self._block._compute_dry_run() return query_job @@ -1899,8 +1934,7 @@ def peek( raise ValueError( "Cannot peek efficiently when data has aggregates, joins or window functions applied. Use force=True to fully compute dataframe." ) - maybe_result.columns = self._block.column_labels - return maybe_result + return maybe_result.set_axis(self._block.column_labels, axis=1, copy=False) def nlargest( self, @@ -1956,7 +1990,6 @@ def insert( self._set_block(block) - @overload def drop( self, labels: typing.Any = None, @@ -1965,31 +1998,7 @@ 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]: + ) -> DataFrame: if labels: if index or columns: raise ValueError("Cannot specify both 'labels' and 'index'/'columns") @@ -2031,11 +2040,7 @@ def drop( inverse_condition_id, ops.invert_op ) elif isinstance(index, indexes.Index): - dropped_block = self._drop_by_index(index)._get_block() - if inplace: - self._set_block(dropped_block) - return None - return DataFrame(dropped_block) + return self._drop_by_index(index) else: block, condition_id = block.project_expr( ops.ne_op.as_expr(level_id, ex.const(index)) @@ -2047,12 +2052,7 @@ 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") - - if inplace: - self._set_block(block) - return None - else: - return DataFrame(block) + return DataFrame(block) def _drop_by_index(self, index: indexes.Index) -> DataFrame: block = index._block @@ -2122,17 +2122,20 @@ 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: ... + def rename(self, *, columns: Mapping[blocks.Label, blocks.Label]) -> DataFrame: + ... @overload def rename( self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: Literal[False] - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def rename( self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: Literal[True] - ) -> None: ... + ) -> None: + ... def rename( self, *, columns: Mapping[blocks.Label, blocks.Label], inplace: bool = False @@ -2149,7 +2152,8 @@ def rename( def rename_axis( self, mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def rename_axis( @@ -2158,7 +2162,8 @@ def rename_axis( *, inplace: Literal[False], **kwargs, - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def rename_axis( @@ -2167,7 +2172,8 @@ def rename_axis( *, inplace: Literal[True], **kwargs, - ) -> None: ... + ) -> None: + ... def rename_axis( self, @@ -2218,13 +2224,6 @@ def _assign_single_item( ) -> DataFrame: if isinstance(v, bigframes.series.Series): return self._assign_series_join_on_index(k, v) - elif isinstance(v, bigframes.core.col.Expression): - label_to_col_ref = { - label: ex.deref(id) for id, label in self._block.col_id_to_label.items() - } - resolved_expr = v._value.bind_variables(label_to_col_ref) - block = self._block.project_block_exprs([resolved_expr], labels=[k]) - return DataFrame(block) elif isinstance(v, bigframes.dataframe.DataFrame): v_df_col_count = len(v._block.value_columns) if v_df_col_count != 1: @@ -2241,47 +2240,10 @@ def _assign_single_item( else: return self._assign_scalar(k, v) # type: ignore - def _assign_single_item_by_offset( - self, - offset: int, - value: SingleItemValue | MultiItemValue, - ) -> DataFrame: - if isinstance(value, bigframes.series.Series): - return self._assign_series_join_on_index_by_offset(offset, value) - elif isinstance(value, bigframes.core.col.Expression): - label_to_col_ref = { - label: ex.deref(id) for id, label in self._block.col_id_to_label.items() - } - resolved_expr = value._value.bind_variables(label_to_col_ref) - block, new_col_id = self._block.project_expr(resolved_expr) - target_col_id = self._block.value_columns[offset] - block = block.copy_values(new_col_id, target_col_id).drop_columns( - [new_col_id] - ) - return DataFrame(block) - elif isinstance(value, DataFrame): - v_df_col_count = len(value._block.value_columns) - if v_df_col_count != 1: - raise ValueError( - f"Cannot set a DataFrame with {v_df_col_count} columns to the single column at offset {offset}" - ) - return self._assign_series_join_on_index_by_offset( - offset, cast(bigframes.series.Series, value[value.columns[0]]) - ) - elif callable(value): - raise NotImplementedError( - "Callable assignment is not supported by column offset." - ) - elif utils.is_list_like(value): - return self._assign_single_item_listlike_by_offset(offset, value) - else: - return self._assign_scalar_by_offset(offset, value) # type: ignore - - def _assign_multi_items_helper( + def _assign_multi_items( self, - k: Sequence[Any] | pandas.Index, + k: list[str], v: SingleItemValue | MultiItemValue, - assign_single_fn: Callable[[DataFrame, Any, Any], DataFrame], ) -> DataFrame: value_sources: Sequence[Any] = [] if isinstance(v, DataFrame): @@ -2299,35 +2261,13 @@ def _assign_multi_items_helper( raise ValueError("Columns must be same length as key") # Repeatedly assign columns in order. - result = assign_single_fn(self, k[0], value_sources[0]) + result = self._assign_single_item(k[0], value_sources[0]) for target, source in zip(k[1:], value_sources[1:]): - result = assign_single_fn(result, target, source) + result = result._assign_single_item(target, source) return result - def _assign_multi_items( - self, - k: list[str] | pandas.Index, - v: SingleItemValue | MultiItemValue, - ) -> DataFrame: - return self._assign_multi_items_helper(k, v, DataFrame._assign_single_item) - - def _assign_multi_items_by_offsets( - self, - k: Sequence[int], - v: SingleItemValue | MultiItemValue, - ) -> DataFrame: - return self._assign_multi_items_helper( - k, v, DataFrame._assign_single_item_by_offset - ) - - _assign_multi_items_by_offset = _assign_multi_items_by_offsets - _assign_multi_items_by_label = _assign_multi_items - _assign_multi_items_by_labels = _assign_multi_items - - def _assign_single_item_listlike_to_col_ids( - self, col_ids: Sequence[str], label: Optional[str], value: Sequence - ) -> DataFrame: - given_rows = len(value) + def _assign_single_item_listlike(self, k: str, v: Sequence) -> DataFrame: + given_rows = len(v) actual_rows = len(self) assigning_to_empty_df = len(self.columns) == 0 and actual_rows == 0 if not assigning_to_empty_df and given_rows != actual_rows: @@ -2335,14 +2275,7 @@ def _assign_single_item_listlike_to_col_ids( f"Length of values ({given_rows}) does not match length of index ({actual_rows})" ) - temp_col_name = ( - label - if label is not None - else bigframes.core.guid.generate_guid("listlike_col_") - ) - local_df = DataFrame( - {temp_col_name: value}, session=self._get_block().expr.session - ) + local_df = DataFrame({k: v}, session=self._get_block().expr.session) # local_df is likely (but not guaranteed) to be cached locally # since the original list came from memory and so is probably < MAX_INLINE_DF_SIZE @@ -2350,24 +2283,17 @@ def _assign_single_item_listlike_to_col_ids( original_index_column_ids = self._block.index_columns self_block = self._block.reset_index(drop=False) if assigning_to_empty_df: - if label is None: - raise ValueError( - "Label required when assigning listlike to empty DataFrame." - ) if len(self._block.index_columns) > 1: # match error raised by pandas here raise ValueError( "Assigning listlike to a first column under multiindex is not supported." ) result_block = new_column_block.with_index_labels(self._block.index.names) - result_block = result_block.with_column_labels([label]) + result_block = result_block.with_column_labels([k]) else: - ( - result_block, - ( - get_column_left, - get_column_right, - ), + result_block, ( + get_column_left, + get_column_right, ) = self_block.join(new_column_block, how="left", block_identity_join=True) result_block = result_block.set_index( [get_column_left[col_id] for col_id in original_index_column_ids], @@ -2375,6 +2301,7 @@ def _assign_single_item_listlike_to_col_ids( ) src_col = get_column_right[new_column_block.value_columns[0]] # Check to see if key exists, and modify in place + col_ids = self._block.cols_matching_label(k) for col_id in col_ids: result_block = result_block.copy_values( src_col, get_column_left[col_id] @@ -2383,22 +2310,9 @@ def _assign_single_item_listlike_to_col_ids( result_block = result_block.drop_columns([src_col]) return DataFrame(result_block) - def _assign_single_item_listlike(self, k: str, v: Sequence) -> DataFrame: - col_ids = self._block.cols_matching_label(k) - return self._assign_single_item_listlike_to_col_ids(col_ids, k, v) - - def _assign_single_item_listlike_by_offset( - self, offset: int, value: Sequence - ) -> DataFrame: - col_ids = [self._block.value_columns[offset]] - return self._assign_single_item_listlike_to_col_ids(col_ids, None, value) + def _assign_scalar(self, label: str, value: Union[int, float, str]) -> DataFrame: + col_ids = self._block.cols_matching_label(label) - def _assign_scalar_to_col_ids( - self, - col_ids: Sequence[str], - label: Optional[str], - value: Union[int, float, str], - ) -> DataFrame: block, constant_col_id = self._block.create_constant(value, label) for col_id in col_ids: block = block.copy_values(constant_col_id, col_id) @@ -2408,40 +2322,25 @@ def _assign_scalar_to_col_ids( return DataFrame(block) - def _assign_scalar(self, label: str, value: Union[int, float, str]) -> DataFrame: - col_ids = self._block.cols_matching_label(label) - return self._assign_scalar_to_col_ids(col_ids, label, value) - - def _assign_scalar_by_offset( - self, offset: int, value: Union[int, float, str] - ) -> DataFrame: - col_ids = [self._block.value_columns[offset]] - return self._assign_scalar_to_col_ids(col_ids, None, value) - - def _assign_series_join_on_index_to_col_ids( - self, - column_ids: Sequence[str], - label: Optional[str], - series: bigframes.series.Series, + def _assign_series_join_on_index( + self, label: str, series: bigframes.series.Series ) -> DataFrame: block, (get_column_left, get_column_right) = self._block.join( series._block, how="left" ) - mapped_column_ids = [get_column_left[col_id] for col_id in column_ids] + column_ids = [ + get_column_left[col_id] for col_id in self._block.cols_matching_label(label) + ] source_column = get_column_right[series._value_column] - # Replace each column matching the ids - for column_id in mapped_column_ids: - block = block.copy_values(source_column, column_id) - if label is not None: - block = block.assign_label(column_id, label) + # Replace each column matching the label + for column_id in column_ids: + block = block.copy_values(source_column, column_id).assign_label( + column_id, label + ) - if not mapped_column_ids: - if label is None: - raise ValueError( - "Label required when appending a new column from Series." - ) + if not column_ids: # Append case, so new column needs appropriate label block = block.assign_label(source_column, label) else: @@ -2450,18 +2349,6 @@ def _assign_series_join_on_index_to_col_ids( return DataFrame(block.with_index_labels(self._block.index.names)) - def _assign_series_join_on_index( - self, label: str, series: bigframes.series.Series - ) -> DataFrame: - column_ids = self._block.cols_matching_label(label) - return self._assign_series_join_on_index_to_col_ids(column_ids, label, series) - - def _assign_series_join_on_index_by_offset( - self, offset: int, series: bigframes.series.Series - ) -> DataFrame: - column_ids = [self._block.value_columns[offset]] - return self._assign_series_join_on_index_to_col_ids(column_ids, None, series) - @overload # type: ignore[override] def reset_index( self, @@ -2472,7 +2359,8 @@ def reset_index( col_fill: Hashable = ..., allow_duplicates: Optional[bool] = ..., names: Union[None, Hashable, Sequence[Hashable]] = ..., - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def reset_index( @@ -2484,7 +2372,8 @@ def reset_index( col_fill: Hashable = ..., allow_duplicates: Optional[bool] = ..., names: Union[None, Hashable, Sequence[Hashable]] = ..., - ) -> None: ... + ) -> None: + ... @overload def reset_index( @@ -2496,7 +2385,8 @@ def reset_index( col_fill: Hashable = ..., allow_duplicates: Optional[bool] = ..., names: Union[None, Hashable, Sequence[Hashable]] = ..., - ) -> Optional[DataFrame]: ... + ) -> Optional[DataFrame]: + ... def reset_index( self, @@ -2509,7 +2399,7 @@ def reset_index( names: Union[None, Hashable, Sequence[Hashable]] = None, ) -> Optional[DataFrame]: block = self._block - if names is not None: + if names: if isinstance(names, blocks.Label) and not isinstance(names, tuple): names = [names] else: @@ -2558,9 +2448,9 @@ def sort_index( *, ascending: bool = ..., inplace: Literal[False] = ..., - kind: str | None = ..., na_position: Literal["first", "last"] = ..., - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def sort_index( @@ -2568,43 +2458,29 @@ def sort_index( *, ascending: bool = ..., inplace: Literal[True] = ..., - kind: str | None = ..., na_position: Literal["first", "last"] = ..., - ) -> None: ... + ) -> None: + ... + @validations.requires_index def sort_index( self, *, - axis: Union[int, str] = 0, ascending: bool = True, inplace: bool = False, - kind: str | None = None, 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 - ] - is_stable = ( - kind or constants.DEFAULT_SORT_KIND - ) in constants.STABLE_SORT_KINDS - block = self._block.order_by(ordering, stable=is_stable) - 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 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) if inplace: self._set_block(block) return None @@ -2618,9 +2494,10 @@ def sort_values( *, inplace: Literal[False] = ..., ascending: bool | typing.Sequence[bool] = ..., - kind: str | None = ..., + kind: str = ..., na_position: typing.Literal["first", "last"] = ..., - ) -> DataFrame: ... + ) -> DataFrame: + ... @overload def sort_values( @@ -2629,9 +2506,10 @@ def sort_values( *, inplace: Literal[True] = ..., ascending: bool | typing.Sequence[bool] = ..., - kind: str | None = ..., + kind: str = ..., na_position: typing.Literal["first", "last"] = ..., - ) -> None: ... + ) -> None: + ... def sort_values( self, @@ -2639,7 +2517,7 @@ def sort_values( *, inplace: bool = False, ascending: bool | typing.Sequence[bool] = True, - kind: str | None = None, + kind: str = "quicksort", na_position: typing.Literal["first", "last"] = "last", ) -> Optional[DataFrame]: if isinstance(by, (bigframes.series.Series, indexes.Index, DataFrame)): @@ -2671,8 +2549,7 @@ def sort_values( if is_ascending else order.descending_over(column_id, na_last) ) - is_stable = (kind or constants.DEFAULT_SORT_KIND) in constants.STABLE_SORT_KINDS - block = self._block.order_by(ordering, stable=is_stable) + block = self._block.order_by(ordering) if inplace: self._set_block(block) return None @@ -2724,9 +2601,9 @@ def take( if not utils.is_list_like(indices): raise ValueError("indices should be a list-like object.") if axis == 0 or axis == "index": - return typing.cast(DataFrame, self.iloc[indices]) + return self.iloc[indices] elif axis == 1 or axis == "columns": - return typing.cast(DataFrame, self.iloc[:, indices]) + return self.iloc[:, indices] else: raise ValueError(f"No axis named {axis} for object type DataFrame") @@ -2915,11 +2792,11 @@ def replace( ): if utils.is_dict_like(value): return self.apply( - lambda x: ( - x.replace(to_replace=to_replace, value=value[x.name], regex=regex) - if (x.name in value) - else x + lambda x: x.replace( + to_replace=to_replace, value=value[x.name], regex=regex ) + if (x.name in value) + else x ) return self.apply( lambda x: x.replace(to_replace=to_replace, value=value, regex=regex) @@ -2989,7 +2866,7 @@ def _apply_callable(self, condition): """Executes the possible callable condition as needed.""" if callable(condition): # When it's a bigframes function. - if isinstance(condition, bigframes.functions.Udf): + if hasattr(condition, "bigframes_bigquery_function"): return self.apply(condition, axis=1) # When it's a plain Python function. @@ -2999,6 +2876,9 @@ def _apply_callable(self, condition): return condition def where(self, cond, other=None): + if isinstance(other, bigframes.series.Series): + raise ValueError("Seires is not a supported replacement type!") + if self.columns.nlevels > 1: raise NotImplementedError( "The dataframe.where() method does not support multi-column." @@ -3009,9 +2889,6 @@ def where(self, cond, other=None): 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): @@ -3211,9 +3088,7 @@ def quantile( frame = self._drop_non_numeric() multi_q = utils.is_list_like(q) result = block_ops.quantile( - frame._block, - frame._block.value_columns, - qs=tuple(q) if multi_q else (q,), # type: ignore + frame._block, frame._block.value_columns, qs=tuple(q) if multi_q else (q,) # type: ignore ) if multi_q: return DataFrame(result.stack()).droplevel(0) @@ -3277,6 +3152,7 @@ def prod( return bigframes.series.Series(block) product = prod + product.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.prod) def count(self, *, numeric_only: bool = False) -> bigframes.series.Series: if not numeric_only: @@ -3290,7 +3166,12 @@ 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) -> DataFrame | bigframes.series.Series: + def agg( + self, + func: str + | typing.Sequence[str] + | typing.Mapping[blocks.Label, typing.Sequence[str] | str], + ) -> DataFrame | bigframes.series.Series: if utils.is_dict_like(func): # Must check dict-like first because dictionaries are list-like # according to Pandas. @@ -3304,29 +3185,27 @@ def agg(self, func) -> DataFrame | bigframes.series.Series: 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_op = agg_ops.lookup_agg_func(typing.cast(str, 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]) + ex.UnaryAggregation(agg_op, ex.deref(col_id)) + if isinstance(agg_op, agg_ops.UnaryAggregateOp) + else ex.NullaryAggregation(agg_op) ) aggs.append(agg_expr) labels.append(col_label) - funcnames.append(op_and_label[1]) + funcnames.append(agg_func) # 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( + 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( + agg_block, _ = self._block.aggregate( aggregations=aggs, column_labels=new_index ) return bigframes.series.Series( @@ -3335,7 +3214,7 @@ def agg(self, func) -> DataFrame | bigframes.series.Series: ) ) elif utils.is_list_like(func): - aggregations = [agg_ops.lookup_agg_func(f)[0] for f in func] + aggregations = [agg_ops.lookup_agg_func(f) for f in func] for dtype, agg in itertools.product(self.dtypes, aggregations): agg.output_type( @@ -3351,10 +3230,13 @@ def agg(self, func) -> DataFrame | bigframes.series.Series: else: # function name string return bigframes.series.Series( - self._block.aggregate_all_and_stack(agg_ops.lookup_agg_func(func)[0]) + self._block.aggregate_all_and_stack( + agg_ops.lookup_agg_func(typing.cast(str, func)) + ) ) aggregate = agg + aggregate.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.agg) @validations.requires_index @validations.requires_ordering() @@ -3427,6 +3309,7 @@ def kurt(self, *, numeric_only: bool = False): return bigframes.series.Series(result_block) kurtosis = kurt + kurtosis.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.kurt) def _pivot( self, @@ -3463,6 +3346,8 @@ def _pivot( ) return DataFrame(pivot_block) + @validations.requires_index + @validations.requires_ordering() def pivot( self, *, @@ -3476,6 +3361,8 @@ 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[ @@ -3486,30 +3373,7 @@ 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 ): @@ -3551,17 +3415,13 @@ def pivot_table( columns=columns, index=index, values=values if len(values) > 1 else None, - ) - if fill_value is not None: - pivoted = pivoted.fillna(fill_value) - if sort: - pivoted = pivoted.sort_index() + ).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.sort_index(axis=1) # type: ignore + return pivoted[pivoted.columns.sort_values()] def stack(self, level: LevelsType = -1): if not isinstance(self.columns, pandas.MultiIndex): @@ -3684,29 +3544,100 @@ def merge( *, 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: - from bigframes.core.reshape import merge + 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) - return merge.merge( - self, - right, + left_on, right_on = self._validate_left_right_on( + right, on, left_on=left_on, right_on=right_on + ) + + 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, how, - on, - left_on=left_on, - right_on=right_on, - left_index=left_index, - right_index=right_index, + left_join_ids, + right_join_ids, sort=sort, suffixes=suffixes, ) + return DataFrame(block) + + def _validate_left_right_on( + self, + right: DataFrame, + 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, + ): + if on is not None: + 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." + ) + return on, on + + if left_on is not None and right_on is not None: + return left_on, right_on + + left_cols = self.columns + right_cols = right.columns + common_cols = left_cols.intersection(right_cols) + 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}, " + ) + if ( + not left_cols.join(common_cols, how="inner").is_unique + or not right_cols.join(common_cols, how="inner").is_unique + ): + raise ValueError(f"Data columns not unique: {repr(common_cols)}") + + return common_cols, common_cols def join( self, other: Union[DataFrame, bigframes.series.Series], + *, on: Optional[str] = None, how: str = "left", lsuffix: str = "", @@ -3946,22 +3877,6 @@ def expanding(self, min_periods: int = 1) -> bigframes.core.window.Window: self._block, window, self._block.value_columns ) - def pipe( - self, - func: Union[Callable[..., U], tuple[Callable[..., U], str]], - *args, - **kwargs, - ) -> U: - import bigframes_vendored.pandas.core.common as common - - return common.pipe(self, func, *args, **kwargs) - - def get(self, key, default=None): - try: - return self[key] - except (KeyError, ValueError, IndexError): - return default - def groupby( self, by: typing.Union[ @@ -3991,17 +3906,11 @@ 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( @@ -4014,25 +3923,18 @@ 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] = [] for key in by: if isinstance(key, bigframes.series.Series): - ( - block, - ( - get_column_left, - get_column_right, - ), + block, ( + get_column_left, + get_column_right, ) = block.join(key._block, how="inner" if dropna else "left") col_ids = [ *[get_column_left[value] for value in col_ids], @@ -4054,7 +3956,6 @@ 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: @@ -4073,14 +3974,12 @@ def round(self, decimals: Union[int, dict[Hashable, int]] = 0) -> DataFrame: bigframes.dtypes.BOOL_DTYPE }: if is_mapping: - decimals_dict = typing.cast(dict[typing.Hashable, int], decimals) - if label in decimals_dict: + if label in decimals: # type: ignore exprs.append( ops.round_op.as_expr( col_id, ex.const( - decimals_dict[label], - dtype=bigframes.dtypes.INT_DTYPE, # type: ignore + decimals[label], dtype=bigframes.dtypes.INT_DTYPE # type: ignore ), ) ) @@ -4107,11 +4006,13 @@ def isna(self) -> DataFrame: return self._apply_unary_op(ops.isnull_op) isnull = isna + isnull.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.isna) def notna(self) -> DataFrame: return self._apply_unary_op(ops.notnull_op) notnull = notna + notnull.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.notna) @validations.requires_ordering() def cumsum(self): @@ -4179,22 +4080,7 @@ def _apply_window_op( op, window_spec=window_spec, ) - 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) + return DataFrame(block.select_columns(result_ids)) @validations.requires_ordering() def sample( @@ -4254,12 +4140,10 @@ 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[ @@ -4269,10 +4153,67 @@ 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, @@ -4330,19 +4271,17 @@ def to_csv( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - options: dict[str, Union[bool, str]] = { + options = { "field_delimiter": sep, "header": header, } - result = self._session._executor.execute( + query_job = self._session._executor.export_gcs( export_array.rename_columns(id_overrides), - ex_spec.ExecutionSpec( - ex_spec.GcsOutputSpec( - uri=path_or_buf, format="csv", export_options=tuple(options.items()) - ) - ), + path_or_buf, + format="csv", + export_options=options, ) - self._set_internal_query_job(result.query_job) + self._set_internal_query_job(query_job) return None def to_json( @@ -4385,13 +4324,13 @@ def to_json( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - result = self._session._executor.execute( + query_job = self._session._executor.export_gcs( export_array.rename_columns(id_overrides), - ex_spec.ExecutionSpec( - ex_spec.GcsOutputSpec(uri=path_or_buf, format="json", export_options=()) - ), + path_or_buf, + format="json", + export_options={}, ) - self._set_internal_query_job(result.query_job) + self._set_internal_query_job(query_job) return None def to_gbq( @@ -4464,21 +4403,16 @@ def to_gbq( ) ) - result = self._session._executor.execute( + query_job = self._session._executor.export_gbq( export_array.rename_columns(id_overrides), - ex_spec.ExecutionSpec( - ex_spec.TableOutputSpec( - destination, - cluster_cols=tuple(clustering_fields), - if_exists=if_exists, - ) - ), + destination=destination, + cluster_cols=clustering_fields, + if_exists=if_exists, ) - assert result.query_job is not None - self._set_internal_query_job(result.query_job) + self._set_internal_query_job(query_job) # The query job should have finished, so there should be always be a result table. - result_table = result.query_job.destination + result_table = query_job.destination assert result_table is not None if temp_table_ref: @@ -4514,6 +4448,8 @@ def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: raise ValueError("Cannot convert to array without copy.") return self.to_numpy(dtype=dtype) + __array__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__array__) + def to_parquet( self, path=None, @@ -4544,17 +4480,13 @@ def to_parquet( index=index and self._has_index, ordering_id=bigframes.session._io.bigquery.IO_ORDERING_ID, ) - result = self._session._executor.execute( + query_job = self._session._executor.export_gcs( export_array.rename_columns(id_overrides), - ex_spec.ExecutionSpec( - ex_spec.GcsOutputSpec( - uri=path, - format="parquet", - export_options=tuple(export_options.items()), - ) - ), + path, + format="parquet", + export_options=export_options, ) - self._set_internal_query_job(result.query_job) + self._set_internal_query_job(query_job) return None def to_dict( @@ -4567,9 +4499,7 @@ def to_dict( allow_large_results: Optional[bool] = None, **kwargs, ) -> dict | list[dict]: - return self.to_pandas(allow_large_results=allow_large_results).to_dict( - orient=orient, into=into, **kwargs - ) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_dict(orient, into, **kwargs) # type: ignore def to_excel( self, @@ -4580,7 +4510,7 @@ def to_excel( **kwargs, ) -> None: return self.to_pandas(allow_large_results=allow_large_results).to_excel( - excel_writer, sheet_name=sheet_name, **kwargs + excel_writer, sheet_name, **kwargs ) def to_latex( @@ -4594,11 +4524,7 @@ def to_latex( **kwargs, ) -> str | None: return self.to_pandas(allow_large_results=allow_large_results).to_latex( - buf, - columns=typing.cast(typing.Optional[list[str]], columns), - header=typing.cast(typing.Union[bool, list[str]], header), - index=index, - **kwargs, # type: ignore + buf, columns=columns, header=header, index=index, **kwargs # type: ignore ) def to_records( @@ -4639,24 +4565,24 @@ def to_string( ) -> str | None: return self.to_pandas(allow_large_results=allow_large_results).to_string( buf, - 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, + 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, ) def to_html( @@ -4689,28 +4615,28 @@ def to_html( ) -> str: return self.to_pandas(allow_large_results=allow_large_results).to_html( buf, - 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, + 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, ) def to_markdown( @@ -4722,9 +4648,7 @@ def to_markdown( allow_large_results: Optional[bool] = None, **kwargs, ) -> str | None: - return self.to_pandas(allow_large_results=allow_large_results).to_markdown( - buf, mode=mode, index=index, **kwargs - ) # type: ignore + return self.to_pandas(allow_large_results=allow_large_results).to_markdown(buf, mode, index, **kwargs) # type: ignore def to_pickle(self, path, *, allow_large_results=None, **kwargs) -> None: return self.to_pandas(allow_large_results=allow_large_results).to_pickle( @@ -4823,24 +4747,18 @@ def _prepare_export( return array_value, id_overrides def map(self, func, na_action: Optional[str] = None) -> DataFrame: - from bigframes._config import options - - if not isinstance(func, bigframes.functions.Udf) and not ( - options.experiments.enable_python_transpiler and callable(func) - ): + if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): raise TypeError("the first argument must be callable") if na_action not in {None, "ignore"}: raise ValueError(f"na_action={na_action} not supported") - expr = ops.func_to_expr(func).apply(ex.free_var("input")) - if na_action == "ignore": - # True case, predicate, False case - expr = ops.where_op.as_expr( - expr, ops.notnull_op.as_expr(ex.free_var("input")), ex.const(None) + # TODO(shobs): Support **kwargs + return self._apply_unary_op( + ops.RemoteFunctionOp( + function_def=func.udf_def, apply_on_null=(na_action is None) ) - - return DataFrame(self._block.multi_apply_unary_op(expr)) + ) def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): # In Bigframes BigQuery function, DataFrame '.apply' method is specifically @@ -4853,26 +4771,18 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): ) warnings.warn(msg, category=bfe.FunctionAxisOnePreviewWarning) - from bigframes._config import options - - if not isinstance(func, bigframes.functions.Udf) and not ( - options.experiments.enable_python_transpiler and callable(func) + if not isinstance( + func, + ( + bigframes.functions.BigqueryCallableRoutine, + bigframes.functions.BigqueryCallableRowRoutine, + ), ): raise ValueError( "For axis=1 a BigFrames BigQuery function must be used." ) - if ( - not isinstance(func, bigframes.functions.Udf) - and options.experiments.enable_python_transpiler - and callable(func) - ): - result_block = block_ops.apply_to_block_rows( - func, self._block, *args, **kwargs - ) - return bigframes.series.Series(result_block) - - if func.udf_def.signature.is_row_processor: + if func.is_row_processor: # Early check whether the dataframe dtypes are currently supported # in the bigquery function # NOTE: Keep in sync with the value converters used in the gcf code @@ -4925,83 +4835,46 @@ def apply(self, func, *, axis=0, args: typing.Tuple = (), **kwargs): ) # Apply the function - expr = ops.func_to_expr(func).expr - if not ( - isinstance(expr, ex.OpExpression) - and isinstance(expr.op, ops.NaryOp) - ): - raise TypeError(f"Expected OpExpression with NaryOp, got {expr}") - result_series = rows_as_json_series._apply_nary_op( - expr.op, - list(args), + 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 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. + # 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 # 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 = tuple( - arg.bf_type for arg in func.udf_def.signature.inputs - ) - 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): + # to the order of the input params in the function + udf_input_dtypes = func.udf_def.signature.bf_input_types + if 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) + len(args)} values" - f" ({len(self.columns)} DataFrame columns and" - f" {len(args)} args)." + f"BigFrames BigQuery function takes {len(udf_input_dtypes)}" + f" arguments but DataFrame has {len(self.columns)} columns." ) - end_slice = -len(args) if args else None - if udf_input_dtypes[:end_slice] != tuple(self.dtypes.to_list()): + if udf_input_dtypes != tuple(self.dtypes.to_list()): raise ValueError( - 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 + f"BigFrames BigQuery function takes arguments of types " + f"{udf_input_dtypes} but DataFrame dtypes are {tuple(self.dtypes)}." ) - 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_callable_expr( - ops.func_to_expr(func), op_list + result_series = series_list[0]._apply_nary_op( + ops.NaryRemoteFunctionOp(function_def=func.udf_def), series_list[1:] ) result_series.name = None + result_series = func._post_process_series(result_series) return result_series # At this point column-wise or element-wise bigquery function operation will # be performed (not supported). - if isinstance(func, bigframes.functions.Udf): + if hasattr(func, "bigframes_bigquery_function"): raise formatter.create_exception_with_feedback_link( NotImplementedError, "BigFrames DataFrame '.apply()' does not support BigFrames " @@ -5030,6 +4903,8 @@ 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): @@ -5043,6 +4918,8 @@ 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: @@ -5053,7 +4930,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( @@ -5063,17 +4940,15 @@ 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, pct=pct) - ) + return DataFrame(block_ops.rank(df._block, method, na_option, ascending)) def first_valid_index(self): return applymap = map + applymap.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.map) def _slice( self, @@ -5275,6 +5150,8 @@ def scatter( def __matmul__(self, other) -> DataFrame: return self.dot(other) + __matmul__.__doc__ = inspect.getdoc(vendored_pandas_frame.DataFrame.__matmul__) + @property def struct(self): return bigframes.operations.structs.StructFrameAccessor(self) @@ -5284,3 +5161,16 @@ def _throw_if_null_index(self, opname: str): raise bigframes.exceptions.NullIndexError( f"DataFrame cannot perform {opname} as it has no index. Set an index using set_index." ) + + @property + def semantics(self): + msg = bfe.format_message( + "The 'semantics' property will be removed. Please use 'ai' instead." + ) + warnings.warn(msg, category=FutureWarning) + return bigframes.operations.semantics.Semantics(self) + + @property + def ai(self): + """Returns the accessor for AI operators.""" + return bigframes.operations.ai.AIAccessor(self) diff --git a/bigframes/display/__init__.py b/bigframes/display/__init__.py index aa1371db564..48e52bc7665 100644 --- a/bigframes/display/__init__.py +++ b/bigframes/display/__init__.py @@ -12,37 +12,13 @@ # 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}'") +try: + import anywidget # noqa + from bigframes.display.anywidget import TableWidget -__all__ = ["TableWidget"] + __all__ = ["TableWidget"] +except Exception: + pass diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 01135d6670b..5a20ddcb7f5 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -12,385 +12,98 @@ # 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 logging - -logger = logging.getLogger(__name__) import math -import threading +from typing import Any, Dict, Iterator, List, Optional, Type import uuid -import warnings -from importlib import resources -from typing import Any, Iterator, Optional import pandas as pd import bigframes -import bigframes.dataframe import bigframes.display.html -import bigframes.dtypes as dtypes -from bigframes.core import blocks -# 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. +# 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 + ANYWIDGET_INSTALLED = True except Exception: - _ANYWIDGET_INSTALLED = False + ANYWIDGET_INSTALLED = False -_WIDGET_BASE: type[Any] -if _ANYWIDGET_INSTALLED: - _WIDGET_BASE = anywidget.AnyWidget +WIDGET_BASE: Type[Any] +if ANYWIDGET_INSTALLED: + WIDGET_BASE = anywidget.AnyWidget else: - _WIDGET_BASE = object - - -@dataclasses.dataclass(frozen=True) -class _SortState: - columns: tuple[str, ...] - ascending: tuple[bool, ...] - + WIDGET_BASE = object -@dataclasses.dataclass -class _ExecutionResult: - df_to_set: Optional[bigframes.dataframe.DataFrame] = None - orderable_cols: Optional[list[str]] = None - batches: Optional[blocks.PandasBatches] = None - batch_iter: Optional[Iterator[pd.DataFrame]] = None - cached_batches: Optional[list[pd.DataFrame]] = None - all_data_loaded: bool = False - total_rows: Optional[int] = None - initial_html: Optional[str] = None - error_message: Optional[str] = None - -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. +class TableWidget(WIDGET_BASE): + """ + An interactive, paginated table widget for BigFrames DataFrames. """ - page = traitlets.Int(0).tag(sync=True) - page_size = traitlets.Int(0).tag(sync=True) - max_columns = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) - row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) - table_html = traitlets.Unicode("").tag(sync=True) - sort_context = traitlets.List(traitlets.Dict(), default_value=[]).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 - ) - start_execution = traitlets.Bool(False).tag(sync=True) - is_deferred_mode = traitlets.Bool(False).tag(sync=True) - dry_run_info = traitlets.Unicode("").tag(sync=True) - ping = traitlets.Int(0).tag(sync=True) - - def __init__( - self, - dataframe: ( - bigframes.dataframe.DataFrame - | bigframes.session.deferred.DeferredBigQueryDataFrame - ), - dry_run_info: Optional[str] = None, - ): + def __init__(self, dataframe: bigframes.dataframe.DataFrame): """Initialize the TableWidget. Args: dataframe: The Bigframes Dataframe to display in the widget. """ - if not _ANYWIDGET_INSTALLED: + if not ANYWIDGET_INSTALLED: raise ImportError( - "Please `pip install anywidget traitlets` or " - "`pip install 'bigframes[anywidget]'` to use TableWidget." + "Please `pip install anywidget traitlets` or `pip install 'bigframes[anywidget]'` to use TableWidget." ) - # Enable third-party widgets manager in Google Colab environment. - try: - import sys - - if "google.colab" in sys.modules: - from google.colab import output - - output.enable_custom_widget_manager() - except Exception: - pass - - from bigframes.session import deferred - - is_deferred = False - deferred_df = None - df = None - - if isinstance(dataframe, deferred.DeferredBigQueryDataFrame): - is_deferred = True - deferred_df = dataframe - elif bigframes.options.display.repr_mode == "deferred": - is_deferred = True - df = dataframe - else: - df = dataframe - - from bigframes.core.utils import get_ipython_execution_count - - self._cell_execution_count = get_ipython_execution_count() - super().__init__() + self._dataframe = dataframe - self.is_deferred_mode = is_deferred - self._deferred_dataframe = deferred_df - self._dataframe = df - - if dry_run_info: - self.dry_run_info = dry_run_info - - # Initialize attributes that might be needed by observers first + # 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 - self._execution_result: Optional[_ExecutionResult] = None - # Lock to ensure only one thread at a time is updating the table HTML. - self._setting_html_lock = threading.Lock() + self._cached_batches: List[pd.DataFrame] = [] # respect display options for initial page size initial_page_size = bigframes.options.display.max_rows - initial_max_columns = bigframes.options.display.max_columns + # Initialize data fetching attributes. + self._batches = dataframe.to_pandas_batches(page_size=initial_page_size) + + # set traitlets properties that trigger observers self.page_size = initial_page_size - self.max_columns = initial_max_columns - - if not self.is_deferred_mode: - self._initialize_from_dataframe() - - # 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 - - @traitlets.observe("start_execution") - def _on_start_execution(self, change: dict[str, Any]): - if change["new"]: - import asyncio - - try: - loop = asyncio.get_running_loop() - except RuntimeError: - try: - import tornado.ioloop # type: ignore[import-not-found] - - loop = tornado.ioloop.IOLoop.current().asyncio_loop # type: ignore[attr-defined] - except Exception: - loop = None - - def run_execution(): - try: - self._error_message = None - df = None - if self.is_deferred_mode: - if self._deferred_dataframe is not None: - result = self._deferred_dataframe.execute() - if isinstance(result, bigframes.series.Series): - df = result.to_frame() - elif isinstance(result, bigframes.dataframe.DataFrame): - df = result - else: - raise TypeError( - f"Unexpected result type: {type(result)}" - ) - elif self._dataframe is not None: - df = self._dataframe - else: - df = self._dataframe - - if df is None: - raise ValueError("No DataFrame to execute.") - - df_to_set = df._prepare_display_df() - orderable_cols = self._get_orderable_columns(df_to_set) - - with bigframes.option_context("display.progress_bar", None): - batches = df_to_set.to_pandas_batches( - page_size=self.page_size, - cell_execution_count=self._cell_execution_count, - ) - - total_rows = getattr(batches, "total_rows", None) - - # Fetch the first batch - batch_iter = iter(batches) - try: - initial_batch = next(batch_iter) - cached_batches = [initial_batch] - all_data_loaded = False - except StopIteration: - initial_batch = pd.DataFrame(columns=df_to_set.columns) - cached_batches = [] - all_data_loaded = True - - # Render the HTML - page_data = initial_batch.copy() - start = 0 - if df_to_set._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) - else: - page_data.insert( - 0, "Row", range(start + 1, start + len(page_data) + 1) - ) - - initial_html = bigframes.display.html.render_html( - dataframe=page_data, - table_id=f"table-{self._table_id}", - orderable_columns=orderable_cols, - max_columns=self.max_columns, - ) - - self._execution_result = _ExecutionResult( - df_to_set=df_to_set, - orderable_cols=orderable_cols, - batches=batches, - batch_iter=batch_iter, - cached_batches=cached_batches, - all_data_loaded=all_data_loaded, - total_rows=total_rows, - initial_html=initial_html, - ) - except Exception as e: - logger.warning(f"Error in background execution: {e}") - self._execution_result = _ExecutionResult(error_message=str(e)) - - import sys - - is_colab = "google.colab" in sys.modules - - if loop is not None and loop.is_running() and not is_colab: - loop.call_soon_threadsafe(self._apply_execution_result) - elif is_colab: - # In Google Colab, background thread updates to traitlets are not automatically - # synchronized to the frontend. We rely on the frontend's active pinging - # (which triggers `_on_ping` on the main kernel thread) to apply the result. - pass - else: - self._apply_execution_result() - - self._execution_thread = threading.Thread(target=run_execution, daemon=True) - self._execution_thread.start() - - def _apply_execution_result(self) -> None: - if self._execution_result is None: - return - - result = self._execution_result - self._execution_result = None - - with self.hold_sync(): - if result.error_message is not None: - self._error_message = result.error_message - self.start_execution = False - else: - self._dataframe = result.df_to_set - self.orderable_columns = result.orderable_cols or [] - self._batches = result.batches - self._batch_iter = result.batch_iter - self._cached_batches = result.cached_batches or [] - self._all_data_loaded = result.all_data_loaded - self._last_sort_state = _SortState((), ()) - self.row_count = result.total_rows - self.table_html = result.initial_html or "" - self.is_deferred_mode = False - self.start_execution = False - - @traitlets.observe("ping") - def _on_ping(self, _change: dict[str, Any]): - self._apply_execution_result() - - def _initialize_from_dataframe(self): - if self._dataframe is None: - return - - self.orderable_columns = self._get_orderable_columns(self._dataframe) - - self._initial_load() - - def _get_orderable_columns( - self, dataframe: bigframes.dataframe.DataFrame - ) -> list[str]: - """Determine which columns can be used for client-side sorting.""" - # 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 not all(isinstance(col, str) for col in dataframe.columns): - return [] - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", bigframes.exceptions.JSONDtypeWarning) - warnings.simplefilter("ignore", category=FutureWarning) - return [ - str(col_name) - for col_name, dtype in dataframe.dtypes.items() - if dtypes.is_orderable(dtype) - ] - - def _initial_load(self) -> None: - """Get initial data and row count.""" - # obtain the row counts + + # len(dataframe) is expensive, since it will trigger a + # SELECT COUNT(*) query. It is a must have however. # TODO(b/428238610): Start iterating over the result of `to_pandas_batches()` # before we get here so that the count might already be cached. - with bigframes.option_context("display.progress_bar", None): - 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() + self.row_count = len(dataframe) - @traitlets.observe("_initial_load_complete") - def _on_initial_load_complete(self, change: dict[str, Any]): - if change["new"]: - self._set_table_html() + # get the initial page + self._set_table_html() @functools.cached_property def _esm(self): - """Load JavaScript code from the compiled Angular hybrid bundle.""" - return resources.read_text(bigframes.display, "table_widget_angular.js") + """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") + page = traitlets.Int(0).tag(sync=True) + page_size = traitlets.Int(25).tag(sync=True) + row_count = traitlets.Int(0).tag(sync=True) + table_html = traitlets.Unicode().tag(sync=True) + @traitlets.validate("page") - def _validate_page(self, proposal: dict[str, Any]) -> int: + def _validate_page(self, proposal: Dict[str, Any]) -> int: """Validate and clamp the page number to a valid range. Args: @@ -400,22 +113,11 @@ def _validate_page(self, proposal: dict[str, Any]) -> int: 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. + value = proposal["value"] 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) @@ -423,7 +125,7 @@ def _validate_page(self, proposal: dict[str, Any]) -> int: return max(0, min(value, max_page)) @traitlets.validate("page_size") - def _validate_page_size(self, proposal: dict[str, Any]) -> int: + def _validate_page_size(self, proposal: Dict[str, Any]) -> int: """Validate page size to ensure it's positive and reasonable. Args: @@ -443,14 +145,6 @@ def _validate_page_size(self, proposal: dict[str, Any]) -> int: max_page_size = 1000 return min(value, max_page_size) - @traitlets.validate("max_columns") - def _validate_max_columns(self, proposal: dict[str, Any]) -> int: - """Validate max columns to ensure it's positive or 0 (for all).""" - value = proposal["value"] - if value is None: - return 0 # Normalize None to 0 for traitlet - return max(0, value) - def _get_next_batch(self) -> bool: """ Gets the next batch of data from the generator and appends to cache. @@ -474,170 +168,58 @@ def _get_next_batch(self) -> bool: 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) + 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: - if self._dataframe is not None: - return pd.DataFrame(columns=self._dataframe.columns) - return pd.DataFrame() - return pd.concat(self._cached_batches) + return pd.DataFrame(columns=self._dataframe.columns) + return pd.concat(self._cached_batches, ignore_index=True) - def _reset_batch_cache(self) -> None: - """Resets batch caching attributes.""" + def _reset_batches_for_new_page_size(self): + """Reset the batch iterator when page size changes.""" + self._batches = self._dataframe.to_pandas_batches(page_size=self.page_size) 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.""" - if self._dataframe is None: - return - with bigframes.option_context("display.progress_bar", None): - self._batches = self._dataframe.to_pandas_batches( - page_size=self.page_size, - cell_execution_count=self._cell_execution_count, - ) - - self._reset_batch_cache() - - def _set_table_html(self) -> None: + def _set_table_html(self): """Sets the current html data based on the current page and page size.""" - if self.is_deferred_mode: - return - - new_page = None - with ( - self._setting_html_lock, - bigframes.option_context("display.progress_bar", None), - ): - if self._error_message: - self.table_html = ( - f"
{self._error_message}
" - ) - return - - if self._dataframe is None: - self.table_html = "
Internal Error: DataFrame is missing.
" - return - - # Apply sorting if a column is selected - df_to_display = self._dataframe - sort_columns = [item["column"] for item in self.sort_context] - sort_ascending = [item["ascending"] for item in self.sort_context] - - if sort_columns: - # TODO(b/463715504): Support sorting by index columns. - df_to_display = df_to_display.sort_values( - by=sort_columns, ascending=sort_ascending - ) - - # Reset batches when sorting changes - current_sort_state = _SortState(tuple(sort_columns), tuple(sort_ascending)) - if self._last_sort_state != current_sort_state: - self._batches = df_to_display.to_pandas_batches( - page_size=self.page_size, - cell_execution_count=self._cell_execution_count, - ) - self._reset_batch_cache() - self._last_sort_state = current_sort_state - 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 + 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 - 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}", - orderable_columns=self.orderable_columns, - max_columns=self.max_columns, - ) - - 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_context") - def _sort_changed(self, _change: dict[str, Any]): - """Handler for when sorting parameters change from the frontend.""" - self._set_table_html() + else: + break + + # Get the data for the current page + page_data = cached_data.iloc[start:end] + + # Generate HTML table + self.table_html = bigframes.display.html.render_html( + dataframe=page_data, + table_id=f"table-{self._table_id}", + ) @traitlets.observe("page") - def _page_changed(self, _change: dict[str, Any]) -> None: + def _page_changed(self, _change: Dict[str, Any]): """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: + def _page_size_changed(self, _change: Dict[str, Any]): """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_context = [] # 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() - - @traitlets.observe("max_columns") - def _max_columns_changed(self, _change: dict[str, Any]) -> None: - """Handler for when max columns is changed from the frontend.""" - if not self._initial_load_complete: - return - self._set_table_html() diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 603d53e6866..f1133789b41 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -17,26 +17,14 @@ from __future__ import annotations import html -import json -import traceback -import typing -import warnings -from typing import Any, Union import pandas as pd import pandas.api.types -import bigframes -import bigframes.formatting_helpers as formatter -from bigframes._config import display_options, options -from bigframes.display import plaintext +from bigframes._config import options -if typing.TYPE_CHECKING: - import bigframes.dataframe - import bigframes.series - -def _is_dtype_numeric(dtype: Any) -> bool: +def _is_dtype_numeric(dtype) -> bool: """Check if a dtype is numeric for alignment purposes.""" return pandas.api.types.is_numeric_dtype(dtype) @@ -45,342 +33,47 @@ def render_html( *, dataframe: pd.DataFrame, table_id: str, - orderable_columns: list[str] | None = None, - max_columns: int | 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''] - - # Handle column truncation - columns = list(dataframe.columns) - if max_columns is not None and max_columns > 0 and len(columns) > max_columns: - half = max_columns // 2 - left_columns = columns[:half] - # Ensure we don't take more than available if half is 0 or calculation is weird, - # but typical case is safe. - right_count = max_columns - half - right_columns = columns[-right_count:] if right_count > 0 else [] - show_ellipsis = True - else: - left_columns = columns - right_columns = [] - show_ellipsis = False - - table_html_parts.append( - _render_table_header( - dataframe, orderable_columns, left_columns, right_columns, show_ellipsis - ) - ) - table_html_parts.append( - _render_table_body(dataframe, left_columns, right_columns, show_ellipsis) - ) - table_html_parts.append("
") - return "".join(table_html_parts) - - -def _render_table_header( - dataframe: pd.DataFrame, - orderable_columns: list[str], - left_columns: list[Any], - right_columns: list[Any], - show_ellipsis: bool, -) -> str: - """Render the header of the HTML table.""" - header_parts = [" ", " "] - - def render_col_header(col): - 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))}
" - ) - - for col in left_columns: - render_col_header(col) + table_html = [f''] + precision = options.display.precision - if show_ellipsis: - header_parts.append( - ' ' + # Render table head + table_html.append(" ") + table_html.append(' ') + for col in dataframe.columns: + table_html.append( + f' ' ) + table_html.append(" ") + table_html.append(" ") - for col in right_columns: - render_col_header(col) - - header_parts.extend([" ", " "]) - return "\n".join(header_parts) - - -def _render_table_body( - dataframe: pd.DataFrame, - left_columns: list[Any], - right_columns: list[Any], - show_ellipsis: bool, -) -> str: - """Render the body of the HTML table.""" - body_parts = [" "] - precision = options.display.precision - + # Render table body + table_html.append(" ") for i in range(len(dataframe)): - body_parts.append(" ") + table_html.append(" ") row = dataframe.iloc[i] - - def render_col_cell(col_name): - value = row[col_name] + for col_name, value in row.items(): dtype = dataframe.dtypes.loc[col_name] # type: ignore align = "right" if _is_dtype_numeric(dtype) else "left" + table_html.append( + ' ' - ) + table_html.append(' <NA>') else: if isinstance(value, float): - cell_content = f"{value:.{precision}f}" + formatted_value = f"{value:.{precision}f}" + table_html.append(f" {html.escape(formatted_value)}") else: - cell_content = str(value) - body_parts.append( - f' " - ) - - for col in left_columns: - render_col_cell(col) - - if show_ellipsis: - # Ellipsis cell - body_parts.append(' ') - - for col in right_columns: - render_col_cell(col) - - 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, -) -> str: - """Create an HTML representation of the DataFrame or Series.""" - import bigframes.series - - opts = options.display - with display_options.pandas_repr(opts): - if isinstance(obj, bigframes.series.Series): - pd_series = pandas_df.iloc[:, 0] - try: - 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: - # _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]: - import bigframes.series - - is_series = isinstance(obj, bigframes.series.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, - dry_run_info: str | None = 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. - """ - import bigframes.series - from bigframes import display - - if isinstance(obj, bigframes.series.Series): - df = obj.to_frame() - else: - df = obj - - from bigframes.session import deferred - - if ( - not isinstance(df, deferred.DeferredBigQueryDataFrame) - and bigframes.options.display.repr_mode != "deferred" - ): - display_df = df._prepare_display_df() - else: - display_df = df - - widget = display.TableWidget(display_df, dry_run_info=dry_run_info) - 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) - - if dry_run_info: - widget_repr["text/plain"] = dry_run_info - else: - widget_repr["text/html"] = create_html_representation( - obj, - cached_pd, - total_rows, - total_columns, - ) - 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]: - import bigframes.series - - opts = options.display - if isinstance(obj, bigframes.series.Series): - df = obj.to_frame() - else: - df = obj - - df = df._prepare_display_df() - 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) - - 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.render_mode == "anywidget" - or opts.repr_mode == "anywidget" - or opts.repr_mode == "deferred" - ): - try: - with bigframes.option_context("display.progress_bar", None): - with warnings.catch_warnings(): - warnings.simplefilter( - "ignore", category=bigframes.exceptions.JSONDtypeWarning - ) - warnings.simplefilter("ignore", category=FutureWarning) - dry_run_info = None - if opts.repr_mode == "deferred": - dry_run_job = obj._compute_dry_run() - dry_run_info = formatter.repr_query_job(dry_run_job) - return get_anywidget_bundle( - obj, - include=include, - exclude=exclude, - dry_run_info=dry_run_info, - ) - except Exception: - # 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 or failed to load. " - "Please `pip install anywidget traitlets` or " - "`pip install 'bigframes[anywidget]'` to use interactive tables. " - f"Falling back to static HTML. Error: {traceback.format_exc()}" - ) - if opts.repr_mode == "deferred": - return repr_mimebundle_deferred(obj) - - bundle = repr_mimebundle_head(obj) - if opts.render_mode == "plaintext": - bundle.pop("text/html", None) + table_html.append(f" {html.escape(str(value))}") + table_html.append(" ") + table_html.append(" ") + table_html.append(" ") + table_html.append("
...
{html.escape(str(col))}
'.format(align) + ) # 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>' - f"{html.escape(cell_content)}...
") - return bundle + return "\n".join(table_html) diff --git a/bigframes/display/plaintext.py b/bigframes/display/plaintext.py deleted file mode 100644 index 2f7bc1df07f..00000000000 --- a/bigframes/display/plaintext.py +++ /dev/null @@ -1,102 +0,0 @@ -# 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 index da0a701d694..0c6c5fa5efe 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -14,234 +14,66 @@ * limitations under the License. */ -/* Increase specificity to override framework styles without !important */ -.bigframes-widget.bigframes-widget { - /* Default Light Mode Variables */ - --bf-bg: white; - --bf-border-color: #ccc; - --bf-error-bg: #fbe; - --bf-error-border: red; - --bf-error-fg: black; - --bf-fg: black; - --bf-header-bg: #f5f5f5; - --bf-null-fg: gray; - --bf-row-even-bg: #f5f5f5; - --bf-row-odd-bg: white; - - background-color: var(--bf-bg); - box-sizing: border-box; - color: var(--bf-fg); - display: flex; - flex-direction: column; - font-family: - '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', sans-serif; - margin: 0; - padding: 0; -} - -.bigframes-widget * { - box-sizing: border-box; -} - -/* Dark Mode Overrides: - * 1. @media (prefers-color-scheme: dark) - System-wide dark mode - * 2. .bigframes-dark-mode - Explicit class for VSCode theme detection - * 3. html[theme="dark"], body[data-theme="dark"] - Colab/Pantheon manual override - */ -@media (prefers-color-scheme: dark) { - .bigframes-widget.bigframes-widget { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; - } -} - -.bigframes-widget.bigframes-dark-mode.bigframes-dark-mode, -html[theme='dark'] .bigframes-widget.bigframes-widget, -body[data-theme='dark'] .bigframes-widget.bigframes-widget { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; +.bigframes-widget { + display: inline-block; } .bigframes-widget .table-container { - background-color: var(--bf-bg); - margin: 0; - max-height: 620px; - overflow: auto; - padding: 0; + max-height: 620px; + overflow: auto; } .bigframes-widget .footer { - align-items: center; - background-color: var(--bf-bg); - color: var(--bf-fg); - display: flex; - font-size: 0.8rem; - justify-content: space-between; - padding: 8px; + align-items: center; + display: flex; + font-size: 0.8rem; + padding-top: 8px; } .bigframes-widget .footer > * { - flex: 1; + 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; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + padding: 4px; } -.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 .settings { - align-items: center; - display: flex; - flex-direction: row; - gap: 16px; - justify-content: end; -} - -.bigframes-widget .page-size, -.bigframes-widget .max-columns { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; -} - -.bigframes-widget .page-size label, -.bigframes-widget .max-columns label { - margin-right: 8px; -} - -.bigframes-widget table.bigframes-widget-table, -.bigframes-widget table.dataframe { - background-color: var(--bf-bg); - border: 1px solid var(--bf-border-color); - border-collapse: collapse; - border-spacing: 0; - box-shadow: none; - color: var(--bf-fg); - margin: 0; - outline: none; - text-align: left; - width: auto; /* Fix stretching */ -} - -.bigframes-widget tr { - border: none; +.bigframes-widget table { + border-collapse: collapse; + text-align: left; } .bigframes-widget th { - background-color: var(--bf-header-bg); - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0; - position: sticky; - text-align: left; - top: 0; - z-index: 1; -} - -.bigframes-widget td { - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0.5em; -} - -.bigframes-widget table tbody tr:nth-child(odd), -.bigframes-widget table tbody tr:nth-child(odd) td { - background-color: var(--bf-row-odd-bg); -} - -.bigframes-widget table tbody tr:nth-child(even), -.bigframes-widget table tbody tr:nth-child(even) td { - background-color: var(--bf-row-even-bg); -} - -.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; + background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); + /* Uncomment once we support sorting: cursor: pointer; */ + position: sticky; + top: 0; + z-index: 1; } .bigframes-widget button { - background-color: transparent; - border: 1px solid currentColor; - border-radius: 4px; - color: inherit; - cursor: pointer; - display: inline-block; - padding: 2px 8px; - text-align: center; - text-decoration: none; - user-select: none; - vertical-align: middle; + 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: var(--bf-error-bg); - border: 1px solid var(--bf-error-border); - border-radius: 4px; - color: var(--bf-error-fg); - 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: var(--bf-null-fg); -} - -.bigframes-widget .debug-info { - border-top: 1px solid var(--bf-border-color); + opacity: 0.65; + pointer-events: none; } diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 314bf771d0e..6b4d99ff28d 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -1,4 +1,4 @@ -/* +/** * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); @@ -15,336 +15,146 @@ */ const ModelProperty = { - ERROR_MESSAGE: 'error_message', - ORDERABLE_COLUMNS: 'orderable_columns', - PAGE: 'page', - PAGE_SIZE: 'page_size', - ROW_COUNT: 'row_count', - SORT_CONTEXT: 'sort_context', - TABLE_HTML: 'table_html', - MAX_COLUMNS: 'max_columns', + PAGE: "page", + PAGE_SIZE: "page_size", + ROW_COUNT: "row_count", + TABLE_HTML: "table_html", }; const Event = { - CHANGE: 'change', - CHANGE_TABLE_HTML: 'change:table_html', - CLICK: 'click', + CHANGE: "change", + CHANGE_TABLE_HTML: `change:${ModelProperty.TABLE_HTML}`, + CLICK: "click", }; /** * Renders the interactive table widget. - * @param {{ model: any, el: !HTMLElement }} props - The widget properties. + * @param {{ + * model: any, + * el: HTMLElement + * }} options */ function render({ model, el }) { - el.classList.add('bigframes-widget'); - - 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'); - - /** Detects theme and applies necessary style overrides. */ - function updateTheme() { - const body = document.body; - const isDark = - body.classList.contains('vscode-dark') || - body.classList.contains('theme-dark') || - body.dataset.theme === 'dark' || - body.getAttribute('data-vscode-theme-kind') === 'vscode-dark'; - - if (isDark) { - el.classList.add('bigframes-dark-mode'); - } else { - el.classList.remove('bigframes-dark-mode'); - } - } - - updateTheme(); - // Re-check after mount to ensure parent styling is applied. - setTimeout(updateTheme, 300); - - const observer = new MutationObserver(updateTheme); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['class', 'data-theme', 'data-vscode-theme-kind'], - }); - - // Settings controls container - const settingsContainer = document.createElement('div'); - settingsContainer.classList.add('settings'); - - // 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:'; - - 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); - } - - // Max columns controls - const maxColumnsContainer = document.createElement('div'); - maxColumnsContainer.classList.add('max-columns'); - const maxColumnsLabel = document.createElement('label'); - const maxColumnsInput = document.createElement('select'); - - maxColumnsLabel.textContent = 'Max columns:'; - - // 0 represents "All" (all columns) - const maxColumnOptions = [5, 10, 15, 20, 0]; - for (const cols of maxColumnOptions) { - const option = document.createElement('option'); - option.value = cols; - option.textContent = cols === 0 ? 'All' : cols; - - const currentMax = model.get(ModelProperty.MAX_COLUMNS); - // Handle None/null from python as 0/All - const currentMaxVal = - currentMax === null || currentMax === undefined ? 0 : currentMax; - - if (cols === currentMaxVal) { - option.selected = true; - } - maxColumnsInput.appendChild(option); - } - - 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) { - rowCountLabel.textContent = 'Total rows unknown'; - pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; - prevPage.disabled = currentPage === 0; - nextPage.disabled = false; - } else if (rowCount === 0) { - rowCountLabel.textContent = '0 total rows'; - pageIndicator.textContent = 'Page 1 of 1'; - prevPage.disabled = true; - nextPage.disabled = true; - } else { - 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; - } - - function handlePageChange(direction) { - const currentPage = model.get(ModelProperty.PAGE); - model.set(ModelProperty.PAGE, currentPage + direction); - model.save_changes(); - } - - function handlePageSizeChange(newSize) { - model.set(ModelProperty.PAGE_SIZE, newSize); - model.set(ModelProperty.PAGE, 0); - model.save_changes(); - } - - let isHeightInitialized = false; - - function handleTableHTMLChange() { - tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); - - // After the first render, dynamically set the container height to fit the - // initial page (usually 10 rows) and then lock it. - setTimeout(() => { - if (!isHeightInitialized) { - const table = tableContainer.querySelector('table'); - if (table) { - const tableHeight = table.offsetHeight; - // Add a small buffer(e.g. 2px) for borders to avoid scrollbars. - if (tableHeight > 0) { - tableContainer.style.height = `${tableHeight + 2}px`; - isHeightInitialized = true; - } - } - } - }, 0); - - const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); - const currentSortContext = model.get(ModelProperty.SORT_CONTEXT) || []; - - const getSortIndex = (colName) => - currentSortContext.findIndex((item) => item.column === colName); - - const headers = tableContainer.querySelectorAll('th'); - headers.forEach((header) => { - const headerDiv = header.querySelector('div'); - const columnName = headerDiv.textContent.trim(); - - if (columnName && sortableColumns.includes(columnName)) { - header.style.cursor = 'pointer'; - - 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) - const sortIndex = getSortIndex(columnName); - - if (sortIndex !== -1) { - const isAscending = currentSortContext[sortIndex].ascending; - indicator = isAscending ? '▲' : '▼'; - indicatorSpan.style.visibility = 'visible'; // Sorted arrows always visible - } else { - indicatorSpan.style.visibility = 'hidden'; - } - indicatorSpan.textContent = indicator; - - const existingIndicator = headerDiv.querySelector('.sort-indicator'); - if (existingIndicator) { - headerDiv.removeChild(existingIndicator); - } - headerDiv.appendChild(indicatorSpan); - - header.addEventListener('mouseover', () => { - if (getSortIndex(columnName) === -1) { - indicatorSpan.style.visibility = 'visible'; - } - }); - header.addEventListener('mouseout', () => { - if (getSortIndex(columnName) === -1) { - indicatorSpan.style.visibility = 'hidden'; - } - }); - - // Add click handler for three-state toggle - header.addEventListener(Event.CLICK, (event) => { - const sortIndex = getSortIndex(columnName); - let newContext = [...currentSortContext]; - - if (event.shiftKey) { - if (sortIndex !== -1) { - // Already sorted. Toggle or Remove. - if (newContext[sortIndex].ascending) { - // Asc -> Desc - // Clone object to avoid mutation issues - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false, - }; - } else { - // Desc -> Remove - newContext.splice(sortIndex, 1); - } - } else { - // Not sorted -> Append Asc - newContext.push({ column: columnName, ascending: true }); - } - } else { - // No shift key. Single column mode. - if (sortIndex !== -1 && newContext.length === 1) { - // Already only this column. Toggle or Remove. - if (newContext[sortIndex].ascending) { - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false, - }; - } else { - newContext = []; - } - } else { - // Start fresh with this column - newContext = [{ column: columnName, ascending: true }]; - } - } - - model.set(ModelProperty.SORT_CONTEXT, newContext); - model.save_changes(); - }); - } - }); - - updateButtonStates(); - } - - function handleErrorMessageChange() { - const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); - if (errorMsg) { - errorContainer.textContent = errorMsg; - errorContainer.style.display = 'block'; - } else { - errorContainer.style.display = 'none'; - } - } - - 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); - } - }); - - maxColumnsInput.addEventListener(Event.CHANGE, (e) => { - const newVal = Number(e.target.value); - model.set(ModelProperty.MAX_COLUMNS, newVal); - model.save_changes(); - }); - - 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); - - paginationContainer.appendChild(prevPage); - paginationContainer.appendChild(pageIndicator); - paginationContainer.appendChild(nextPage); - - pageSizeContainer.appendChild(pageSizeLabel); - pageSizeContainer.appendChild(pageSizeInput); - - maxColumnsContainer.appendChild(maxColumnsLabel); - maxColumnsContainer.appendChild(maxColumnsInput); - - settingsContainer.appendChild(maxColumnsContainer); - settingsContainer.appendChild(pageSizeContainer); - - footer.appendChild(rowCountLabel); - footer.appendChild(paginationContainer); - footer.appendChild(settingsContainer); - - el.appendChild(errorContainer); - el.appendChild(tableContainer); - el.appendChild(footer); - - handleTableHTMLChange(); - handleErrorMessageChange(); + // Main container with a unique class for CSS scoping + el.classList.add("bigframes-widget"); + + // Structure + const tableContainer = document.createElement("div"); + const footer = document.createElement("div"); + + // Footer: Total rows label + const rowCountLabel = document.createElement("div"); + + // Footer: Pagination controls + const paginationContainer = document.createElement("div"); + const prevPage = document.createElement("button"); + const paginationLabel = document.createElement("span"); + const nextPage = document.createElement("button"); + + // Footer: Page size controls + const pageSizeContainer = document.createElement("div"); + const pageSizeLabel = document.createElement("label"); + const pageSizeSelect = document.createElement("select"); + + // Add CSS classes + tableContainer.classList.add("table-container"); + footer.classList.add("footer"); + paginationContainer.classList.add("pagination"); + pageSizeContainer.classList.add("page-size"); + + // Configure pagination buttons + prevPage.type = "button"; + nextPage.type = "button"; + prevPage.textContent = "Prev"; + nextPage.textContent = "Next"; + + // Configure page size selector + pageSizeLabel.textContent = "Page Size"; + for (const size of [10, 25, 50, 100]) { + const option = document.createElement("option"); + option.value = size; + option.textContent = size; + if (size === model.get(ModelProperty.PAGE_SIZE)) { + option.selected = true; + } + pageSizeSelect.appendChild(option); + } + + /** Updates the footer states and page label based on the model. */ + function updateButtonStates() { + const rowCount = model.get(ModelProperty.ROW_COUNT); + const pageSize = model.get(ModelProperty.PAGE_SIZE); + const currentPage = model.get(ModelProperty.PAGE); + const totalPages = Math.ceil(rowCount / pageSize); + + rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; + paginationLabel.textContent = `Page ${( + currentPage + 1 + ).toLocaleString()} of ${(totalPages || 1).toLocaleString()}`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = currentPage >= totalPages - 1; + pageSizeSelect.value = pageSize; + } + + /** + * Increments or decrements the page in the model. + * @param {number} direction - `1` for next, `-1` for previous. + */ + function handlePageChange(direction) { + const current = model.get(ModelProperty.PAGE); + const next = current + direction; + model.set(ModelProperty.PAGE, next); + model.save_changes(); + } + + /** + * Handles changes to the page size from the dropdown. + * @param {number} size - The new page size. + */ + function handlePageSizeChange(size) { + const currentSize = model.get(ModelProperty.PAGE_SIZE); + if (size !== currentSize) { + model.set(ModelProperty.PAGE_SIZE, size); + 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); + updateButtonStates(); + } + + // Add event listeners + prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); + nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); + pageSizeSelect.addEventListener(Event.CHANGE, (e) => { + const newSize = Number(e.target.value); + if (newSize) { + handlePageSizeChange(newSize); + } + }); + model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); + + // Assemble the DOM + paginationContainer.appendChild(prevPage); + paginationContainer.appendChild(paginationLabel); + paginationContainer.appendChild(nextPage); + + pageSizeContainer.appendChild(pageSizeLabel); + pageSizeContainer.appendChild(pageSizeSelect); + + footer.appendChild(rowCountLabel); + footer.appendChild(paginationContainer); + footer.appendChild(pageSizeContainer); + + el.appendChild(tableContainer); + el.appendChild(footer); + + // Initial render + handleTableHTMLChange(); } export default { render }; diff --git a/bigframes/display/table_widget_angular.js b/bigframes/display/table_widget_angular.js deleted file mode 100644 index ad1697def54..00000000000 --- a/bigframes/display/table_widget_angular.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -var Ba=Object.defineProperty,qa=Object.defineProperties,Ua=Object.getOwnPropertyDescriptors,Hi=Object.getOwnPropertySymbols,Za=Object.prototype.hasOwnProperty,$a=Object.prototype.propertyIsEnumerable,zi=(e,t,n)=>t in e?Ba(e,t,{enumerable:!0,configurable:!0,writable:!0,value:n}):e[t]=n,$=(e,t)=>{for(var n in t||={})Za.call(t,n)&&zi(e,n,t[n]);if(Hi)for(var n of Hi(t))$a.call(t,n)&&zi(e,n,t[n]);return e},Q=(e,t)=>qa(e,Ua(t)),R=null,Ft=!1,Wr=1,Qa=null,ne=Symbol("SIGNAL");function m(e){let t=R;return R=e,t}function Wa(){return R}var St={version:0,lastCleanEpoch:0,dirty:!1,producers:void 0,producersTail:void 0,consumers:void 0,consumersTail:void 0,recomputing:!1,consumerAllowSignalWrites:!1,consumerIsAlwaysLive:!1,kind:"unknown",producerMustRecompute:()=>!1,producerRecomputeValue:()=>{},consumerMarkedDirty:()=>{},consumerOnSignalRead:()=>{}};function Wo(e){if(Ft)throw new Error("");if(R===null)return;R.consumerOnSignalRead(e);let t=R.producersTail;if(t!==void 0&&t.producer===e)return;let n,r=R.recomputing;if(r&&(n=t!==void 0?t.nextProducer:R.producers,n!==void 0&&n.producer===e)){R.producersTail=n,n.lastReadVersion=e.version;return}let i=e.consumersTail;if(i!==void 0&&i.consumer===R&&(!r||Ja(i,R)))return;let o=tt(R),s={producer:e,consumer:R,nextProducer:n,prevConsumer:i,lastReadVersion:e.version,nextConsumer:void 0};R.producersTail=s,t!==void 0?t.nextProducer=s:R.producers=s,o&&Xo(e,s)}function Ga(){Wr++}function Go(e){if(!(tt(e)&&!e.dirty)&&!(!e.dirty&&e.lastCleanEpoch===Wr)){if(!e.producerMustRecompute(e)&&!Yr(e)){Bi(e);return}e.producerRecomputeValue(e),Bi(e)}}function Yo(e){if(e.consumers===void 0)return;let t=Ft;Ft=!0;try{for(let n=e.consumers;n!==void 0;n=n.nextConsumer){let r=n.consumer;r.dirty||Ya(r)}}finally{Ft=t}}function Ko(){return R?.consumerAllowSignalWrites!==!1}function Ya(e){e.dirty=!0,Yo(e),e.consumerMarkedDirty?.(e)}function Bi(e){e.dirty=!1,e.lastCleanEpoch=Wr}function Gt(e){return e&&Ka(e),m(e)}function Ka(e){e.producersTail=void 0,e.recomputing=!0}function Gr(e,t){m(t),e&&Xa(e)}function Xa(e){e.recomputing=!1;let t=e.producersTail,n=t!==void 0?t.nextProducer:e.producers;if(n!==void 0){if(tt(e))do n=Kr(n);while(n!==void 0);t!==void 0?t.nextProducer=void 0:e.producers=void 0}}function Yr(e){for(let t=e.producers;t!==void 0;t=t.nextProducer){let n=t.producer,r=t.lastReadVersion;if(r!==n.version||(Go(n),r!==n.version))return!0}return!1}function yn(e){if(tt(e)){let t=e.producers;for(;t!==void 0;)t=Kr(t)}e.producers=void 0,e.producersTail=void 0,e.consumers=void 0,e.consumersTail=void 0}function Xo(e,t){let n=e.consumersTail,r=tt(e);if(n!==void 0?(t.nextConsumer=n.nextConsumer,n.nextConsumer=t):(t.nextConsumer=void 0,e.consumers=t),t.prevConsumer=n,e.consumersTail=t,!r)for(let i=e.producers;i!==void 0;i=i.nextProducer)Xo(i.producer,i)}function Kr(e){let t=e.producer,n=e.nextProducer,r=e.nextConsumer,i=e.prevConsumer;if(e.nextConsumer=void 0,e.prevConsumer=void 0,r!==void 0?r.prevConsumer=i:t.consumersTail=i,i!==void 0)i.nextConsumer=r;else if(t.consumers=r,!tt(t)){let o=t.producers;for(;o!==void 0;)o=Kr(o)}return n}function tt(e){return e.consumerIsAlwaysLive||e.consumers!==void 0}function Jo(e){Qa?.(e)}function Ja(e,t){let n=t.producersTail;if(n!==void 0){let r=t.producers;do{if(r===e)return!0;if(r===n)break;r=r.nextProducer}while(r!==void 0)}return!1}function es(e,t){return Object.is(e,t)}function eu(e,t){let n=Object.create(tu);n.computation=e,t!==void 0&&(n.equal=t);let r=()=>{if(Go(n),Wo(n),n.value===Ht)throw n.error;return n.value};return r[ne]=n,Jo(n),r}var Ln=Symbol("UNSET"),jn=Symbol("COMPUTING"),Ht=Symbol("ERRORED"),tu=Q($({},St),{value:Ln,dirty:!0,error:null,equal:es,kind:"computed",producerMustRecompute(e){return e.value===Ln||e.value===jn},producerRecomputeValue(e){if(e.value===jn)throw new Error("");let t=e.value;e.value=jn;let n=Gt(e),r,i=!1;try{r=e.computation(),m(null),i=t!==Ln&&t!==Ht&&r!==Ht&&e.equal(t,r)}catch(o){r=Ht,e.error=o}finally{Gr(e,n)}if(i){e.value=t;return}e.value=r,e.version++}});function nu(){throw new Error}var ts=nu;function ns(e){ts(e)}function ru(e){ts=e}var iu=null;function ou(e,t){let n=Object.create(au);n.value=e,t!==void 0&&(n.equal=t);let r=()=>su(n);return r[ne]=n,Jo(n),[r,i=>rs(n,i),i=>lu(n,i)]}function su(e){return Wo(e),e.value}function rs(e,t){Ko()||ns(e),e.equal(e.value,t)||(e.value=t,uu(e))}function lu(e,t){Ko()||ns(e),rs(e,t(e.value))}var au=Q($({},St),{equal:es,value:void 0,kind:"signal"});function uu(e){e.version++,Ga(),Yo(e),iu?.(e)}var cu=Q($({},St),{consumerIsAlwaysLive:!0,consumerAllowSignalWrites:!0,dirty:!0,kind:"effect"});function du(e){if(e.dirty=!1,e.version>0&&!Yr(e))return;e.version++;let t=Gt(e);try{e.cleanup(),e.fn()}finally{Gr(e,t)}}function Y(e){return typeof e=="function"}function is(e){let t=e(n=>{Error.call(n),n.stack=new Error().stack});return t.prototype=Object.create(Error.prototype),t.prototype.constructor=t,t}var Fn=is(e=>function(t){e(this),this.message=t?`${t.length} errors occurred during unsubscription: -${t.map((n,r)=>`${r+1}) ${n.toString()}`).join(` - `)}`:"",this.name="UnsubscriptionError",this.errors=t});function or(e,t){if(e){let n=e.indexOf(t);0<=n&&e.splice(n,1)}}var me=class sr{constructor(t){this.initialTeardown=t,this.closed=!1,this._parentage=null,this._finalizers=null}unsubscribe(){let t;if(!this.closed){this.closed=!0;let{_parentage:n}=this;if(n)if(this._parentage=null,Array.isArray(n))for(let o of n)o.remove(this);else n.remove(this);let{initialTeardown:r}=this;if(Y(r))try{r()}catch(o){t=o instanceof Fn?o.errors:[o]}let{_finalizers:i}=this;if(i){this._finalizers=null;for(let o of i)try{qi(o)}catch(s){t=t??[],s instanceof Fn?t=[...t,...s.errors]:t.push(s)}}if(t)throw new Fn(t)}}add(t){var n;if(t&&t!==this)if(this.closed)qi(t);else{if(t instanceof sr){if(t.closed||t._hasParent(this))return;t._addParent(this)}(this._finalizers=(n=this._finalizers)!==null&&n!==void 0?n:[]).push(t)}}_hasParent(t){let{_parentage:n}=this;return n===t||Array.isArray(n)&&n.includes(t)}_addParent(t){let{_parentage:n}=this;this._parentage=Array.isArray(n)?(n.push(t),n):n?[n,t]:t}_removeParent(t){let{_parentage:n}=this;n===t?this._parentage=null:Array.isArray(n)&&or(n,t)}remove(t){let{_finalizers:n}=this;n&&or(n,t),t instanceof sr&&t._removeParent(this)}};me.EMPTY=(()=>{let e=new me;return e.closed=!0,e})();var os=me.EMPTY;function ss(e){return e instanceof me||e&&"closed"in e&&Y(e.remove)&&Y(e.add)&&Y(e.unsubscribe)}function qi(e){Y(e)?e():e.unsubscribe()}var je={onUnhandledError:null,onStoppedNotification:null,Promise:void 0,useDeprecatedSynchronousErrorHandling:!1,useDeprecatedNextContext:!1},Yt={setTimeout(e,t,...n){let{delegate:r}=Yt;return r?.setTimeout?r.setTimeout(e,t,...n):setTimeout(e,t,...n)},clearTimeout(e){let{delegate:t}=Yt;return(t?.clearTimeout||clearTimeout)(e)},delegate:void 0};function fu(e){Yt.setTimeout(()=>{let{onUnhandledError:t}=je;if(t)t(e);else throw e})}function Ui(){}var hu=Xr("C",void 0,void 0);function pu(e){return Xr("E",void 0,e)}function gu(e){return Xr("N",e,void 0)}function Xr(e,t,n){return{kind:e,value:t,error:n}}var ke=null;function zt(e){if(je.useDeprecatedSynchronousErrorHandling){let t=!ke;if(t&&(ke={errorThrown:!1,error:null}),e(),t){let{errorThrown:n,error:r}=ke;if(ke=null,n)throw r}}else e()}function mu(e){je.useDeprecatedSynchronousErrorHandling&&ke&&(ke.errorThrown=!0,ke.error=e)}var Jr=class extends me{constructor(e){super(),this.isStopped=!1,e?(this.destination=e,ss(e)&&e.add(this)):this.destination=wu}static create(e,t,n){return new lr(e,t,n)}next(e){this.isStopped?zn(gu(e),this):this._next(e)}error(e){this.isStopped?zn(pu(e),this):(this.isStopped=!0,this._error(e))}complete(){this.isStopped?zn(hu,this):(this.isStopped=!0,this._complete())}unsubscribe(){this.closed||(this.isStopped=!0,super.unsubscribe(),this.destination=null)}_next(e){this.destination.next(e)}_error(e){try{this.destination.error(e)}finally{this.unsubscribe()}}_complete(){try{this.destination.complete()}finally{this.unsubscribe()}}},vu=Function.prototype.bind;function Hn(e,t){return vu.call(e,t)}var yu=class{constructor(e){this.partialObserver=e}next(e){let{partialObserver:t}=this;if(t.next)try{t.next(e)}catch(n){At(n)}}error(e){let{partialObserver:t}=this;if(t.error)try{t.error(e)}catch(n){At(n)}else At(e)}complete(){let{partialObserver:e}=this;if(e.complete)try{e.complete()}catch(t){At(t)}}},lr=class extends Jr{constructor(e,t,n){super();let r;if(Y(e)||!e)r={next:e??void 0,error:t??void 0,complete:n??void 0};else{let i;this&&je.useDeprecatedNextContext?(i=Object.create(e),i.unsubscribe=()=>this.unsubscribe(),r={next:e.next&&Hn(e.next,i),error:e.error&&Hn(e.error,i),complete:e.complete&&Hn(e.complete,i)}):r=e}this.destination=new yu(r)}};function At(e){je.useDeprecatedSynchronousErrorHandling?mu(e):fu(e)}function bu(e){throw e}function zn(e,t){let{onStoppedNotification:n}=je;n&&Yt.setTimeout(()=>n(e,t))}var wu={closed:!0,next:Ui,error:bu,complete:Ui},_u=typeof Symbol=="function"&&Symbol.observable||"@@observable";function Cu(e){return e}function xu(e){return e.length===0?Cu:e.length===1?e[0]:function(t){return e.reduce((n,r)=>r(n),t)}}var ar=(()=>{class e{constructor(n){n&&(this._subscribe=n)}lift(n){let r=new e;return r.source=this,r.operator=n,r}subscribe(n,r,i){let o=Su(n)?n:new lr(n,r,i);return zt(()=>{let{operator:s,source:l}=this;o.add(s?s.call(o,l):l?this._subscribe(o):this._trySubscribe(o))}),o}_trySubscribe(n){try{return this._subscribe(n)}catch(r){n.error(r)}}forEach(n,r){return r=Zi(r),new r((i,o)=>{let s=new lr({next:l=>{try{n(l)}catch(a){o(a),s.unsubscribe()}},error:o,complete:i});this.subscribe(s)})}_subscribe(n){var r;return(r=this.source)===null||r===void 0?void 0:r.subscribe(n)}[_u](){return this}pipe(...n){return xu(n)(this)}toPromise(n){return n=Zi(n),new n((r,i)=>{let o;this.subscribe(s=>o=s,s=>i(s),()=>r(o))})}}return e.create=t=>new e(t),e})();function Zi(e){var t;return(t=e??je.Promise)!==null&&t!==void 0?t:Promise}function ku(e){return e&&Y(e.next)&&Y(e.error)&&Y(e.complete)}function Su(e){return e&&e instanceof Jr||ku(e)&&ss(e)}function Eu(e){return Y(e?.lift)}function Iu(e){return t=>{if(Eu(t))return t.lift(function(n){try{return e(n,this)}catch(r){this.error(r)}});throw new TypeError("Unable to lift unknown Observable type")}}function Tu(e,t,n,r,i){return new Ou(e,t,n,r,i)}var Ou=class extends Jr{constructor(e,t,n,r,i,o){super(e),this.onFinalize=i,this.shouldUnsubscribe=o,this._next=t?function(s){try{t(s)}catch(l){e.error(l)}}:super._next,this._error=r?function(s){try{r(s)}catch(l){e.error(l)}finally{this.unsubscribe()}}:super._error,this._complete=n?function(){try{n()}catch(s){e.error(s)}finally{this.unsubscribe()}}:super._complete}unsubscribe(){var e;if(!this.shouldUnsubscribe||this.shouldUnsubscribe()){let{closed:t}=this;super.unsubscribe(),!t&&((e=this.onFinalize)===null||e===void 0||e.call(this))}}},Du=is(e=>function(){e(this),this.name="ObjectUnsubscribedError",this.message="object unsubscribed"}),Et=(()=>{class e extends ar{constructor(){super(),this.closed=!1,this.currentObservers=null,this.observers=[],this.isStopped=!1,this.hasError=!1,this.thrownError=null}lift(n){let r=new $i(this,this);return r.operator=n,r}_throwIfClosed(){if(this.closed)throw new Du}next(n){zt(()=>{if(this._throwIfClosed(),!this.isStopped){this.currentObservers||(this.currentObservers=Array.from(this.observers));for(let r of this.currentObservers)r.next(n)}})}error(n){zt(()=>{if(this._throwIfClosed(),!this.isStopped){this.hasError=this.isStopped=!0,this.thrownError=n;let{observers:r}=this;for(;r.length;)r.shift().error(n)}})}complete(){zt(()=>{if(this._throwIfClosed(),!this.isStopped){this.isStopped=!0;let{observers:n}=this;for(;n.length;)n.shift().complete()}})}unsubscribe(){this.isStopped=this.closed=!0,this.observers=this.currentObservers=null}get observed(){var n;return((n=this.observers)===null||n===void 0?void 0:n.length)>0}_trySubscribe(n){return this._throwIfClosed(),super._trySubscribe(n)}_subscribe(n){return this._throwIfClosed(),this._checkFinalizedStatuses(n),this._innerSubscribe(n)}_innerSubscribe(n){let{hasError:r,isStopped:i,observers:o}=this;return r||i?os:(this.currentObservers=null,o.push(n),new me(()=>{this.currentObservers=null,or(o,n)}))}_checkFinalizedStatuses(n){let{hasError:r,thrownError:i,isStopped:o}=this;r?n.error(i):o&&n.complete()}asObservable(){let n=new ar;return n.source=this,n}}return e.create=(t,n)=>new $i(t,n),e})(),$i=class extends Et{constructor(e,t){super(),this.destination=e,this.source=t}next(e){var t,n;(n=(t=this.destination)===null||t===void 0?void 0:t.next)===null||n===void 0||n.call(t,e)}error(e){var t,n;(n=(t=this.destination)===null||t===void 0?void 0:t.error)===null||n===void 0||n.call(t,e)}complete(){var e,t;(t=(e=this.destination)===null||e===void 0?void 0:e.complete)===null||t===void 0||t.call(e)}_subscribe(e){var t,n;return(n=(t=this.source)===null||t===void 0?void 0:t.subscribe(e))!==null&&n!==void 0?n:os}},Mu=class extends Et{constructor(e){super(),this._value=e}get value(){return this.getValue()}_subscribe(e){let t=super._subscribe(e);return!t.closed&&e.next(this._value),t}getValue(){let{hasError:e,thrownError:t,_value:n}=this;if(e)throw t;return this._throwIfClosed(),n}next(e){super.next(this._value=e)}};function Pu(e,t){return Iu((n,r)=>{let i=0;n.subscribe(Tu(r,o=>{r.next(e.call(t,o,i++))}))})}var ur;function ls(){return ur}function fe(e){let t=ur;return ur=e,t}var Nu=Symbol("NotFound");function ei(e){return e===Nu||e?.name==="\u0275NotFound"}var as="https://angular.dev/best-practices/security#preventing-cross-site-scripting-xss",w=class extends Error{code;constructor(e,t){super(Vu(e,t)),this.code=e}};function Au(e){return`NG0${Math.abs(e)}`}function Vu(e,t){return`${Au(e)}${t?": "+t:""}`}var Kt=globalThis;function k(e){for(let t in e)if(e[t]===k)return t;throw Error("")}function us(e){if(typeof e=="string")return e;if(Array.isArray(e))return`[${e.map(us).join(", ")}]`;if(e==null)return""+e;let t=e.overriddenName||e.name;if(t)return`${t}`;let n=e.toString();if(n==null)return""+n;let r=n.indexOf(` -`);return r>=0?n.slice(0,r):n}function Qi(e,t){return e?t?`${e} ${t}`:e:t||""}var Ru=k({__forward_ref__:k});function cs(e){return e.__forward_ref__=cs,e}function F(e){return Lu(e)?e():e}function Lu(e){return typeof e=="function"&&e.hasOwnProperty(Ru)&&e.__forward_ref__===cs}function D(e){return{token:e.token,providedIn:e.providedIn||null,factory:e.factory,value:void 0}}function ti(e){return ju(e,ds)}function ju(e,t){return e.hasOwnProperty(t)&&e[t]||null}function Fu(e){return(e?.[ds]??null)||null}function Wi(e){return e&&e.hasOwnProperty(Gi)?e[Gi]:null}var ds=k({\u0275prov:k}),Gi=k({\u0275inj:k}),E=class{_desc;ngMetadataName="InjectionToken";\u0275prov;constructor(e,t){this._desc=e,this.\u0275prov=void 0,typeof t=="number"?this.__NG_ELEMENT_ID__=t:t!==void 0&&(this.\u0275prov=D({token:this,providedIn:t.providedIn||"root",factory:t.factory}))}get multi(){return this}toString(){return`InjectionToken ${this._desc}`}};function fs(e){return e&&!!e.\u0275providers}var Hu=k({\u0275cmp:k}),zu=k({\u0275dir:k}),Bu=k({\u0275pipe:k}),Yi=k({\u0275fac:k}),ht=k({__NG_ELEMENT_ID__:k}),Ki=k({__NG_ENV_ID__:k});function gt(e){return ni(e,"@Component"),e[Hu]||null}function hs(e){return ni(e,"@Directive"),e[zu]||null}function qu(e){return ni(e,"@Pipe"),e[Bu]||null}function ni(e,t){if(e==null)throw new w(-919,!1)}function ps(e){return typeof e=="string"?e:e==null?"":String(e)}var gs=k({ngErrorCode:k}),Uu=k({ngErrorMessage:k}),Zu=k({ngTokenPath:k});function ms(e,t){return vs("",-200,t)}function ri(e,t){throw new w(-201,!1)}function vs(e,t,n){let r=new w(t,e);return r[gs]=t,r[Uu]=e,n&&(r[Zu]=n),r}function $u(e){return e[gs]}var cr;function ys(){return cr}function z(e){let t=cr;return cr=e,t}function bs(e,t,n){let r=ti(e);if(r&&r.providedIn=="root")return r.value===void 0?r.value=r.factory():r.value;if(n&8)return null;if(t!==void 0)return t;ri(e,"")}var Qu={},Ee=Qu,Wu="__NG_DI_FLAG__",Gu=class{injector;constructor(e){this.injector=e}retrieve(e,t){let n=mt(t)||0;try{return this.injector.get(e,n&8?null:Ee,n)}catch(r){if(ei(r))return r;throw r}}};function Yu(e,t=0){let n=ls();if(n===void 0)throw new w(-203,!1);if(n===null)return bs(e,void 0,t);{let r=Ku(t),i=n.retrieve(e,r);if(ei(i)){if(r.optional)return null;throw i}return i}}function C(e,t=0){return(ys()||Yu)(F(e),t)}function b(e,t){return C(e,mt(t))}function mt(e){return typeof e>"u"||typeof e=="number"?e:0|(e.optional&&8)|(e.host&&1)|(e.self&&2)|(e.skipSelf&&4)}function Ku(e){return{optional:!!(e&8),host:!!(e&1),self:!!(e&2),skipSelf:!!(e&4)}}function dr(e){let t=[];for(let n=0;nArray.isArray(n)?ii(n,t):t(n))}function ws(e,t,n){t>=e.length?e.push(n):e.splice(t,0,n)}function Xt(e,t){return t>=e.length-1?e.pop():e.splice(t,1)[0]}function tc(e,t,n,r){let i=e.length;if(i==t)e.push(n,r);else if(i===1)e.push(r,e[0]),e[0]=n;else{for(i--,e.push(e[i-1],e[i]);i>t;){let o=i-2;e[i]=e[o],i--}e[t]=n,e[t+1]=r}}function nc(e,t,n){let r=It(e,t);return r>=0?e[r|1]=n:(r=~r,tc(e,r,t,n)),r}function Bn(e,t){let n=It(e,t);if(n>=0)return e[n|1]}function It(e,t){return rc(e,t,1)}function rc(e,t,n){let r=0,i=e.length>>n;for(;i!==r;){let o=r+(i-r>>1),s=e[o<t?i=o:r=o+1}return~(i<{n.push(s)};return ii(t,s=>{let l=s;fr(l,o,[],r)&&(i||=[],i.push(l))}),i!==void 0&&Ss(i,o),n}function Ss(e,t){for(let n=0;n{t(o,r)})}}function fr(e,t,n,r){if(e=F(e),!e)return!1;let i=null,o=Wi(e),s=!o&>(e);if(!o&&!s){let a=e.ngModule;if(o=Wi(a),o)i=a;else return!1}else{if(s&&!s.standalone)return!1;i=e}let l=r.has(i);if(s){if(l)return!1;if(r.add(i),s.dependencies){let a=typeof s.dependencies=="function"?s.dependencies():s.dependencies;for(let u of a)fr(u,t,n,r)}}else if(o){if(o.imports!=null&&!l){r.add(i);let u;ii(o.imports,c=>{fr(c,t,n,r)&&(u||=[],u.push(c))}),u!==void 0&&Ss(u,t)}if(!l){let u=vt(i)||(()=>new i);t({provide:i,useFactory:u,deps:Te},i),t({provide:Cs,useValue:i,multi:!0},i),t({provide:bn,useValue:()=>C(i),multi:!0},i)}let a=o.providers;if(a!=null&&!l){let u=e;si(a,c=>{t(c,u)})}}else return!1;return i!==e&&e.providers!==void 0}function si(e,t){for(let n of e)fs(n)&&(n=n.\u0275providers),Array.isArray(n)?si(n,t):t(n)}var sc=k({provide:String,useValue:k});function Es(e){return e!==null&&typeof e=="object"&&sc in e}function lc(e){return!!(e&&e.useExisting)}function ac(e){return!!(e&&e.useFactory)}function Ke(e){return typeof e=="function"}function uc(e){return!!e.useClass}var li=new E(""),Bt={},Xi={},qn;function ai(){return qn===void 0&&(qn=new xs),qn}var ve=class{},ui=class extends ve{parent;source;scopes;records=new Map;_ngOnDestroyHooks=new Set;_onDestroyHooks=[];get destroyed(){return this._destroyed}_destroyed=!1;injectorDefTypes;constructor(e,t,n,r){super(),this.parent=t,this.source=n,this.scopes=r,pr(e,o=>this.processProvider(o)),this.records.set(_s,$e(void 0,this)),r.has("environment")&&this.records.set(ve,$e(void 0,this));let i=this.records.get(li);i!=null&&typeof i.value=="string"&&this.scopes.add(i.value),this.injectorDefTypes=new Set(this.get(Cs,Te,{self:!0}))}retrieve(e,t){let n=mt(t)||0;try{return this.get(e,Ee,n)}catch(r){if(ei(r))return r;throw r}}destroy(){ut(this),this._destroyed=!0;let e=m(null);try{for(let n of this._ngOnDestroyHooks)n.ngOnDestroy();let t=this._onDestroyHooks;this._onDestroyHooks=[];for(let n of t)n()}finally{this.records.clear(),this._ngOnDestroyHooks.clear(),this.injectorDefTypes.clear(),m(e)}}onDestroy(e){return ut(this),this._onDestroyHooks.push(e),()=>this.removeOnDestroy(e)}runInContext(e){ut(this);let t=fe(this),n=z(void 0),r;try{return e()}finally{fe(t),z(n)}}get(e,t=Ee,n){if(ut(this),e.hasOwnProperty(Ki))return e[Ki](this);let r=mt(n),i,o=fe(this),s=z(void 0);try{if(!(r&4)){let a=this.records.get(e);if(a===void 0){let u=pc(e)&&ti(e);u&&this.injectableDefInScope(u)?a=$e(hr(e),Bt):a=null,this.records.set(e,a)}if(a!=null)return this.hydrate(e,a,r)}let l=r&2?ai():this.parent;return t=r&8&&t===Ee?null:t,l.get(e,t)}catch(l){let a=$u(l);throw a===-200||a===-201?new w(a,null):l}finally{z(s),fe(o)}}resolveInjectorInitializers(){let e=m(null),t=fe(this),n=z(void 0),r;try{let i=this.get(bn,Te,{self:!0});for(let o of i)o()}finally{fe(t),z(n),m(e)}}toString(){return"R3Injector[...]"}processProvider(e){e=F(e);let t=Ke(e)?e:F(e&&e.provide),n=dc(e);if(!Ke(e)&&e.multi===!0){let r=this.records.get(t);r||(r=$e(void 0,Bt,!0),r.factory=()=>dr(r.multi),this.records.set(t,r)),t=e,r.multi.push(e)}this.records.set(t,n)}hydrate(e,t,n){let r=m(null);try{if(t.value===Xi)throw ms("");return t.value===Bt&&(t.value=Xi,t.value=t.factory(void 0,n)),typeof t.value=="object"&&t.value&&hc(t.value)&&this._ngOnDestroyHooks.add(t.value),t.value}finally{m(r)}}injectableDefInScope(e){if(!e.providedIn)return!1;let t=F(e.providedIn);return typeof t=="string"?t==="any"||this.scopes.has(t):this.injectorDefTypes.has(t)}removeOnDestroy(e){let t=this._onDestroyHooks.indexOf(e);t!==-1&&this._onDestroyHooks.splice(t,1)}};function hr(e){let t=ti(e),n=t!==null?t.factory:vt(e);if(n!==null)return n;if(e instanceof E)throw new w(-204,!1);if(e instanceof Function)return cc(e);throw new w(-204,!1)}function cc(e){if(e.length>0)throw new w(-204,!1);let t=Fu(e);return t!==null?()=>t.factory(e):()=>new e}function dc(e){if(Es(e))return $e(void 0,e.useValue);{let t=Is(e);return $e(t,Bt)}}function Is(e,t,n){let r;if(Ke(e)){let i=F(e);return vt(i)||hr(i)}else if(Es(e))r=()=>F(e.useValue);else if(ac(e))r=()=>e.useFactory(...dr(e.deps||[]));else if(lc(e))r=(i,o)=>C(F(e.useExisting),o!==void 0&&o&8?8:void 0);else{let i=F(e&&(e.useClass||e.provide));if(fc(e))r=()=>new i(...dr(e.deps));else return vt(i)||hr(i)}return r}function ut(e){if(e.destroyed)throw new w(-205,!1)}function $e(e,t,n=!1){return{factory:e,value:t,multi:n?[]:void 0}}function fc(e){return!!e.deps}function hc(e){return e!==null&&typeof e=="object"&&typeof e.ngOnDestroy=="function"}function pc(e){return typeof e=="function"||typeof e=="object"&&e.ngMetadataName==="InjectionToken"}function pr(e,t){for(let n of e)Array.isArray(n)?pr(n,t):n&&fs(n)?pr(n.\u0275providers,t):t(n)}function Ts(e,t){let n;e instanceof ui?(ut(e),n=e):n=new Gu(e);let r,i=fe(n),o=z(void 0);try{return t()}finally{fe(i),z(o)}}function gc(){return ys()!==void 0||ls()!=null}var le=0,g=1,v=2,A=3,Z=4,W=5,yt=6,Jt=7,O=8,ye=9,ie=10,V=11,bt=12,Ji=13,nt=14,K=15,Oe=16,Qe=17,oe=18,be=19,Os=20,ge=21,Un=22,De=23,q=24,Zn=25,Me=26,H=27,Ds=1,eo=6,Pe=7,en=8,Xe=9,T=10;function Ie(e){return Array.isArray(e)&&typeof e[Ds]=="object"}function ae(e){return Array.isArray(e)&&e[Ds]===!0}function Ms(e){return(e.flags&4)!==0}function wn(e){return e.componentOffset>-1}function Ps(e){return(e.flags&1)===1}function rt(e){return!!e.template}function tn(e){return(e[v]&512)!==0}function it(e){return(e[v]&256)===256}var mc="svg",vc="math";function X(e){for(;Array.isArray(e);)e=e[le];return e}function Ns(e,t){return X(t[e])}function ue(e,t){return X(t[e.index])}function ci(e,t){return e.data[t]}function Ne(e,t){let n=t[e];return Ie(n)?n:n[le]}function yc(e){return(e[v]&4)===4}function di(e){return(e[v]&128)===128}function bc(e){return ae(e[A])}function se(e,t){return t==null?null:e[t]}function As(e){e[Qe]=0}function Vs(e){e[v]&1024||(e[v]|=1024,di(e)&&Tt(e))}function wc(e,t){for(;e>0;)t=t[nt],e--;return t}function nn(e){return!!(e[v]&9216||e[q]?.dirty)}function gr(e){e[ie].changeDetectionScheduler?.notify(8),e[v]&64&&(e[v]|=1024),nn(e)&&Tt(e)}function Tt(e){e[ie].changeDetectionScheduler?.notify(0);let t=Ae(e);for(;t!==null&&!(t[v]&8192||(t[v]|=8192,!di(t)));)t=Ae(t)}function Rs(e,t){if(it(e))throw new w(911,!1);e[ge]===null&&(e[ge]=[]),e[ge].push(t)}function _c(e,t){if(e[ge]===null)return;let n=e[ge].indexOf(t);n!==-1&&e[ge].splice(n,1)}function Ae(e){let t=e[A];return ae(t)?t[A]:t}function Ls(e){return e[Jt]??=[]}function js(e){return e.cleanup??=[]}function Cc(e,t,n,r){let i=Ls(t);i.push(n),e.firstCreatePass&&js(e).push(r,i.length-1)}var y={lFrame:Zs(null),bindingsEnabled:!0,skipHydrationRootTNode:null},mr=!1;function xc(){return y.lFrame.elementDepthCount}function kc(){y.lFrame.elementDepthCount++}function Sc(){y.lFrame.elementDepthCount--}function Ec(){return y.skipHydrationRootTNode!==null}function Ic(e){return y.skipHydrationRootTNode===e}function Tc(){y.skipHydrationRootTNode=null}function S(){return y.lFrame.lView}function U(){return y.lFrame.tView}function qe(e){return y.lFrame.contextLView=e,e[O]}function Ue(e){return y.lFrame.contextLView=null,e}function ce(){let e=Fs();for(;e!==null&&e.type===64;)e=e.parent;return e}function Fs(){return y.lFrame.currentTNode}function Oc(){let e=y.lFrame,t=e.currentTNode;return e.isParent?t:t.parent}function Ot(e,t){let n=y.lFrame;n.currentTNode=e,n.isParent=t}function Hs(){return y.lFrame.isParent}function Dc(){y.lFrame.isParent=!1}function zs(){return mr}function rn(e){let t=mr;return mr=e,t}function Mc(e){return y.lFrame.bindingIndex=e}function _n(){return y.lFrame.bindingIndex++}function Pc(e){let t=y.lFrame,n=t.bindingIndex;return t.bindingIndex=t.bindingIndex+e,n}function Nc(){return y.lFrame.inI18n}function Ac(e,t){let n=y.lFrame;n.bindingIndex=n.bindingRootIndex=e,vr(t)}function Vc(){return y.lFrame.currentDirectiveIndex}function vr(e){y.lFrame.currentDirectiveIndex=e}function Rc(e){let t=y.lFrame.currentDirectiveIndex;return t===-1?null:e[t]}function Bs(){return y.lFrame.currentQueryIndex}function fi(e){y.lFrame.currentQueryIndex=e}function Lc(e){let t=e[g];return t.type===2?t.declTNode:t.type===1?e[W]:null}function qs(e,t,n){if(n&4){let i=t,o=e;for(;(i=i.parent,i===null&&!(n&1))&&(i=Lc(o),!(i===null||(o=o[nt],i.type&10))););if(i===null)return!1;t=i,e=o}let r=y.lFrame=Us();return r.currentTNode=t,r.lView=e,!0}function hi(e){let t=Us(),n=e[g];y.lFrame=t,t.currentTNode=n.firstChild,t.lView=e,t.tView=n,t.contextLView=e,t.bindingIndex=n.bindingStartIndex,t.inI18n=!1}function Us(){let e=y.lFrame,t=e===null?null:e.child;return t===null?Zs(e):t}function Zs(e){let t={currentTNode:null,isParent:!0,lView:null,tView:null,selectedIndex:-1,contextLView:null,elementDepthCount:0,currentNamespace:null,currentDirectiveIndex:-1,bindingRootIndex:-1,bindingIndex:-1,currentQueryIndex:0,parent:e,child:null,inI18n:!1};return e!==null&&(e.child=t),t}function $s(){let e=y.lFrame;return y.lFrame=e.parent,e.currentTNode=null,e.lView=null,e}var Qs=$s;function pi(){let e=$s();e.isParent=!0,e.tView=null,e.selectedIndex=-1,e.contextLView=null,e.elementDepthCount=0,e.currentDirectiveIndex=-1,e.currentNamespace=null,e.bindingRootIndex=-1,e.bindingIndex=-1,e.currentQueryIndex=0}function jc(e){return(y.lFrame.contextLView=wc(e,y.lFrame.contextLView))[O]}function Fe(){return y.lFrame.selectedIndex}function Ve(e){y.lFrame.selectedIndex=e}function Fc(){let e=y.lFrame;return ci(e.tView,e.selectedIndex)}function Hc(){return y.lFrame.currentNamespace}var Ws=!0;function gi(){return Ws}function mi(e){Ws=e}function to(e,t=null,n=null,r){let i=zc(e,t,n,r);return i.resolveInjectorInitializers(),i}function zc(e,t=null,n=null,r,i=new Set){let o=[n||Te,oc(e)],s;return new ui(o,t||ai(),s||null,i)}var Cn=class Gs{static THROW_IF_NOT_FOUND=Ee;static NULL=new xs;static create(t,n){if(Array.isArray(t))return to({name:""},n,t,"");{let r=t.name??"";return to({name:r},t.parent,t.providers,r)}}static \u0275prov=D({token:Gs,providedIn:"any",factory:()=>C(_s)});static __NG_ELEMENT_ID__=-1},we=new E(""),xn=(()=>{class e{static __NG_ELEMENT_ID__=Bc;static __NG_ENV_ID__=n=>n}return e})(),Ys=class extends xn{_lView;constructor(e){super(),this._lView=e}get destroyed(){return it(this._lView)}onDestroy(e){let t=this._lView;return Rs(t,e),()=>_c(t,e)}};function Bc(){return new Ys(S())}var qc=!1,Uc=new E(""),kn=(()=>{class e{taskId=0;pendingTasks=new Set;destroyed=!1;pendingTask=new Mu(!1);debugTaskTracker=b(Uc,{optional:!0});get hasPendingTasks(){return this.destroyed?!1:this.pendingTask.value}get hasPendingTasksObservable(){return this.destroyed?new ar(n=>{n.next(!1),n.complete()}):this.pendingTask}add(){!this.hasPendingTasks&&!this.destroyed&&this.pendingTask.next(!0);let n=this.taskId++;return this.pendingTasks.add(n),this.debugTaskTracker?.add(n),n}has(n){return this.pendingTasks.has(n)}remove(n){this.pendingTasks.delete(n),this.debugTaskTracker?.remove(n),this.pendingTasks.size===0&&this.hasPendingTasks&&this.pendingTask.next(!1)}ngOnDestroy(){this.pendingTasks.clear(),this.hasPendingTasks&&this.pendingTask.next(!1),this.destroyed=!0,this.pendingTask.unsubscribe()}static \u0275prov=D({token:e,providedIn:"root",factory:()=>new e})}return e})(),Zc=class extends Et{__isAsync;destroyRef=void 0;pendingTasks=void 0;constructor(e=!1){super(),this.__isAsync=e,gc()&&(this.destroyRef=b(xn,{optional:!0})??void 0,this.pendingTasks=b(kn,{optional:!0})??void 0)}emit(e){let t=m(null);try{super.next(e)}finally{m(t)}}subscribe(e,t,n){let r=e,i=t||(()=>null),o=n;if(e&&typeof e=="object"){let l=e;r=l.next?.bind(l),i=l.error?.bind(l),o=l.complete?.bind(l)}this.__isAsync&&(i=this.wrapInTimeout(i),r&&(r=this.wrapInTimeout(r)),o&&(o=this.wrapInTimeout(o)));let s=super.subscribe({next:r,error:i,complete:o});return e instanceof me&&e.add(s),s}wrapInTimeout(e){return t=>{let n=this.pendingTasks?.add();setTimeout(()=>{try{e(t)}finally{n!==void 0&&this.pendingTasks?.remove(n)}})}}},pe=Zc;function on(...e){}function Ks(e){let t,n;function r(){e=on;try{n!==void 0&&typeof cancelAnimationFrame=="function"&&cancelAnimationFrame(n),t!==void 0&&clearTimeout(t)}catch{}}return t=setTimeout(()=>{e(),r()}),typeof requestAnimationFrame=="function"&&(n=requestAnimationFrame(()=>{e(),r()})),()=>r()}function $c(e){return queueMicrotask(()=>e()),()=>{e=on}}var vi="isAngularZone",sn=vi+"_ID",Qc=0,He=class yr{hasPendingMacrotasks=!1;hasPendingMicrotasks=!1;isStable=!0;onUnstable=new pe(!1);onMicrotaskEmpty=new pe(!1);onStable=new pe(!1);onError=new pe(!1);constructor(t){let{enableLongStackTrace:n=!1,shouldCoalesceEventChangeDetection:r=!1,shouldCoalesceRunChangeDetection:i=!1,scheduleInRootZone:o=qc}=t;if(typeof Zone>"u")throw new w(908,!1);Zone.assertZonePatched();let s=this;s._nesting=0,s._outer=s._inner=Zone.current,Zone.TaskTrackingZoneSpec&&(s._inner=s._inner.fork(new Zone.TaskTrackingZoneSpec)),n&&Zone.longStackTraceZoneSpec&&(s._inner=s._inner.fork(Zone.longStackTraceZoneSpec)),s.shouldCoalesceEventChangeDetection=!i&&r,s.shouldCoalesceRunChangeDetection=i,s.callbackScheduled=!1,s.scheduleInRootZone=o,Yc(s)}static isInAngularZone(){return typeof Zone<"u"&&Zone.current.get(vi)===!0}static assertInAngularZone(){if(!yr.isInAngularZone())throw new w(909,!1)}static assertNotInAngularZone(){if(yr.isInAngularZone())throw new w(909,!1)}run(t,n,r){return this._inner.run(t,n,r)}runTask(t,n,r,i){let o=this._inner,s=o.scheduleEventTask("NgZoneEvent: "+i,t,Wc,on,on);try{return o.runTask(s,n,r)}finally{o.cancelTask(s)}}runGuarded(t,n,r){return this._inner.runGuarded(t,n,r)}runOutsideAngular(t){return this._outer.run(t)}},Wc={};function yi(e){if(e._nesting==0&&!e.hasPendingMicrotasks&&!e.isStable)try{e._nesting++,e.onMicrotaskEmpty.emit(null)}finally{if(e._nesting--,!e.hasPendingMicrotasks)try{e.runOutsideAngular(()=>e.onStable.emit(null))}finally{e.isStable=!0}}}function Gc(e){if(e.isCheckStableRunning||e.callbackScheduled)return;e.callbackScheduled=!0;function t(){Ks(()=>{e.callbackScheduled=!1,br(e),e.isCheckStableRunning=!0,yi(e),e.isCheckStableRunning=!1})}e.scheduleInRootZone?Zone.root.run(()=>{t()}):e._outer.run(()=>{t()}),br(e)}function Yc(e){let t=()=>{Gc(e)},n=Qc++;e._inner=e._inner.fork({name:"angular",properties:{[vi]:!0,[sn]:n,[sn+n]:!0},onInvokeTask:(r,i,o,s,l,a)=>{if(Xc(a))return r.invokeTask(o,s,l,a);try{return no(e),r.invokeTask(o,s,l,a)}finally{(e.shouldCoalesceEventChangeDetection&&s.type==="eventTask"||e.shouldCoalesceRunChangeDetection)&&t(),ro(e)}},onInvoke:(r,i,o,s,l,a,u)=>{try{return no(e),r.invoke(o,s,l,a,u)}finally{e.shouldCoalesceRunChangeDetection&&!e.callbackScheduled&&!Jc(a)&&t(),ro(e)}},onHasTask:(r,i,o,s)=>{r.hasTask(o,s),i===o&&(s.change=="microTask"?(e._hasPendingMicrotasks=s.microTask,br(e),yi(e)):s.change=="macroTask"&&(e.hasPendingMacrotasks=s.macroTask))},onHandleError:(r,i,o,s)=>(r.handleError(o,s),e.runOutsideAngular(()=>e.onError.emit(s)),!1)})}function br(e){e._hasPendingMicrotasks||(e.shouldCoalesceEventChangeDetection||e.shouldCoalesceRunChangeDetection)&&e.callbackScheduled===!0?e.hasPendingMicrotasks=!0:e.hasPendingMicrotasks=!1}function no(e){e._nesting++,e.isStable&&(e.isStable=!1,e.onUnstable.emit(null))}function ro(e){e._nesting--,yi(e)}var Kc=class{hasPendingMicrotasks=!1;hasPendingMacrotasks=!1;isStable=!0;onUnstable=new pe;onMicrotaskEmpty=new pe;onStable=new pe;onError=new pe;run(e,t,n){return e.apply(t,n)}runGuarded(e,t,n){return e.apply(t,n)}runOutsideAngular(e){return e()}runTask(e,t,n,r){return e.apply(t,n)}};function Xc(e){return Xs(e,"__ignore_ng_zone__")}function Jc(e){return Xs(e,"__scheduler_tick__")}function Xs(e,t){return!Array.isArray(e)||e.length!==1?!1:e[0]?.data?.[t]===!0}var Sn=class{_console=console;handleError(e){this._console.error("ERROR",e)}},Dt=new E("",{factory:()=>{let e=b(He),t=b(ve),n;return r=>{e.runOutsideAngular(()=>{t.destroyed&&!n?setTimeout(()=>{throw r}):(n??=t.get(Sn),n.handleError(r))})}}}),ed={provide:bn,useValue:()=>{let e=b(Sn,{optional:!0})},multi:!0},td=new E("",{factory:()=>{let e=b(we).defaultView;if(!e)return;let t=b(Dt),n=o=>{t(o.reason),o.preventDefault()},r=o=>{o.error?t(o.error):t(new Error(o.message,{cause:o})),o.preventDefault()},i=()=>{e.addEventListener("unhandledrejection",n),e.addEventListener("error",r)};typeof Zone<"u"?Zone.root.run(i):i(),b(xn).onDestroy(()=>{e.removeEventListener("error",r),e.removeEventListener("unhandledrejection",n)})}});function nd(){return oi([ic(()=>{b(td)})])}function j(e,t){let[n,r,i]=ou(e,t?.equal),o=n,s=o[ne];return o.set=r,o.update=i,o.asReadonly=rd.bind(o),o}function rd(){let e=this[ne];if(e.readonlyFn===void 0){let t=()=>this();t[ne]=e,e.readonlyFn=t}return e.readonlyFn}var Js=(()=>{class e{view;node;constructor(n,r){this.view=n,this.node=r}static __NG_ELEMENT_ID__=id}return e})();function id(){return new Js(S(),ce())}var bi=class{},wi=new E("",{factory:()=>!0}),od=new E(""),el=(()=>{class e{static \u0275prov=D({token:e,providedIn:"root",factory:()=>new sd})}return e})(),sd=class{dirtyEffectCount=0;queues=new Map;add(e){this.enqueue(e),this.schedule(e)}schedule(e){e.dirty&&this.dirtyEffectCount++}remove(e){let t=e.zone,n=this.queues.get(t);n.has(e)&&(n.delete(e),e.dirty&&this.dirtyEffectCount--)}enqueue(e){let t=e.zone;this.queues.has(t)||this.queues.set(t,new Set);let n=this.queues.get(t);n.has(e)||n.add(e)}flush(){for(;this.dirtyEffectCount>0;){let e=!1;for(let[t,n]of this.queues)t===null?e||=this.flushQueue(n):e||=t.run(()=>this.flushQueue(n));e||(this.dirtyEffectCount=0)}}flushQueue(e){let t=!1;for(let n of e)n.dirty&&(this.dirtyEffectCount--,t=!0,n.run());return t}},ld=class{[ne];constructor(e){this[ne]=e}destroy(){this[ne].destroy()}};function $n(e,t){let n=t?.injector??b(Cn),r=t?.manualCleanup!==!0?n.get(xn):null,i,o=n.get(Js,null,{optional:!0}),s=n.get(bi);return o!==null?(i=cd(o.view,s,e),r instanceof Ys&&r._lView===o.view&&(r=null)):i=dd(e,n.get(el),s),i.injector=n,r!==null&&(i.onDestroyFns=[r.onDestroy(()=>i.destroy())]),new ld(i)}var tl=Q($({},cu),{cleanupFns:void 0,zone:null,onDestroyFns:null,run(){let e=rn(!1);try{du(this)}finally{rn(e)}},cleanup(){if(!this.cleanupFns?.length)return;let e=m(null);try{for(;this.cleanupFns.length;)this.cleanupFns.pop()()}finally{this.cleanupFns=[],m(e)}}}),ad=Q($({},tl),{consumerMarkedDirty(){this.scheduler.schedule(this),this.notifier.notify(12)},destroy(){if(yn(this),this.onDestroyFns!==null)for(let e of this.onDestroyFns)e();this.cleanup(),this.scheduler.remove(this)}}),ud=Q($({},tl),{consumerMarkedDirty(){this.view[v]|=8192,Tt(this.view),this.notifier.notify(13)},destroy(){if(yn(this),this.onDestroyFns!==null)for(let e of this.onDestroyFns)e();this.cleanup(),this.view[De]?.delete(this)}});function cd(e,t,n){let r=Object.create(ud);return r.view=e,r.zone=typeof Zone<"u"?Zone.current:null,r.notifier=t,r.fn=nl(r,n),e[De]??=new Set,e[De].add(r),r.consumerMarkedDirty(r),r}function dd(e,t,n){let r=Object.create(ad);return r.fn=nl(r,e),r.scheduler=t,r.notifier=n,r.zone=typeof Zone<"u"?Zone.current:null,r.scheduler.add(r),r.notifier.notify(12),r}function nl(e,t){return()=>{t(n=>(e.cleanupFns??=[]).push(n))}}function fd(e){return{toString:e}.toString()}function hd(e){return typeof e=="function"}function rl(e,t,n,r){t!==null?t.applyValueToInputSignal(t,r):e[n]=r}var pd=class{previousValue;currentValue;firstChange;constructor(e,t,n){this.previousValue=e,this.currentValue=t,this.firstChange=n}isFirstChange(){return this.firstChange}};function gd(e){return e.type.prototype.ngOnChanges&&(e.setInput=vd),md}function md(){let e=ol(this),t=e?.current;if(t){let n=e.previous;if(n===Ye)e.previous=t;else for(let r in t)n[r]=t[r];e.current=null,this.ngOnChanges(t)}}function vd(e,t,n,r,i){let o=this.declaredInputs[r],s=ol(e)||yd(e,{previous:Ye,current:null}),l=s.current||(s.current={}),a=s.previous,u=a[o];l[o]=new pd(u&&u.currentValue,n,a===Ye),rl(e,t,i,n)}var il="__ngSimpleChanges__";function ol(e){return e[il]||null}function yd(e,t){return e[il]=t}var io=[],x=function(e,t=null,n){for(let r=0;r=r)break}else t[a]<0&&(e[Qe]+=65536),(l>14>16&&(e[v]&3)===t&&(e[v]+=16384,oo(l,o)):oo(l,o)}var Ge=-1,Mt=class{factory;name;injectImpl;resolving=!1;canSeeViewProviders;multi;componentProviders;index;providerFactory;constructor(e,t,n,r){this.factory=e,this.name=r,this.canSeeViewProviders=t,this.injectImpl=n}};function Cd(e,t,n){let r=0;for(;rt){s=o-1;break}}}for(;o>16}function an(e,t){let n=kd(e),r=t;for(;n>0;)r=r[nt],n--;return r}var wr=!0;function lo(e){let t=wr;return wr=e,t}var Sd=256,al=Sd-1,ul=5,Ed=0,G={};function Id(e,t,n){let r;typeof n=="string"?r=n.charCodeAt(0)||0:n.hasOwnProperty(ht)&&(r=n[ht]),r==null&&(r=n[ht]=Ed++);let i=r&al,o=1<>ul)]|=o}function un(e,t){let n=cl(e,t);if(n!==-1)return n;let r=t[g];r.firstCreatePass&&(e.injectorIndex=t.length,Wn(r.data,e),Wn(t,null),Wn(r.blueprint,null));let i=_i(e,t),o=e.injectorIndex;if(ll(i)){let s=ln(i),l=an(i,t),a=l[g].data;for(let u=0;u<8;u++)t[o+u]=l[s+u]|a[s+u]}return t[o+8]=i,o}function Wn(e,t){e.push(0,0,0,0,0,0,0,0,t)}function cl(e,t){return e.injectorIndex===-1||e.parent&&e.parent.injectorIndex===e.injectorIndex||t[e.injectorIndex+8]===null?-1:e.injectorIndex}function _i(e,t){if(e.parent&&e.parent.injectorIndex!==-1)return e.parent.injectorIndex;let n=0,r=null,i=t;for(;i!==null;){if(r=gl(i),r===null)return Ge;if(n++,i=i[nt],r.injectorIndex!==-1)return r.injectorIndex|n<<16}return Ge}function _r(e,t,n){Id(e,t,n)}function dl(e,t,n){if(n&8||e!==void 0)return e;ri(t,"NodeInjector")}function fl(e,t,n,r){if(n&8&&r===void 0&&(r=null),(n&3)===0){let i=e[ye],o=z(void 0);try{return i?i.get(t,r,n&8):bs(t,r,n&8)}finally{z(o)}}return dl(r,t,n)}function hl(e,t,n,r=0,i){if(e!==null){if(t[v]&2048&&!(r&2)){let s=Md(e,t,n,r,G);if(s!==G)return s}let o=pl(e,t,n,r,G);if(o!==G)return o}return fl(t,n,r,i)}function pl(e,t,n,r,i){let o=Od(n);if(typeof o=="function"){if(!qs(t,e,r))return r&1?dl(i,n,r):fl(t,n,r,i);try{let s;if(s=o(r),s==null&&!(r&8))ri(n);else return s}finally{Qs()}}else if(typeof o=="number"){let s=null,l=cl(e,t),a=Ge,u=r&1?t[K][W]:null;for((l===-1||r&4)&&(a=l===-1?_i(e,t):t[l+8],a===Ge||!uo(r,!1)?l=-1:(s=t[g],l=ln(a),t=an(a,t)));l!==-1;){let c=t[g];if(ao(o,l,c.data)){let d=Td(l,t,n,s,r,u);if(d!==G)return d}a=t[l+8],a!==Ge&&uo(r,t[g].data[l+8]===u)&&ao(o,l,t)?(s=c,l=ln(a),t=an(a,t)):l=-1}}return i}function Td(e,t,n,r,i,o){let s=t[g],l=s.data[e+8],a=r==null?wn(l)&&wr:r!=s&&(l.type&3)!==0,u=i&1&&o===l,c=Zt(l,s,n,a,u);return c!==null?wt(t,s,c,l,i):G}function Zt(e,t,n,r,i){let o=e.providerIndexes,s=t.data,l=o&1048575,a=e.directiveStart,u=e.directiveEnd,c=o>>20,d=r?l:l+c,h=i?l+c:u;for(let f=d;f=a&&p.type===n)return f}if(i){let f=s[a];if(f&&rt(f)&&f.type===n)return a}return null}function wt(e,t,n,r,i){let o=e[n],s=t.data;if(o instanceof Mt){let l=o;if(l.resolving)throw ms("");let a=lo(l.canSeeViewProviders);l.resolving=!0;let u=s[n].type||s[n],c,d=l.injectImpl?z(l.injectImpl):null,h=qs(e,r,0);try{o=e[n]=l.factory(void 0,i,s,e,r),t.firstCreatePass&&n>=r.directiveStart&&bd(n,s[n],t)}finally{d!==null&&z(d),lo(a),l.resolving=!1,Qs()}}return o}function Od(e){if(typeof e=="string")return e.charCodeAt(0)||0;let t=e.hasOwnProperty(ht)?e[ht]:void 0;return typeof t=="number"?t>=0?t&al:Dd:t}function ao(e,t,n){let r=1<>ul)]&r)}function uo(e,t){return!(e&2)&&!(e&1&&t)}var pt=class{_tNode;_lView;constructor(e,t){this._tNode=e,this._lView=t}get(e,t,n){return hl(this._tNode,this._lView,e,mt(n),t)}};function Dd(){return new pt(ce(),S())}function Md(e,t,n,r,i){let o=e,s=t;for(;o!==null&&s!==null&&s[v]&2048&&!tn(s);){let l=pl(o,s,n,r|2,G);if(l!==G)return l;let a=o.parent;if(!a){let u=s[Os];if(u){let c=u.get(n,G,r&-5);if(c!==G)return c}a=gl(s),s=s[nt]}o=a}return i}function gl(e){let t=e[g],n=t.type;return n===2?t.declTNode:n===1?e[W]:null}function Pd(){return ot(ce(),S())}function ot(e,t){return new In(ue(e,t))}var In=(()=>{class e{nativeElement;constructor(n){this.nativeElement=n}static __NG_ELEMENT_ID__=Pd}return e})();function Nd(e){return e instanceof In?e.nativeElement:e}function Ad(){return this._results[Symbol.iterator]()}var Vd=class{_emitDistinctChangesOnly;dirty=!0;_onDirty=void 0;_results=[];_changesDetected=!1;_changes=void 0;length=0;first=void 0;last=void 0;get changes(){return this._changes??=new Et}constructor(e=!1){this._emitDistinctChangesOnly=e}get(e){return this._results[e]}map(e){return this._results.map(e)}filter(e){return this._results.filter(e)}find(e){return this._results.find(e)}reduce(e,t){return this._results.reduce(e,t)}forEach(e){this._results.forEach(e)}some(e){return this._results.some(e)}toArray(){return this._results.slice()}toString(){return this._results.toString()}reset(e,t){this.dirty=!1;let n=ec(e);(this._changesDetected=!Ju(this._results,n,t))&&(this._results=n,this.length=n.length,this.last=n[this.length-1],this.first=n[0])}notifyOnChanges(){this._changes!==void 0&&(this._changesDetected||!this._emitDistinctChangesOnly)&&this._changes.next(this)}onDirty(e){this._onDirty=e}setDirty(){this.dirty=!0,this._onDirty?.()}destroy(){this._changes!==void 0&&(this._changes.complete(),this._changes.unsubscribe())}[Symbol.iterator]=Ad};function ml(e){return(e.flags&128)===128}var vl=function(e){return e[e.OnPush=0]="OnPush",e[e.Eager=1]="Eager",e[e.Default=1]="Default",e}(vl||{}),yl=new Map,Rd=0;function Ld(){return Rd++}function jd(e){yl.set(e[be],e)}function Cr(e){yl.delete(e[be])}var co="__ngContext__";function Je(e,t){Ie(t)?(e[co]=t[be],jd(t)):e[co]=t}function bl(e){return _l(e[bt])}function wl(e){return _l(e[Z])}function _l(e){for(;e!==null&&!ae(e);)e=e[Z];return e}var xr;function Fd(e){xr=e}function Hd(){if(xr!==void 0)return xr;if(typeof document<"u")return document;throw new w(210,!1)}var Cl=new E("",{factory:()=>zd}),zd="ng",xl=new E(""),kl=new E("",{providedIn:"platform",factory:()=>"unknown"}),Sl=new E("",{factory:()=>b(we).body?.querySelector("[ngCspNonce]")?.getAttribute("ngCspNonce")||null}),Bd="r",qd="di",El=!1,Ud=new E("",{factory:()=>El}),fo=new WeakMap;function Zd(e,t){if(e==null||typeof e!="object")return;let n=fo.get(e);n||(n=new WeakSet,fo.set(e,n)),n.add(t)}var $d=(e,t,n,r)=>{};function Qd(e,t,n,r){$d(e,t,n,r)}function Il(e){return(e.flags&32)===32}var Wd=()=>null;function Tl(e,t,n=!1){return Wd(e,t,n)}function Ol(e,t){let n=e.contentQueries;if(n!==null){let r=m(null);try{for(let i=0;ie,createScript:e=>e,createScriptURL:e=>e})}catch{}return Vt}function Tn(e){return Yd()?.createHTML(e)||e}var Rt;function Kd(){if(Rt===void 0&&(Rt=null,Kt.trustedTypes))try{Rt=Kt.trustedTypes.createPolicy("angular#unsafe-bypass",{createHTML:e=>e,createScript:e=>e,createScriptURL:e=>e})}catch{}return Rt}function ho(e){return Kd()?.createHTML(e)||e}var ze=class{changingThisBreaksApplicationSecurity;constructor(e){this.changingThisBreaksApplicationSecurity=e}toString(){return`SafeValue must use [property]=binding: ${this.changingThisBreaksApplicationSecurity} (see ${as})`}},Xd=class extends ze{getTypeName(){return"HTML"}},Jd=class extends ze{getTypeName(){return"Style"}},ef=class extends ze{getTypeName(){return"Script"}},tf=class extends ze{getTypeName(){return"URL"}},nf=class extends ze{getTypeName(){return"ResourceURL"}};function Ce(e){return e instanceof ze?e.changingThisBreaksApplicationSecurity:e}function Ze(e,t){let n=rf(e);if(n!=null&&n!==t){if(n==="ResourceURL"&&t==="URL")return!0;throw new Error(`Required a safe ${t}, got a ${n} (see ${as})`)}return n===t}function rf(e){return e instanceof ze&&e.getTypeName()||null}function of(e){return new Xd(e)}function sf(e){return new Jd(e)}function lf(e){return new ef(e)}function af(e){return new tf(e)}function uf(e){return new nf(e)}function cf(e){let t=new ff(e);return hf()?new df(t):t}var df=class{inertDocumentHelper;constructor(e){this.inertDocumentHelper=e}getInertBodyElement(e){e=""+e;try{let t=new window.DOMParser().parseFromString(Tn(e),"text/html").body;return t===null?this.inertDocumentHelper.getInertBodyElement(e):(t.firstChild?.remove(),t)}catch{return null}}},ff=class{defaultDoc;inertDocument;constructor(e){this.defaultDoc=e,this.inertDocument=this.defaultDoc.implementation.createHTMLDocument("sanitization-inert")}getInertBodyElement(e){let t=this.inertDocument.createElement("template");return t.innerHTML=Tn(e),t}};function hf(){try{return!!new window.DOMParser().parseFromString(Tn(""),"text/html")}catch{return!1}}var pf=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:\/?#]*(?:[\/?#]|$))/i;function Dl(e){return e=String(e),e.match(pf)?e:"unsafe:"+e}function de(e){let t={};for(let n of e.split(","))t[n]=!0;return t}function Pt(...e){let t={};for(let n of e)for(let r in n)n.hasOwnProperty(r)&&(t[r]=!0);return t}var Ml=de("area,br,col,hr,img,wbr"),Pl=de("colgroup,dd,dt,li,p,tbody,td,tfoot,th,thead,tr"),Nl=de("rp,rt"),gf=Pt(Nl,Pl),mf=Pt(Pl,de("address,article,aside,blockquote,caption,center,del,details,dialog,dir,div,dl,figure,figcaption,footer,h1,h2,h3,h4,h5,h6,header,hgroup,hr,ins,main,map,menu,nav,ol,pre,section,summary,table,ul")),vf=Pt(Nl,de("a,abbr,acronym,audio,b,bdi,bdo,big,br,cite,code,del,dfn,em,font,i,img,ins,kbd,label,map,mark,picture,q,ruby,rp,rt,s,samp,small,source,span,strike,strong,sub,sup,time,track,tt,u,var,video")),po=Pt(Ml,mf,vf,gf),Al=de("background,cite,href,itemtype,longdesc,poster,src,xlink:href"),yf=de("abbr,accesskey,align,alt,autoplay,axis,bgcolor,border,cellpadding,cellspacing,class,clear,color,cols,colspan,compact,controls,coords,datetime,default,dir,download,face,headers,height,hidden,hreflang,hspace,ismap,itemscope,itemprop,kind,label,lang,language,loop,media,muted,nohref,nowrap,open,preload,rel,rev,role,rows,rowspan,rules,scope,scrolling,shape,size,sizes,span,srclang,srcset,start,summary,tabindex,target,title,translate,type,usemap,valign,value,vspace,width"),bf=de("aria-activedescendant,aria-atomic,aria-autocomplete,aria-busy,aria-checked,aria-colcount,aria-colindex,aria-colspan,aria-controls,aria-current,aria-describedby,aria-details,aria-disabled,aria-dropeffect,aria-errormessage,aria-expanded,aria-flowto,aria-grabbed,aria-haspopup,aria-hidden,aria-invalid,aria-keyshortcuts,aria-label,aria-labelledby,aria-level,aria-live,aria-modal,aria-multiline,aria-multiselectable,aria-orientation,aria-owns,aria-placeholder,aria-posinset,aria-pressed,aria-readonly,aria-relevant,aria-required,aria-roledescription,aria-rowcount,aria-rowindex,aria-rowspan,aria-selected,aria-setsize,aria-sort,aria-valuemax,aria-valuemin,aria-valuenow,aria-valuetext"),wf=Pt(Al,yf,bf),_f=de("script,style,template"),Cf=class{sanitizedSomething=!1;buf=[];sanitizeChildren(e){let t=e.firstChild,n=!0,r=[];for(;t;){if(t.nodeType===Node.ELEMENT_NODE?n=this.startElement(t):t.nodeType===Node.TEXT_NODE?this.chars(t.nodeValue):this.sanitizedSomething=!0,n&&t.firstChild){r.push(t),t=Sf(t);continue}for(;t;){t.nodeType===Node.ELEMENT_NODE&&this.endElement(t);let i=kf(t);if(i){t=i;break}t=r.pop()}}return this.buf.join("")}startElement(e){let t=go(e).toLowerCase();if(!po.hasOwnProperty(t))return this.sanitizedSomething=!0,!_f.hasOwnProperty(t);this.buf.push("<"),this.buf.push(t);let n=e.attributes;for(let r=0;r"),!0}endElement(e){let t=go(e).toLowerCase();po.hasOwnProperty(t)&&!Ml.hasOwnProperty(t)&&(this.buf.push(""))}chars(e){this.buf.push(mo(e))}};function xf(e,t){return(e.compareDocumentPosition(t)&Node.DOCUMENT_POSITION_CONTAINED_BY)!==Node.DOCUMENT_POSITION_CONTAINED_BY}function kf(e){let t=e.nextSibling;if(t&&e!==t.previousSibling)throw Vl(t);return t}function Sf(e){let t=e.firstChild;if(t&&xf(e,t))throw Vl(t);return t}function go(e){let t=e.nodeName;return typeof t=="string"?t:"FORM"}function Vl(e){return new Error(`Failed to sanitize html because the element is clobbered: ${e.outerHTML}`)}var Ef=/[\uD800-\uDBFF][\uDC00-\uDFFF]/g,If=/([^\#-~ |!])/g;function mo(e){return e.replace(/&/g,"&").replace(Ef,function(t){let n=t.charCodeAt(0),r=t.charCodeAt(1);return"&#"+((n-55296)*1024+(r-56320)+65536)+";"}).replace(If,function(t){return"&#"+t.charCodeAt(0)+";"}).replace(//g,">")}var Lt;function Rl(e,t){let n=null;try{Lt=Lt||cf(e);let r=t?String(t):"";n=Lt.getInertBodyElement(r);let i=5,o=r;do{if(i===0)throw new Error("Failed to sanitize html because the input is unstable");i--,r=o,o=n.innerHTML,n=Lt.getInertBodyElement(r)}while(r!==o);let s=new Cf().sanitizeChildren(vo(n)||n);return Tn(s)}finally{if(n){let r=vo(n)||n;for(;r.firstChild;)r.firstChild.remove()}}}function vo(e){return"content"in e&&Tf(e)?e.content:null}function Tf(e){return e.nodeType===Node.ELEMENT_NODE&&e.nodeName==="TEMPLATE"}function Of(e,t){return e.createText(t)}function Df(e,t,n){e.setValue(t,n)}function Ll(e,t,n){return e.createElement(t,n)}function cn(e,t,n,r,i){e.insertBefore(t,n,r,i)}function jl(e,t,n){e.appendChild(t,n)}function yo(e,t,n,r,i){r!==null?cn(e,t,n,r,i):jl(e,t,n)}function Fl(e,t,n,r){e.removeChild(null,t,n,r)}function Mf(e,t,n){e.setAttribute(t,"style",n)}function Pf(e,t,n){n===""?e.removeAttribute(t,"class"):e.setAttribute(t,"class",n)}function Hl(e,t,n){let{mergedAttrs:r,classes:i,styles:o}=n;r!==null&&Cd(e,t,r),i!==null&&Pf(e,t,i),o!==null&&Mf(e,t,o)}var he=function(e){return e[e.NONE=0]="NONE",e[e.HTML=1]="HTML",e[e.STYLE=2]="STYLE",e[e.SCRIPT=3]="SCRIPT",e[e.URL=4]="URL",e[e.RESOURCE_URL=5]="RESOURCE_URL",e}(he||{});function Nf(e){let t=Af();return t?ho(t.sanitize(he.HTML,e)||""):Ze(e,"HTML")?ho(Ce(e)):Rl(Hd(),ps(e))}function Af(){let e=S();return e&&e[ie].sanitizer}var Vf="ng-template";function Rf(e){return e.type===4&&e.value!==Vf}function Sr(e){return(e&1)===0}function bo(e,t){return e?":not("+t.trim()+")":t}function Lf(e){let t=e[0],n=1,r=2,i="",o=!1;for(;n0?'="'+l+'"':"")+"]"}else r&8?i+="."+s:r&4&&(i+=" "+s);else i!==""&&!Sr(s)&&(t+=bo(o,i),i=""),r=s,o=o||!Sr(r);n++}return i!==""&&(t+=bo(o,i)),t}function jf(e){return e.map(Lf).join(",")}function Ff(e){let t=[],n=[],r=1,i=2;for(;r=0;o--){let s=n[o],l=s.parentNode;s===t?(n.splice(o,1),ct.add(s),s.dispatchEvent(new CustomEvent("animationend",{detail:{cancel:!0}}))):(i&&s===i||l&&r&&l!==r)&&(n.splice(o,1),s.dispatchEvent(new CustomEvent("animationend",{detail:{cancel:!0}})),s.parentNode?.removeChild(s))}}function Zf(e,t){let n=Ir.get(e);n?n.includes(t)||n.push(t):Ir.set(e,[t])}var _t=new Set,Ul=function(e){return e[e.CHANGE_DETECTION=0]="CHANGE_DETECTION",e[e.AFTER_NEXT_RENDER=1]="AFTER_NEXT_RENDER",e}(Ul||{}),Dn=new E(""),wo=new Set;function st(e){wo.has(e)||(wo.add(e),performance?.mark?.("mark_feature_usage",{detail:{feature:e}}))}var $f=(()=>{class e{impl=null;execute(){this.impl?.execute()}static \u0275prov=D({token:e,providedIn:"root",factory:()=>new e})}return e})(),Zl=new E("",{factory:()=>({queue:new Set,isScheduled:!1,scheduler:null,injector:b(ve)})});function $l(e,t,n){let r=e.get(Zl);if(Array.isArray(t))for(let i of t)r.queue.add(i),n?.detachedLeaveAnimationFns?.push(i);else r.queue.add(t),n?.detachedLeaveAnimationFns?.push(t);r.scheduler&&r.scheduler(e)}function Qf(e,t){let n=e.get(Zl);if(t.detachedLeaveAnimationFns){for(let r of t.detachedLeaveAnimationFns)n.queue.delete(r);t.detachedLeaveAnimationFns=void 0}}function Wf(e,t){for(let[n,r]of t)$l(e,r.animateFns)}function _o(e,t,n,r){let i=e?.[Me]?.enter;t!==null&&i&&i.has(n.index)&&Wf(r,i)}function We(e,t,n,r,i,o,s,l){if(i!=null){let a,u=!1;ae(i)?a=i:Ie(i)&&(u=!0,i=i[le]);let c=X(i);e===0&&r!==null?(_o(l,r,o,n),s==null?jl(t,r,c):cn(t,r,c,s||null,!0)):e===1&&r!==null?(_o(l,r,o,n),cn(t,r,c,s||null,!0),Uf(o,c)):e===2?(l?.[Me]?.leave?.has(o.index)&&Zf(o,c),ct.delete(c),Co(l,o,n,d=>{if(ct.has(c)){ct.delete(c);return}Fl(t,c,u,d)})):e===3&&(ct.delete(c),Co(l,o,n,()=>{t.destroyNode(c)})),a!=null&&lh(t,e,n,a,o,r,s)}}function Gf(e,t){Ql(e,t),t[le]=null,t[W]=null}function Yf(e,t,n,r,i,o){r[le]=i,r[W]=t,Pn(e,r,n,1,i,o)}function Ql(e,t){t[ie].changeDetectionScheduler?.notify(9),Pn(e,t,t[V],2,null,null)}function Kf(e){let t=e[bt];if(!t)return Gn(e[g],e);for(;t;){let n=null;if(Ie(t))n=t[bt];else{let r=t[T];r&&(n=r)}if(!n){for(;t&&!t[Z]&&t!==e;)Ie(t)&&Gn(t[g],t),t=t[A];t===null&&(t=e),Ie(t)&&Gn(t[g],t),n=t&&t[Z]}t=n}}function Ei(e,t){let n=e[Xe],r=n.indexOf(t);n.splice(r,1)}function Mn(e,t){if(it(t))return;let n=t[V];n.destroyNode&&Pn(e,t,n,3,null,null),Kf(t)}function Gn(e,t){if(it(t))return;let n=m(null);try{t[v]&=-129,t[v]|=256,t[q]&&yn(t[q]),eh(e,t),Jf(e,t),t[g].type===1&&t[V].destroy();let r=t[Oe];if(r!==null&&ae(t[A])){r!==t[A]&&Ei(r,t);let i=t[oe];i!==null&&i.detachView(e)}Cr(t)}finally{m(n)}}function Co(e,t,n,r){let i=e?.[Me];if(i==null||i.leave==null||!i.leave.has(t.index))return r(!1);e&&_t.add(e[be]),$l(n,()=>{if(i.leave&&i.leave.has(t.index)){let o=i.leave.get(t.index),s=[];if(o){for(let l=0;l{e[Me].running=void 0,_t.delete(e[be]),t(!0)});return}t(!1)}function Jf(e,t){let n=e.cleanup,r=t[Jt];if(n!==null)for(let s=0;s=0?r[l]():r[-l].unsubscribe(),s+=2}else{let l=r[n[s+1]];n[s].call(l)}r!==null&&(t[Jt]=null);let i=t[ge];if(i!==null){t[ge]=null;for(let s=0;sH&&ql(e,t,H,!1);let l=s?_.TemplateUpdateStart:_.TemplateCreateStart;x(l,i,n),n(r,i)}finally{Ve(o);let l=s?_.TemplateUpdateEnd:_.TemplateCreateEnd;x(l,i,n)}}function uh(e,t,n){ph(e,t,n),(n.flags&64)===64&&gh(e,t,n)}function Yl(e,t,n=ue){let r=t.localNames;if(r!==null){let i=t.index+1;for(let o=0;onull;function hh(e,t,n,r,i,o){if(e.type&3){let s=ue(e,t);r=o!=null?o(r,e.value||"",n):r,i.setProperty(s,n,r)}else e.type&12}function ph(e,t,n){let r=n.directiveStart,i=n.directiveEnd;wn(n)&&Bf(t,n,e.data[r+n.componentOffset]),e.firstCreatePass||un(n,t);let o=n.initialInputs;for(let s=r;s{Tt(e.lView)},consumerOnSignalRead(){this.lView[q]=this}});function Dh(e){let t=e[q]??Object.create(Mh);return t.lView=e,t}var Mh=Q($({},St),{consumerIsAlwaysLive:!0,kind:"template",consumerMarkedDirty:e=>{let t=Ae(e.lView);for(;t&&!Jl(t[g]);)t=Ae(t);t&&Vs(t)},consumerOnSignalRead(){this.lView[q]=this}});function Jl(e){return e.type!==2}function ea(e){if(e[De]===null)return;let t=!0;for(;t;){let n=!1;for(let r of e[De])r.dirty&&(n=!0,r.zone===null||Zone.current===r.zone?r.run():r.zone.run(()=>r.run()));t=n&&!!(e[v]&8192)}}var Ph=100;function ta(e,t=0){let n=e[ie].rendererFactory,r=!1;r||n.begin?.();try{Nh(e,t)}finally{r||n.end?.()}}function Nh(e,t){let n=zs();try{rn(!0),Or(e,t);let r=0;for(;nn(e);){if(r===Ph)throw new w(103,!1);r++,Or(e,1)}}finally{rn(n)}}function Ah(e,t,n,r){if(it(t))return;let i=t[v],o=!1,s=!1;hi(t);let l=!0,a=null,u=null;o||(Jl(e)?(u=Eh(t),a=Gt(u)):Wa()===null?(l=!1,u=Dh(t),a=Gt(u)):t[q]&&(yn(t[q]),t[q]=null));try{As(t),Mc(e.bindingStartIndex),n!==null&&Gl(e,t,n,2,r);let c=(i&3)===3;if(!o)if(c){let f=e.preOrderCheckHooks;f!==null&&qt(t,f,null)}else{let f=e.preOrderHooks;f!==null&&Ut(t,f,0,null),Qn(t,0)}if(s||Vh(t),ea(t),na(t,0),e.contentQueries!==null&&Ol(e,t),!o)if(c){let f=e.contentCheckHooks;f!==null&&qt(t,f)}else{let f=e.contentHooks;f!==null&&Ut(t,f,1),Qn(t,1)}Lh(e,t);let d=e.components;d!==null&&ia(t,d,0);let h=e.viewQuery;if(h!==null&&kr(2,h,r),!o)if(c){let f=e.viewCheckHooks;f!==null&&qt(t,f)}else{let f=e.viewHooks;f!==null&&Ut(t,f,2),Qn(t,2)}if(e.firstUpdatePass===!0&&(e.firstUpdatePass=!1),t[Un]){for(let f of t[Un])f();t[Un]=null}o||(Kl(t),t[v]&=-73)}catch(c){throw o||Tt(t),c}finally{u!==null&&(Gr(u,a),l&&Th(u)),pi()}}function na(e,t){for(let n=bl(e);n!==null;n=wl(n))for(let r=T;r0&&(e[n-1][Z]=r[Z]);let o=Xt(e,T+t);Gf(r[g],r);let s=o[oe];s!==null&&s.detachView(o[g]),r[A]=null,r[Z]=null,r[v]&=-129}return r}function jh(e,t,n,r){let i=T+r,o=n.length;r>0&&(n[i-1][Z]=t),r-1&&(xt(e,n),Xt(t,n))}this._attachedToViewContainer=!1}Mn(this._lView[g],this._lView)}onDestroy(e){Rs(this._lView,e)}markForCheck(){Di(this._cdRefInjectingView||this._lView,4)}detach(){this._lView[v]&=-129}reattach(){gr(this._lView),this._lView[v]|=128}detectChanges(){this._lView[v]|=1024,ta(this._lView)}checkNoChanges(){}attachToViewContainerRef(){if(this._appRef)throw new w(902,!1);this._attachedToViewContainer=!0}detachFromAppRef(){this._appRef=null;let e=tn(this._lView),t=this._lView[Oe];t!==null&&!e&&Ei(t,this._lView),Ql(this._lView[g],this._lView)}attachToAppRef(e){if(this._attachedToViewContainer)throw new w(902,!1);this._appRef=e;let t=tn(this._lView),n=this._lView[Oe];n!==null&&!t&&aa(n,this._lView),gr(this._lView)}},fn=(()=>{class e{_declarationLView;_declarationTContainer;elementRef;static __NG_ELEMENT_ID__=Fh;constructor(n,r,i){this._declarationLView=n,this._declarationTContainer=r,this.elementRef=i}get ssrId(){return this._declarationTContainer.tView?.ssrId||null}createEmbeddedView(n,r){return this.createEmbeddedViewImpl(n,r)}createEmbeddedViewImpl(n,r,i){let o=Nn(this._declarationLView,this._declarationTContainer,n,{embeddedViewInjector:r,dehydratedView:i});return new Mi(o)}}return e})();function Fh(){return Pi(ce(),S())}function Pi(e,t){return e.type&4?new fn(t,e,ot(e,t)):null}function Vn(e,t,n,r,i){let o=e.data[t];if(o===null)o=Hh(e,t,n,r,i),Nc()&&(o.flags|=32);else if(o.type&64){o.type=n,o.value=r,o.attrs=i;let s=Oc();o.injectorIndex=s===null?-1:s.injectorIndex}return Ot(o,!0),o}function Hh(e,t,n,r,i){let o=Fs(),s=Hs(),l=s?o:o&&o.parent,a=e.data[t]=Bh(e,l,n,t,r,i);return zh(e,a,o,s),a}function zh(e,t,n,r){e.firstChild===null&&(e.firstChild=t),n!==null&&(r?n.child==null&&t.parent!==null&&(n.child=t):n.next===null&&(n.next=t,t.prev=n))}function Bh(e,t,n,r,i,o){let s=t?t.injectorIndex:-1,l=0;return Ec()&&(l|=128),{type:n,index:r,insertBeforeIndex:null,injectorIndex:s,directiveStart:-1,directiveEnd:-1,directiveStylingLast:-1,componentOffset:-1,controlDirectiveIndex:-1,customControlIndex:-1,propertyBindings:null,flags:l,providerIndexes:0,value:i,attrs:o,mergedAttrs:null,localNames:null,initialInputs:null,inputs:null,hostDirectiveInputs:null,outputs:null,hostDirectiveOutputs:null,directiveToIndex:null,tView:null,next:null,prev:null,projectionNext:null,child:null,parent:t,projection:null,styles:null,stylesWithoutHost:null,residualStyles:void 0,classes:null,classesWithoutHost:null,residualClasses:void 0,classBindings:0,styleBindings:0}}function qh(e){let t=e[eo]??[],n=e[A][V],r=[];for(let i of t)i.data[qd]!==void 0?r.push(i):Uh(i,n);e[eo]=r}function Uh(e,t){let n=0,r=e.firstChild;if(r){let i=e.data[Bd];for(;nnull,$h=()=>null;function Dr(e,t){return Zh(e,t)}function ua(e,t,n){return $h(e,t,n)}var Qh=class{},ca=class{},Wh=class{resolveComponentFactory(e){throw new w(917,!1)}},Ni=class{static NULL=new Wh},Ai=class{},Gh=(()=>{class e{static \u0275prov=D({token:e,providedIn:"root",factory:()=>null})}return e})(),Yn={},Yh=class{injector;parentInjector;constructor(e,t){this.injector=e,this.parentInjector=t}get(e,t,n){let r=this.injector.get(e,Yn,n);return r!==Yn||t===Yn?r:this.parentInjector.get(e,t,n)}};function hn(e,t,n){let r=n?e.styles:null,i=n?e.classes:null,o=0;if(t!==null)for(let s=0;s0&&(n.directiveToIndex=new Map);for(let h=0;h0;){let n=e[--t];if(typeof n=="number"&&n<0)return n}return 0}function op(e,t,n){if(n){if(t.exportAs)for(let r=0;rr(X(M[e.index])):e.index;pp(p,t,n,o,l,f,!1)}}return u}function fp(e){return e.startsWith("animation")||e.startsWith("transition")}function hp(e,t,n,r){let i=e.cleanup;if(i!=null)for(let o=0;oa?l[a]:null}typeof s=="string"&&(o+=2)}return null}function pp(e,t,n,r,i,o,s){let l=t.firstCreatePass?js(t):null,a=Ls(n),u=a.length;a.push(i,o),l&&l.push(r,e,u,(u+1)*(s?-1:1))}var Mr=Symbol("BINDING");function gp(e){return e.debugInfo?.className||e.type.name||null}var mp=class extends Ni{ngModule;constructor(e){super(),this.ngModule=e}resolveComponentFactory(e){let t=gt(e);return new fa(t,this.ngModule)}};function vp(e){return Object.keys(e).map(t=>{let[n,r,i]=e[t],o={propName:n,templateName:t,isSignal:(r&On.SignalBased)!==0};return i&&(o.transform=i),o})}function yp(e){return Object.keys(e).map(t=>({propName:e[t],templateName:t}))}function bp(e,t,n){let r=t instanceof ve?t:t?.injector;return r&&e.getStandaloneInjector!==null&&(r=e.getStandaloneInjector(r)||r),r?new Yh(n,r):n}function wp(e){let t=e.get(Ai,null);if(t===null)throw new w(407,!1);let n=e.get(Gh,null),r=e.get(bi,null),i=e.get(Dn,null,{optional:!0});return{rendererFactory:t,sanitizer:n,changeDetectionScheduler:r,ngReflect:!1,tracingService:i}}function _p(e,t){let n=Cp(e);return Ll(t,n,n==="svg"?mc:n==="math"?vc:null)}function Cp(e){return(e.selectors[0][0]||"div").toLowerCase()}var fa=class extends ca{componentDef;ngModule;selector;componentType;ngContentSelectors;isBoundToModule;cachedInputs=null;cachedOutputs=null;get inputs(){return this.cachedInputs??=vp(this.componentDef.inputs),this.cachedInputs}get outputs(){return this.cachedOutputs??=yp(this.componentDef.outputs),this.cachedOutputs}constructor(e,t){super(),this.componentDef=e,this.ngModule=t,this.componentType=e.type,this.selector=jf(e.selectors),this.ngContentSelectors=e.ngContentSelectors??[],this.isBoundToModule=!!t}create(e,t,n,r,i,o){x(_.DynamicComponentStart);let s=m(null);try{let l=this.componentDef,a=bp(l,r||this.ngModule,e),u=wp(a),c=u.tracingService;return c&&c.componentCreate?c.componentCreate(gp(l),()=>this.createComponentRef(u,a,t,n,i,o)):this.createComponentRef(u,a,t,n,i,o)}finally{m(s)}}createComponentRef(e,t,n,r,i,o){let s=this.componentDef,l=xp(r,s,o,i),a=e.rendererFactory.createRenderer(null,s),u=r?ch(a,r,s.encapsulation,t):_p(s,a),c=o?.some(To)||i?.some(f=>typeof f!="function"&&f.bindings.some(To)),d=xi(null,l,null,512|zl(s),null,null,e,a,t,null,Tl(u,t,!0));d[H]=u,hi(d);let h=null;try{let f=lp(H,d,2,"#host",()=>l.directiveRegistry,!0,0);Hl(a,u,f),Je(u,d),uh(l,d,f),Gd(l,f,d),ap(l,f),n!==void 0&&Ep(f,this.ngContentSelectors,n),h=Ne(f.index,d),d[O]=h[O],Oi(l,d,null)}catch(f){throw h!==null&&Cr(h),Cr(d),f}finally{x(_.DynamicComponentEnd),pi()}return new Sp(this.componentType,d,!!c)}};function xp(e,t,n,r){let i=e?["ng-version","21.2.11"]:Ff(t.selectors[0]),o=null,s=null,l=0;if(n)for(let u of n)l+=u[Mr].requiredVars,u.create&&(u.targetIdx=0,(o??=[]).push(u)),u.update&&(u.targetIdx=0,(s??=[]).push(u));if(r)for(let u=0;u{if(n&1&&e)for(let r of e)r.create();if(n&2&&t)for(let r of t)r.update()}}function To(e){let t=e[Mr].kind;return t==="input"||t==="twoWay"}var Sp=class extends Qh{_rootLView;_hasInputBindings;instance;hostView;changeDetectorRef;componentType;location;previousInputValues=null;_tNode;constructor(e,t,n){super(),this._rootLView=t,this._hasInputBindings=n,this._tNode=ci(t[g],H),this.location=ot(this._tNode,t),this.instance=Ne(this._tNode.index,t)[O],this.hostView=this.changeDetectorRef=new Mi(t,void 0),this.componentType=e}setInput(e,t){this._hasInputBindings;let n=this._tNode;if(this.previousInputValues??=new Map,this.previousInputValues.has(e)&&Object.is(this.previousInputValues.get(e),t))return;let r=this._rootLView,i=_h(n,r[g],r,e,t);this.previousInputValues.set(e,t);let o=Ne(n.index,r);Di(o,1)}get injector(){return new pt(this._tNode,this._rootLView)}destroy(){this.hostView.destroy()}onDestroy(e){this.hostView.onDestroy(e)}};function Ep(e,t,n){let r=e.projection=[];for(let i=0;i{class e{static __NG_ELEMENT_ID__=Ip}return e})();function Ip(){let e=ce();return pa(e,S())}var Tp=class ha extends Ri{_lContainer;_hostTNode;_hostLView;constructor(t,n,r){super(),this._lContainer=t,this._hostTNode=n,this._hostLView=r}get element(){return ot(this._hostTNode,this._hostLView)}get injector(){return new pt(this._hostTNode,this._hostLView)}get parentInjector(){let t=_i(this._hostTNode,this._hostLView);if(ll(t)){let n=an(t,this._hostLView),r=ln(t),i=n[g].data[r+8];return new pt(i,n)}else return new pt(null,this._hostLView)}clear(){for(;this.length>0;)this.remove(this.length-1)}get(t){let n=Oo(this._lContainer);return n!==null&&n[t]||null}get length(){return this._lContainer.length-T}createEmbeddedView(t,n,r){let i,o;typeof r=="number"?i=r:r!=null&&(i=r.index,o=r.injector);let s=Dr(this._lContainer,t.ssrId),l=t.createEmbeddedViewImpl(n||{},o,s);return this.insertImpl(l,i,Ct(this._hostTNode,s)),l}createComponent(t,n,r,i,o,s,l){let a=t&&!hd(t),u;if(a)u=n;else{let I=n||{};u=I.index,r=I.injector,i=I.projectableNodes,o=I.environmentInjector||I.ngModuleRef,s=I.directives,l=I.bindings}let c=a?t:new fa(gt(t)),d=r||this.parentInjector;if(!o&&c.ngModule==null){let I=(a?d:this.parentInjector).get(ve,null);I&&(o=I)}let h=gt(c.componentType??{}),f=Dr(this._lContainer,h?.id??null),p=f?.firstChild??null,M=c.create(d,i,p,o,s,l);return this.insertImpl(M.hostView,u,Ct(this._hostTNode,f)),M}insert(t,n){return this.insertImpl(t,n,!0)}insertImpl(t,n,r){let i=t._lView;if(bc(i)){let l=this.indexOf(t);if(l!==-1)this.detach(l);else{let a=i[A],u=new ha(a,a[W],a[A]);u.detach(u.indexOf(t))}}let o=this._adjustIndex(n),s=this._lContainer;return An(s,i,o,r),t.attachToViewContainerRef(),ws(Kn(s),o,t),t}move(t,n){return this.insert(t,n)}indexOf(t){let n=Oo(this._lContainer);return n!==null?n.indexOf(t):-1}remove(t){let n=this._adjustIndex(t,-1),r=xt(this._lContainer,n);r&&(Xt(Kn(this._lContainer),n),Mn(r[g],r))}detach(t){let n=this._adjustIndex(t,-1),r=xt(this._lContainer,n);return r&&Xt(Kn(this._lContainer),n)!=null?new Mi(r):null}_adjustIndex(t,n=0){return t??this.length+n}};function Oo(e){return e[en]}function Kn(e){return e[en]||(e[en]=[])}function pa(e,t){let n,r=t[e.index];return ae(r)?n=r:(n=oa(r,t,null,e),t[e.index]=n,ki(t,n)),Dp(n,t,e,r),new Tp(n,e,t)}function Op(e,t){let n=e[V],r=n.createComment(""),i=ue(t,e),o=n.parentNode(i);return cn(n,o,r,n.nextSibling(i),!1),r}var Dp=Np,Mp=()=>!1;function Pp(e,t,n){return Mp(e,t,n)}function Np(e,t,n,r){if(e[Pe])return;let i;n.type&8?i=X(r):i=Op(t,n),e[Pe]=i}var Ap=class ga{queryList;matches=null;constructor(t){this.queryList=t}clone(){return new ga(this.queryList)}setDirty(){this.queryList.setDirty()}},Vp=class ma{queries;constructor(t=[]){this.queries=t}createEmbeddedView(t){let n=t.queries;if(n!==null){let r=t.contentQueries!==null?t.contentQueries[0]:n.length,i=[];for(let o=0;o0)r.push(s[l/2]);else{let u=o[l+1],c=t[-a];for(let d=T;dt.trim())}function Qp(e,t,n){e.queries===null&&(e.queries=new Lp),e.queries.track(new jp(t,n))}function Li(e,t){return e.queries.getByIndex(t)}function Wp(e,t){let n=e[g],r=Li(n,t);return r.crossesNgTemplate?Pr(n,e,t,[]):ba(n,e,r,t)}var Nr=class{},wa=class extends Nr{injector;componentFactoryResolver=new mp(this);instance=null;constructor(e){super();let t=new ui([...e.providers,{provide:Nr,useValue:this},{provide:Ni,useValue:this.componentFactoryResolver}],e.parent||ai(),e.debugName,new Set(["environment"]));this.injector=t,e.runEnvironmentInitializers&&t.resolveInjectorInitializers()}destroy(){this.injector.destroy()}onDestroy(e){this.injector.onDestroy(e)}};function Gp(e,t,n=null){return new wa({providers:e,parent:t,debugName:n,runEnvironmentInitializers:!0}).injector}var Yp=(()=>{class e{_injector;cachedInjectors=new Map;constructor(n){this._injector=n}getOrCreateStandaloneInjector(n){if(!n.standalone)return null;if(!this.cachedInjectors.has(n)){let r=ks(!1,n.type),i=r.length>0?Gp([r],this._injector,""):null;this.cachedInjectors.set(n,i)}return this.cachedInjectors.get(n)}ngOnDestroy(){try{for(let n of this.cachedInjectors.values())n!==null&&n.destroy()}finally{this.cachedInjectors.clear()}}static \u0275prov=D({token:e,providedIn:"environment",factory:()=>new e(C(ve))})}return e})();function Kp(e){return fd(()=>{let t=tg(e),n=Q($({},t),{decls:e.decls,vars:e.vars,template:e.template,consts:e.consts||null,ngContentSelectors:e.ngContentSelectors,onPush:e.changeDetection===vl.OnPush,directiveDefs:null,pipeDefs:null,dependencies:t.standalone&&e.dependencies||null,getStandaloneInjector:t.standalone?i=>i.get(Yp).getOrCreateStandaloneInjector(n):null,getExternalStyles:null,signals:e.signals??!1,data:e.data||{},encapsulation:e.encapsulation||re.Emulated,styles:e.styles||Te,_:null,schemas:e.schemas||null,tView:null,id:""});t.standalone&&st("NgStandalone"),ng(n);let r=e.dependencies;return n.directiveDefs=Do(r,Xp),n.pipeDefs=Do(r,qu),n.id=rg(n),n})}function Xp(e){return gt(e)||hs(e)}function Jp(e,t){if(e==null)return Ye;let n={};for(let r in e)if(e.hasOwnProperty(r)){let i=e[r],o,s,l,a;Array.isArray(i)?(l=i[0],o=i[1],s=i[2]??o,a=i[3]||null):(o=i,s=i,l=On.None,a=null),n[o]=[r,l,a],t[o]=s}return n}function eg(e){if(e==null)return Ye;let t={};for(let n in e)e.hasOwnProperty(n)&&(t[e[n]]=n);return t}function tg(e){let t={};return{type:e.type,providersResolver:null,viewProvidersResolver:null,factory:null,hostBindings:e.hostBindings||null,hostVars:e.hostVars||0,hostAttrs:e.hostAttrs||null,contentQueries:e.contentQueries||null,declaredInputs:t,inputConfig:e.inputs||Ye,exportAs:e.exportAs||null,standalone:e.standalone??!0,signals:e.signals===!0,selectors:e.selectors||Te,viewQuery:e.viewQuery||null,features:e.features||null,setInput:null,resolveHostDirectives:null,hostDirectives:null,controlDef:null,inputs:Jp(e.inputs,t),outputs:eg(e.outputs),debugInfo:null}}function ng(e){e.features?.forEach(t=>t(e))}function Do(e,t){return e?()=>{let n=typeof e=="function"?e():e,r=[];for(let i of n){let o=t(i);o!==null&&r.push(o)}return r}:null}function rg(e){let t=0,n=typeof e.consts=="function"?"":e.consts,r=[e.selectors,e.ngContentSelectors,e.hostVars,e.hostAttrs,n,e.vars,e.decls,e.encapsulation,e.standalone,e.signals,e.exportAs,JSON.stringify(e.inputs),JSON.stringify(e.outputs),Object.getOwnPropertyNames(e.type.prototype),!!e.contentQueries,!!e.viewQuery];for(let i of r.join("|"))t=Math.imul(31,t)+i.charCodeAt(0)<<0;return t+=2147483648,"c"+t}function ig(e,t,n,r,i,o,s,l){if(n.firstCreatePass){e.mergedAttrs=En(e.mergedAttrs,e.attrs);let c=e.tView=Ci(2,e,i,o,s,n.directiveRegistry,n.pipeRegistry,null,n.schemas,n.consts,null);n.queries!==null&&(n.queries.template(n,e),c.queries=n.queries.embeddedTView(e))}l&&(e.flags|=l),Ot(e,!1);let a=og(n,t,e,r);gi()&&Ii(n,t,a,e),Je(a,t);let u=oa(a,t,a,e);t[r+H]=u,ki(t,u),Pp(u,e,t)}function pn(e,t,n,r,i,o,s,l,a,u,c){let d=n+H,h;if(t.firstCreatePass){if(h=Vn(t,d,4,s||null,l||null),u!=null){let f=se(t.consts,u);h.localNames=[];for(let p=0;p{class e{resolve;reject;initialized=!1;done=!1;donePromise=new Promise((n,r)=>{this.resolve=n,this.reject=r});appInits=b(ug,{optional:!0})??[];injector=b(Cn);constructor(){}runInitializers(){if(this.initialized)return;let n=[];for(let i of this.appInits){let o=Ts(this.injector,i);if(_a(o))n.push(o);else if(ag(o)){let s=new Promise((l,a)=>{o.subscribe({complete:l,error:a})});n.push(s)}}let r=()=>{this.done=!0,this.resolve()};Promise.all(n).then(()=>{r()}).catch(i=>{this.reject(i)}),n.length===0&&r(),this.initialized=!0}static \u0275fac=function(n){return new(n||e)};static \u0275prov=D({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),cg=new E("");function dg(){ru(()=>{let e="";throw new w(600,e)})}function fg(e){return e.isBoundToModule}var hg=10,Ar=(()=>{class e{_runningTick=!1;_destroyed=!1;_destroyListeners=[];_views=[];internalErrorHandler=b(Dt);afterRenderManager=b($f);zonelessEnabled=b(wi);rootEffectScheduler=b(el);dirtyFlags=0;tracingSnapshot=null;allTestViews=new Set;autoDetectTestViews=new Set;includeAllTestViews=!1;afterTick=new Et;get allViews(){return[...(this.includeAllTestViews?this.allTestViews:this.autoDetectTestViews).keys(),...this._views]}get destroyed(){return this._destroyed}componentTypes=[];components=[];internalPendingTask=b(kn);get isStable(){return this.internalPendingTask.hasPendingTasksObservable.pipe(Pu(n=>!n))}constructor(){b(Dn,{optional:!0})}whenStable(){let n;return new Promise(r=>{n=this.isStable.subscribe({next:i=>{i&&r()}})}).finally(()=>{n.unsubscribe()})}_injector=b(ve);_rendererFactory=null;get injector(){return this._injector}bootstrap(n,r){return this.bootstrapImpl(n,r)}bootstrapImpl(n,r,i=Cn.NULL){return this._injector.get(He).run(()=>{x(_.BootstrapComponentStart);let o=n instanceof ca;if(!this._injector.get(Ca).done){let h="";throw new w(405,h)}let s;o?s=n:s=this._injector.get(Ni).resolveComponentFactory(n),this.componentTypes.push(s.componentType);let l=fg(s)?void 0:this._injector.get(Nr),a=r||s.selector,u=s.create(i,[],a,l),c=u.location.nativeElement,d=u.injector.get(lg,null);return d?.registerApplication(c),u.onDestroy(()=>{this.detachView(u.hostView),$t(this.components,u),d?.unregisterApplication(c)}),this._loadComponent(u),x(_.BootstrapComponentEnd,u),u})}tick(){this.zonelessEnabled||(this.dirtyFlags|=1),this._tick()}_tick(){x(_.ChangeDetectionStart),this.tracingSnapshot!==null?this.tracingSnapshot.run(Ul.CHANGE_DETECTION,this.tickImpl):this.tickImpl()}tickImpl=()=>{if(this._runningTick)throw x(_.ChangeDetectionEnd),new w(101,!1);let n=m(null);try{this._runningTick=!0,this.synchronize()}finally{this._runningTick=!1,this.tracingSnapshot?.dispose(),this.tracingSnapshot=null,m(n),this.afterTick.next(),x(_.ChangeDetectionEnd)}};synchronize(){this._rendererFactory===null&&!this._injector.destroyed&&(this._rendererFactory=this._injector.get(Ai,null,{optional:!0}));let n=0;for(;this.dirtyFlags!==0&&n++nn(n))){this.dirtyFlags|=2;return}else this.dirtyFlags&=-8}attachView(n){let r=n;this._views.push(r),r.attachToAppRef(this)}detachView(n){let r=n;$t(this._views,r),r.detachFromAppRef()}_loadComponent(n){this.attachView(n.hostView);try{this.tick()}catch(r){this.internalErrorHandler(r)}this.components.push(n),this._injector.get(cg,[]).forEach(r=>r(n))}ngOnDestroy(){if(!this._destroyed)try{this._destroyListeners.forEach(n=>n()),this._views.slice().forEach(n=>n.destroy())}finally{this._destroyed=!0,this._views=[],this._destroyListeners=[]}}onDestroy(n){return this._destroyListeners.push(n),()=>$t(this._destroyListeners,n)}destroy(){if(this._destroyed)throw new w(406,!1);let n=this._injector;n.destroy&&!n.destroyed&&n.destroy()}get viewCount(){return this._views.length}static \u0275fac=function(n){return new(n||e)};static \u0275prov=D({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function $t(e,t){let n=e.indexOf(t);n>-1&&e.splice(n,1)}var pg=class{destroy(e){}updateValue(e,t){}swap(e,t){let n=Math.min(e,t),r=Math.max(e,t),i=this.detach(r);if(r-n>1){let o=this.detach(n);this.attach(n,i),this.attach(r,o)}else this.attach(n,i)}move(e,t){this.attach(t,this.detach(e))}};function Xn(e,t,n,r,i){return e===n&&Object.is(t,r)?1:Object.is(i(e,t),i(n,r))?-1:0}function gg(e,t,n,r){let i,o,s=0,l=e.length-1,a;if(Array.isArray(t)){m(r);let u=t.length-1;for(m(null);s<=l&&s<=u;){let c=e.at(s),d=t[s],h=Xn(s,c,s,d,n);if(h!==0){h<0&&e.updateValue(s,d),s++;continue}let f=e.at(l),p=t[u],M=Xn(l,f,u,p,n);if(M!==0){M<0&&e.updateValue(l,p),l--,u--;continue}let I=n(s,c),J=n(l,f),lt=n(s,d);if(Object.is(lt,J)){let Rn=n(u,p);Object.is(Rn,I)?(e.swap(s,l),e.updateValue(l,p),u--,l--):e.move(l,s),e.updateValue(s,d),s++;continue}if(i??=new No,o??=Po(e,s,l,n),Vr(e,i,s,lt))e.updateValue(s,d),s++,l++;else if(o.has(lt))i.set(I,e.detach(s)),l--;else{let Rn=e.create(s,t[s]);e.attach(s,Rn),s++,l++}}for(;s<=u;)Mo(e,i,n,s,t[s]),s++}else if(t!=null){m(r);let u=t[Symbol.iterator]();m(null);let c=u.next();for(;!c.done&&s<=l;){let d=e.at(s),h=c.value,f=Xn(s,d,s,h,n);if(f!==0)f<0&&e.updateValue(s,h),s++,c=u.next();else{i??=new No,o??=Po(e,s,l,n);let p=n(s,h);if(Vr(e,i,s,p))e.updateValue(s,h),s++,l++,c=u.next();else if(!o.has(p))e.attach(s,e.create(s,h)),s++,l++,c=u.next();else{let M=n(s,d);i.set(M,e.detach(s)),l--}}}for(;!c.done;)Mo(e,i,n,e.length,c.value),c=u.next()}for(;s<=l;)e.destroy(e.detach(l--));i?.forEach(u=>{e.destroy(u)})}function Vr(e,t,n,r){return t!==void 0&&t.has(r)?(e.attach(n,t.get(r)),t.delete(r),!0):!1}function Mo(e,t,n,r,i){if(Vr(e,t,r,n(r,i)))e.updateValue(r,i);else{let o=e.create(r,i);e.attach(r,o)}}function Po(e,t,n,r){let i=new Set;for(let o=t;o<=n;o++)i.add(r(o,e.at(o)));return i}var No=class{kvMap=new Map;_vMap=void 0;has(e){return this.kvMap.has(e)}delete(e){if(!this.has(e))return!1;let t=this.kvMap.get(e);return this._vMap!==void 0&&this._vMap.has(t)?(this.kvMap.set(e,this._vMap.get(t)),this._vMap.delete(t)):this.kvMap.delete(e),!0}get(e){return this.kvMap.get(e)}set(e,t){if(this.kvMap.has(e)){let n=this.kvMap.get(e);this._vMap===void 0&&(this._vMap=new Map);let r=this._vMap;for(;r.has(n);)n=r.get(n);r.set(n,t)}else this.kvMap.set(e,t)}forEach(e){for(let[t,n]of this.kvMap)if(e(n,t),this._vMap!==void 0){let r=this._vMap;for(;r.has(n);)n=r.get(n),e(n,t)}}};function Rr(e,t,n,r,i,o,s,l){st("NgControlFlow");let a=S(),u=U(),c=se(u.consts,o);return pn(a,u,e,t,n,r,i,c,256,s,l),xa}function xa(e,t,n,r,i,o,s,l){st("NgControlFlow");let a=S(),u=U(),c=se(u.consts,o);return pn(a,u,e,t,n,r,i,c,512,s,l),xa}function Lr(e,t){st("NgControlFlow");let n=S(),r=_n(),i=n[r]!==_e?n[r]:-1,o=i!==-1?gn(n,H+i):void 0,s=0;if(Nt(n,r,e)){let l=m(null);try{if(o!==void 0&&la(o,s),e!==-1){let a=H+e,u=gn(n,a),c=jr(n[g],a),d=ua(u,c,n),h=Nn(n,c,t,{dehydratedView:d});An(u,h,s,Ct(c,d))}}finally{m(l)}}else if(o!==void 0){let l=sa(o,s);l!==void 0&&(l[O]=t)}}var mg=class{lContainer;$implicit;$index;constructor(e,t,n){this.lContainer=e,this.$implicit=t,this.$index=n}get $count(){return this.lContainer.length-T}};function Ao(e,t){return t}var vg=class{hasEmptyBlock;trackByFn;liveCollection;constructor(e,t,n){this.hasEmptyBlock=e,this.trackByFn=t,this.liveCollection=n}};function Vo(e,t,n,r,i,o,s,l,a,u,c,d,h){st("NgControlFlow");let f=S(),p=U(),M=a!==void 0,I=S(),J=l?s.bind(I[K][O]):s,lt=new vg(M,J);I[H+e]=lt,pn(f,p,e+1,t,n,r,i,se(p.consts,o),256),M&&pn(f,p,e+2,a,u,c,d,se(p.consts,h),512)}var yg=class extends pg{lContainer;hostLView;templateTNode;operationsCounter=void 0;needsIndexUpdate=!1;constructor(e,t,n){super(),this.lContainer=e,this.hostLView=t,this.templateTNode=n}get length(){return this.lContainer.length-T}at(e){return this.getLView(e)[O].$implicit}attach(e,t){let n=t[yt];this.needsIndexUpdate||=e!==this.length,An(this.lContainer,t,e,Ct(this.templateTNode,n)),bg(this.lContainer,e)}detach(e){return this.needsIndexUpdate||=e!==this.length-1,wg(this.lContainer,e),_g(this.lContainer,e)}create(e,t){let n=Dr(this.lContainer,this.templateTNode.tView.ssrId);return Nn(this.hostLView,this.templateTNode,new mg(this.lContainer,t,e),{dehydratedView:n})}destroy(e){Mn(e[g],e)}updateValue(e,t){this.getLView(e)[O].$implicit=t}reset(){this.needsIndexUpdate=!1}updateIndexes(){if(this.needsIndexUpdate)for(let e=0;e0){let o=r[ye];Qf(o,i),_t.delete(r[be]),i.detachedLeaveAnimationFns=void 0}}function wg(e,t){if(e.length<=T)return;let n=T+t,r=e[n],i=r?r[Me]:void 0;i&&i.leave&&i.leave.size>0&&(i.detachedLeaveAnimationFns=[])}function _g(e,t){return xt(e,t)}function Cg(e,t){return sa(e,t)}function jr(e,t){return ci(e,t)}function P(e,t,n,r){let i=S(),o=i[g],s=e+H,l=o.firstCreatePass?up(s,o,2,t,n,r):o.data[s];return yh(l,i,e,t,xg),r!=null&&Yl(i,l),P}function N(){let e=ce(),t=bh(e);return Ic(t)&&Tc(),Sc(),N}function ka(e,t,n,r){return P(e,t,n,r),N(),ka}var xg=(e,t,n,r,i)=>(mi(!0),Ll(t[V],r,Hc()));function Sa(){return S()}function te(e,t,n){let r=S(),i=_n();if(Nt(r,i,t)){let o=U(),s=Fc();hh(s,r,e,t,r[V],n)}return te}var mn="en-US",kg=mn;function Sg(e){typeof e=="string"&&(kg=e.toLowerCase().replace(/_/g,"-"))}function xe(e,t,n){let r=S(),i=U(),o=ce();return(o.type&3||n)&&dp(o,i,r,n,r[V],e,t,cp(o,r,t)),xe}function ee(e=1){return jc(e)}function Ea(e,t,n){return Zp(e,t,n),Ea}function Eg(e){let t=S(),n=U(),r=Bs();fi(r+1);let i=Li(n,r);if(e.dirty&&yc(t)===((i.metadata.flags&2)===2)){if(i.matches===null)e.reset([]);else{let o=Wp(t,r);e.reset(o,Nd),e.notifyOnChanges()}return!0}return!1}function Ig(){return qp(S(),Bs())}function jt(e,t){return e<<17|t<<2}function Re(e){return e>>17&32767}function Tg(e){return(e&2)==2}function Og(e,t){return e&131071|t<<17}function Fr(e){return e|2}function et(e){return(e&131068)>>2}function Jn(e,t){return e&-131069|t<<2}function Dg(e){return(e&1)===1}function Hr(e){return e|1}function Mg(e,t,n,r,i,o){let s=o?t.classBindings:t.styleBindings,l=Re(s),a=et(s);e[r]=n;let u=!1,c;if(Array.isArray(n)){let d=n;c=d[1],(c===null||It(d,c)>0)&&(u=!0)}else c=n;if(i)if(a!==0){let d=Re(e[l+1]);e[r+1]=jt(d,l),d!==0&&(e[d+1]=Jn(e[d+1],r)),e[l+1]=Og(e[l+1],r)}else e[r+1]=jt(l,0),l!==0&&(e[l+1]=Jn(e[l+1],r)),l=r;else e[r+1]=jt(a,0),l===0?l=r:e[a+1]=Jn(e[a+1],r),a=r;u&&(e[r+1]=Fr(e[r+1])),Lo(e,c,r,!0),Lo(e,c,r,!1),Pg(t,c,e,r,o),s=jt(l,a),o?t.classBindings=s:t.styleBindings=s}function Pg(e,t,n,r,i){let o=i?e.residualClasses:e.residualStyles;o!=null&&typeof t=="string"&&It(o,t)>=0&&(n[r+1]=Hr(n[r+1]))}function Lo(e,t,n,r){let i=e[n+1],o=t===null,s=r?Re(i):et(i),l=!1;for(;s!==0&&(l===!1||o);){let a=e[s],u=e[s+1];Ng(a,t)&&(l=!0,e[s+1]=r?Hr(u):Fr(u)),s=r?Re(u):et(u)}l&&(e[n+1]=r?Fr(i):Hr(i))}function Ng(e,t){return e===null||t==null||(Array.isArray(e)?e[1]:e)===t?!0:Array.isArray(e)&&typeof t=="string"?It(e,t)>=0:!1}function Ia(e,t){return Ag(e,t,null,!0),Ia}function Ag(e,t,n,r){let i=S(),o=U(),s=Pc(2);if(o.firstUpdatePass&&Rg(o,e,s,r),t!==_e&&Nt(i,s,t)){let l=o.data[Fe()];zg(o,l,i,i[V],e,i[s+1]=Bg(t,n),r,s)}}function Vg(e,t){return t>=e.expandoStartIndex}function Rg(e,t,n,r){let i=e.data;if(i[n+1]===null){let o=i[Fe()],s=Vg(e,n);qg(o,r)&&t===null&&!s&&(t=!1),t=Lg(i,o,t,r),Mg(i,o,t,n,s,r)}}function Lg(e,t,n,r){let i=Rc(e),o=r?t.residualClasses:t.residualStyles;if(i===null)(r?t.classBindings:t.styleBindings)===0&&(n=er(null,e,t,n,r),n=kt(n,t.attrs,r),o=null);else{let s=t.directiveStylingLast;if(s===-1||e[s]!==i)if(n=er(i,e,t,n,r),o===null){let l=jg(e,t,r);l!==void 0&&Array.isArray(l)&&(l=er(null,e,t,l[1],r),l=kt(l,t.attrs,r),Fg(e,t,r,l))}else o=Hg(e,t,r)}return o!==void 0&&(r?t.residualClasses=o:t.residualStyles=o),n}function jg(e,t,n){let r=n?t.classBindings:t.styleBindings;if(et(r)!==0)return e[Re(r)]}function Fg(e,t,n,r){let i=n?t.classBindings:t.styleBindings;e[Re(i)]=r}function Hg(e,t,n){let r,i=t.directiveEnd;for(let o=1+t.directiveStylingLast;o0;){let a=e[i],u=Array.isArray(a),c=u?a[1]:a,d=c===null,h=n[i+1];h===_e&&(h=d?Te:void 0);let f=d?Bn(h,r):c===r?h:void 0;if(u&&!vn(f)&&(f=Bn(a,r)),vn(f)&&(l=f,s))return l;let p=e[i+1];i=s?Re(p):et(p)}if(t!==null){let a=o?t.residualClasses:t.residualStyles;a!=null&&(l=Bn(a,r))}return l}function vn(e){return e!==void 0}function Bg(e,t){return e==null||e===""||(typeof t=="string"?e=e+t:typeof e=="object"&&(e=us(Ce(e)))),e}function qg(e,t){return(e.flags&(t?8:16))!==0}function B(e,t=""){let n=S(),r=U(),i=e+H,o=r.firstCreatePass?Vn(r,i,1,t,null):r.data[i],s=Ug(r,n,o,t);n[i]=s,gi()&&Ii(r,n,s,o),Ot(o,!1)}var Ug=(e,t,n,r)=>(mi(!0),Of(t[V],r));function Zg(e,t,n,r=""){return Nt(e,_n(),n)?t+ps(n)+r:_e}function Le(e){return Ta("",e),Le}function Ta(e,t,n){let r=S(),i=Zg(r,e,t,n);return i!==_e&&$g(r,Fe(),i),Ta}function $g(e,t,n){let r=Ns(t,e);Df(e[V],r,n)}function Fo(e,t,n){let r=U();r.firstCreatePass&&Oa(t,r.data,r.blueprint,rt(e),n)}function Oa(e,t,n,r,i){if(e=F(e),Array.isArray(e))for(let o=0;o>20;if(Ke(e)||!e.multi){let f=new Mt(u,i,Vi,null),p=nr(a,t,i?c:c+h,d);p===-1?(_r(un(l,s),o,a),tr(o,e,t.length),t.push(a),l.directiveStart++,l.directiveEnd++,i&&(l.providerIndexes+=1048576),n.push(f),s.push(f)):(n[p]=f,s[p]=f)}else{let f=nr(a,t,c+h,d),p=nr(a,t,c,c+h),M=f>=0&&n[f],I=p>=0&&n[p];if(i&&!I||!i&&!M){_r(un(l,s),o,a);let J=Gg(i?Wg:Qg,n.length,i,r,u,e);!i&&I&&(n[p].providerFactory=J),tr(o,e,t.length,0),t.push(a),l.directiveStart++,l.directiveEnd++,i&&(l.providerIndexes+=1048576),n.push(J),s.push(J)}else{let J=Da(n[i?p:f],u,!i&&r);tr(o,e,f>-1?f:p,J)}!i&&r&&I&&n[p].componentProviders++}}}function tr(e,t,n,r){let i=Ke(t),o=uc(t);if(i||o){let s=(o?F(t.useClass):t).prototype.ngOnDestroy;if(s){let l=e.destroyHooks||(e.destroyHooks=[]);if(!i&&t.multi){let a=l.indexOf(n);a===-1?l.push(n,[r,s]):l[a+1].push(r,s)}else l.push(n,s)}}}function Da(e,t,n){return n&&e.componentProviders++,e.multi.push(t)-1}function nr(e,t,n,r){for(let i=n;i{n.providersResolver=(r,i)=>Fo(r,i?i(e):e,!1),t&&(n.viewProvidersResolver=(r,i)=>Fo(r,i?i(t):t,!0))}}var Kg=(()=>{class e{applicationErrorHandler=b(Dt);appRef=b(Ar);taskService=b(kn);ngZone=b(He);zonelessEnabled=b(wi);tracing=b(Dn,{optional:!0});zoneIsDefined=typeof Zone<"u"&&!!Zone.root.run;schedulerTickApplyArgs=[{data:{__scheduler_tick__:!0}}];subscriptions=new me;angularZoneId=this.zoneIsDefined?this.ngZone._inner?.get(sn):null;scheduleInRootZone=!this.zonelessEnabled&&this.zoneIsDefined&&(b(od,{optional:!0})??!1);cancelScheduledCallback=null;useMicrotaskScheduler=!1;runningTick=!1;pendingRenderTaskId=null;constructor(){this.subscriptions.add(this.appRef.afterTick.subscribe(()=>{let n=this.taskService.add();if(!this.runningTick&&(this.cleanup(),!this.zonelessEnabled||this.appRef.includeAllTestViews)){this.taskService.remove(n);return}this.switchToMicrotaskScheduler(),this.taskService.remove(n)})),this.subscriptions.add(this.ngZone.onUnstable.subscribe(()=>{this.runningTick||this.cleanup()}))}switchToMicrotaskScheduler(){this.ngZone.runOutsideAngular(()=>{let n=this.taskService.add();this.useMicrotaskScheduler=!0,queueMicrotask(()=>{this.useMicrotaskScheduler=!1,this.taskService.remove(n)})})}notify(n){if(!this.zonelessEnabled&&n===5)return;switch(n){case 0:{this.appRef.dirtyFlags|=2;break}case 3:case 2:case 4:case 5:case 1:{this.appRef.dirtyFlags|=4;break}case 6:{this.appRef.dirtyFlags|=2;break}case 12:{this.appRef.dirtyFlags|=16;break}case 13:{this.appRef.dirtyFlags|=2;break}case 11:break;default:this.appRef.dirtyFlags|=8}if(this.appRef.tracingSnapshot=this.tracing?.snapshot(this.appRef.tracingSnapshot)??null,!this.shouldScheduleTick())return;let r=this.useMicrotaskScheduler?$c:Ks;this.pendingRenderTaskId=this.taskService.add(),this.scheduleInRootZone?this.cancelScheduledCallback=Zone.root.run(()=>r(()=>this.tick())):this.cancelScheduledCallback=this.ngZone.runOutsideAngular(()=>r(()=>this.tick()))}shouldScheduleTick(){return!(this.appRef.destroyed||this.pendingRenderTaskId!==null||this.runningTick||this.appRef._runningTick||!this.zonelessEnabled&&this.zoneIsDefined&&Zone.current.get(sn+this.angularZoneId))}tick(){if(this.runningTick||this.appRef.destroyed)return;if(this.appRef.dirtyFlags===0){this.cleanup();return}!this.zonelessEnabled&&this.appRef.dirtyFlags&7&&(this.appRef.dirtyFlags|=1);let n=this.taskService.add();try{this.ngZone.run(()=>{this.runningTick=!0,this.appRef._tick()},void 0,this.schedulerTickApplyArgs)}catch(r){this.applicationErrorHandler(r)}finally{this.taskService.remove(n),this.cleanup()}}ngOnDestroy(){this.subscriptions.unsubscribe(),this.cleanup()}cleanup(){if(this.runningTick=!1,this.cancelScheduledCallback?.(),this.cancelScheduledCallback=null,this.pendingRenderTaskId!==null){let n=this.pendingRenderTaskId;this.pendingRenderTaskId=null,this.taskService.remove(n)}}static \u0275fac=function(n){return new(n||e)};static \u0275prov=D({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})();function Xg(){return st("NgZoneless"),oi([...Ma(),[]])}function Ma(){return[{provide:bi,useExisting:Kg},{provide:He,useClass:Kc},{provide:wi,useValue:!0}]}function Jg(){return typeof $localize<"u"&&$localize.locale||mn}var Pa=new E("",{factory:()=>b(Pa,{optional:!0,skipSelf:!0})||Jg()});function Be(e,t){return eu(e,t?.equal)}var Br=new E(""),em=new E("");function at(e){return!e.moduleRef}function tm(e){let t=at(e)?e.r3Injector:e.moduleRef.injector,n=t.get(He);return n.run(()=>{at(e)?e.r3Injector.resolveInjectorInitializers():e.moduleRef.resolveInjectorInitializers();let r=t.get(Dt),i;if(n.runOutsideAngular(()=>{i=n.onError.subscribe({next:r})}),at(e)){let o=()=>t.destroy(),s=e.platformInjector.get(Br);s.add(o),t.onDestroy(()=>{i.unsubscribe(),s.delete(o)})}else{let o=()=>e.moduleRef.destroy(),s=e.platformInjector.get(Br);s.add(o),e.moduleRef.onDestroy(()=>{$t(e.allPlatformModules,e.moduleRef),i.unsubscribe(),s.delete(o)})}return rm(r,n,()=>{let o=t.get(kn),s=o.add(),l=t.get(Ca);return l.runInitializers(),l.donePromise.then(()=>{let a=t.get(Pa,mn);if(Sg(a||mn),!t.get(em,!0))return at(e)?t.get(Ar):(e.allPlatformModules.push(e.moduleRef),e.moduleRef);if(at(e)){let u=t.get(Ar);return e.rootComponent!==void 0&&u.bootstrap(e.rootComponent),u}else return nm?.(e.moduleRef,e.allPlatformModules),e.moduleRef}).finally(()=>{o.remove(s)})})})}var nm;function rm(e,t,n){try{let r=n();return _a(r)?r.catch(i=>{throw t.runOutsideAngular(()=>e(i)),i}):r}catch(r){throw t.runOutsideAngular(()=>e(r)),r}}var Qt=null;function im(e=[],t){return Cn.create({name:t,providers:[{provide:li,useValue:"platform"},{provide:Br,useValue:new Set([()=>Qt=null])},...e]})}function om(e=[]){if(Qt)return Qt;let t=im(e);return Qt=t,dg(),sm(t),t}function sm(e){let t=e.get(xl,null);Ts(e,()=>{t?.forEach(n=>n())})}var lm=1e4,Wm=lm-1e3;function am(e){let{rootComponent:t,appProviders:n,platformProviders:r,platformRef:i}=e;x(_.BootstrapApplicationStart);try{let o=i?.injector??om(r),s=[Ma(),ed,...n||[]],l=new wa({providers:s,parent:o,debugName:"",runEnvironmentInitializers:!1});return tm({r3Injector:l.injector,platformInjector:o,rootComponent:t})}catch(o){return Promise.reject(o)}finally{x(_.BootstrapApplicationEnd)}}var Na=null;function Aa(){return Na}function um(e){Na??=e}var cm=class{};function dm(e,t){t=encodeURIComponent(t);for(let n of e.split(";")){let r=n.indexOf("="),[i,o]=r==-1?[n,""]:[n.slice(0,r),n.slice(r+1)];if(i.trim()===t)return decodeURIComponent(o)}return null}var fm=class{},hm="browser",Va=class{_doc;constructor(e){this._doc=e}manager},qr=(()=>{class e extends Va{constructor(n){super(n)}supports(n){return!0}addEventListener(n,r,i,o){return n.addEventListener(r,i,o),()=>this.removeEventListener(n,r,i,o)}removeEventListener(n,r,i,o){return n.removeEventListener(r,i,o)}static \u0275fac=function(n){return new(n||e)(C(we))};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})(),Ur=new E(""),Ra=(()=>{class e{_zone;_plugins;_eventNameToPlugin=new Map;constructor(n,r){this._zone=r,n.forEach(s=>{s.manager=this});let i=n.filter(s=>!(s instanceof qr));this._plugins=i.slice().reverse();let o=n.find(s=>s instanceof qr);o&&this._plugins.push(o)}addEventListener(n,r,i,o){return this._findPluginFor(r).addEventListener(n,r,i,o)}getZone(){return this._zone}_findPluginFor(n){let r=this._eventNameToPlugin.get(n);if(r)return r;if(r=this._plugins.find(i=>i.supports(n)),!r)throw new w(5101,!1);return this._eventNameToPlugin.set(n,r),r}static \u0275fac=function(n){return new(n||e)(C(Ur),C(He))};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})(),rr="ng-app-id";function Ho(e){for(let t of e)t.remove()}function zo(e,t){let n=t.createElement("style");return n.textContent=e,n}function pm(e,t,n,r){let i=e.head?.querySelectorAll(`style[${rr}="${t}"],link[${rr}="${t}"]`);if(i)for(let o of i)o.removeAttribute(rr),o instanceof HTMLLinkElement?r.set(o.href.slice(o.href.lastIndexOf("/")+1),{usage:0,elements:[o]}):o.textContent&&n.set(o.textContent,{usage:0,elements:[o]})}function Zr(e,t){let n=t.createElement("link");return n.setAttribute("rel","stylesheet"),n.setAttribute("href",e),n}var La=(()=>{class e{doc;appId;nonce;inline=new Map;external=new Map;hosts=new Set;constructor(n,r,i,o={}){this.doc=n,this.appId=r,this.nonce=i,pm(n,r,this.inline,this.external),this.hosts.add(n.head)}addStyles(n,r){for(let i of n)this.addUsage(i,this.inline,zo);r?.forEach(i=>this.addUsage(i,this.external,Zr))}removeStyles(n,r){for(let i of n)this.removeUsage(i,this.inline);r?.forEach(i=>this.removeUsage(i,this.external))}addUsage(n,r,i){let o=r.get(n);o?o.usage++:r.set(n,{usage:1,elements:[...this.hosts].map(s=>this.addElement(s,i(n,this.doc)))})}removeUsage(n,r){let i=r.get(n);i&&(i.usage--,i.usage<=0&&(Ho(i.elements),r.delete(n)))}ngOnDestroy(){for(let[,{elements:n}]of[...this.inline,...this.external])Ho(n);this.hosts.clear()}addHost(n){this.hosts.add(n);for(let[r,{elements:i}]of this.inline)i.push(this.addElement(n,zo(r,this.doc)));for(let[r,{elements:i}]of this.external)i.push(this.addElement(n,Zr(r,this.doc)))}removeHost(n){this.hosts.delete(n)}addElement(n,r){return this.nonce&&r.setAttribute("nonce",this.nonce),n.appendChild(r)}static \u0275fac=function(n){return new(n||e)(C(we),C(Cl),C(Sl,8),C(kl))};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})(),ir={svg:"http://www.w3.org/2000/svg",xhtml:"http://www.w3.org/1999/xhtml",xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/",math:"http://www.w3.org/1998/Math/MathML"},ji=/%COMP%/g,ja="%COMP%",gm=`_nghost-${ja}`,mm=`_ngcontent-${ja}`,vm=!0,ym=new E("",{factory:()=>vm});function bm(e){return mm.replace(ji,e)}function wm(e){return gm.replace(ji,e)}function Fa(e,t){return t.map(n=>n.replace(ji,e))}var Bo=(()=>{class e{eventManager;sharedStylesHost;appId;removeStylesOnCompDestroy;doc;ngZone;nonce;tracingService;rendererByCompId=new Map;defaultRenderer;constructor(n,r,i,o,s,l,a=null,u=null){this.eventManager=n,this.sharedStylesHost=r,this.appId=i,this.removeStylesOnCompDestroy=o,this.doc=s,this.ngZone=l,this.nonce=a,this.tracingService=u,this.defaultRenderer=new Fi(n,s,l,this.tracingService)}createRenderer(n,r){if(!n||!r)return this.defaultRenderer;let i=this.getOrCreateRenderer(n,r);return i instanceof Zo?i.applyToHost(n):i instanceof $r&&i.applyStyles(),i}getOrCreateRenderer(n,r){let i=this.rendererByCompId,o=i.get(r.id);if(!o){let s=this.doc,l=this.ngZone,a=this.eventManager,u=this.sharedStylesHost,c=this.removeStylesOnCompDestroy,d=this.tracingService;switch(r.encapsulation){case re.Emulated:o=new Zo(a,u,r,this.appId,c,s,l,d);break;case re.ShadowDom:return new Uo(a,n,r,s,l,this.nonce,d,u);case re.ExperimentalIsolatedShadowDom:return new Uo(a,n,r,s,l,this.nonce,d);default:o=new $r(a,u,r,c,s,l,d);break}i.set(r.id,o)}return o}ngOnDestroy(){this.rendererByCompId.clear()}componentReplaced(n){this.rendererByCompId.delete(n)}static \u0275fac=function(n){return new(n||e)(C(Ra),C(La),C(Cl),C(ym),C(we),C(He),C(Sl),C(Dn,8))};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})(),Fi=class{eventManager;doc;ngZone;tracingService;data=Object.create(null);throwOnSyntheticProps=!0;constructor(e,t,n,r){this.eventManager=e,this.doc=t,this.ngZone=n,this.tracingService=r}destroy(){}destroyNode=null;createElement(e,t){return t?this.doc.createElementNS(ir[t]||t,e):this.doc.createElement(e)}createComment(e){return this.doc.createComment(e)}createText(e){return this.doc.createTextNode(e)}appendChild(e,t){(qo(e)?e.content:e).appendChild(t)}insertBefore(e,t,n){e&&(qo(e)?e.content:e).insertBefore(t,n)}removeChild(e,t){t.remove()}selectRootElement(e,t){let n=typeof e=="string"?this.doc.querySelector(e):e;if(!n)throw new w(-5104,!1);return t||(n.textContent=""),n}parentNode(e){return e.parentNode}nextSibling(e){return e.nextSibling}setAttribute(e,t,n,r){if(r){t=r+":"+t;let i=ir[r];i?e.setAttributeNS(i,t,n):e.setAttribute(t,n)}else e.setAttribute(t,n)}removeAttribute(e,t,n){if(n){let r=ir[n];r?e.removeAttributeNS(r,t):e.removeAttribute(`${n}:${t}`)}else e.removeAttribute(t)}addClass(e,t){e.classList.add(t)}removeClass(e,t){e.classList.remove(t)}setStyle(e,t,n,r){r&(Se.DashCase|Se.Important)?e.style.setProperty(t,n,r&Se.Important?"important":""):e.style[t]=n}removeStyle(e,t,n){n&Se.DashCase?e.style.removeProperty(t):e.style[t]=""}setProperty(e,t,n){e!=null&&(e[t]=n)}setValue(e,t){e.nodeValue=t}listen(e,t,n,r){if(typeof e=="string"&&(e=Aa().getGlobalEventTarget(this.doc,e),!e))throw new w(5102,!1);let i=this.decoratePreventDefault(n);return this.tracingService?.wrapEventListener&&(i=this.tracingService.wrapEventListener(e,t,i)),this.eventManager.addEventListener(e,t,i,r)}decoratePreventDefault(e){return t=>{if(t==="__ngUnwrap__")return e;e(t)===!1&&t.preventDefault()}}};function qo(e){return e.tagName==="TEMPLATE"&&e.content!==void 0}var Uo=class extends Fi{hostEl;sharedStylesHost;shadowRoot;constructor(e,t,n,r,i,o,s,l){super(e,r,i,s),this.hostEl=t,this.sharedStylesHost=l,this.shadowRoot=t.attachShadow({mode:"open"}),this.sharedStylesHost&&this.sharedStylesHost.addHost(this.shadowRoot);let a=n.styles;a=Fa(n.id,a);for(let c of a){let d=document.createElement("style");o&&d.setAttribute("nonce",o),d.textContent=c,this.shadowRoot.appendChild(d)}let u=n.getExternalStyles?.();if(u)for(let c of u){let d=Zr(c,r);o&&d.setAttribute("nonce",o),this.shadowRoot.appendChild(d)}}nodeOrShadowRoot(e){return e===this.hostEl?this.shadowRoot:e}appendChild(e,t){return super.appendChild(this.nodeOrShadowRoot(e),t)}insertBefore(e,t,n){return super.insertBefore(this.nodeOrShadowRoot(e),t,n)}removeChild(e,t){return super.removeChild(null,t)}parentNode(e){return this.nodeOrShadowRoot(super.parentNode(this.nodeOrShadowRoot(e)))}destroy(){this.sharedStylesHost&&this.sharedStylesHost.removeHost(this.shadowRoot)}},$r=class extends Fi{sharedStylesHost;removeStylesOnCompDestroy;styles;styleUrls;constructor(e,t,n,r,i,o,s,l){super(e,i,o,s),this.sharedStylesHost=t,this.removeStylesOnCompDestroy=r;let a=n.styles;this.styles=l?Fa(l,a):a,this.styleUrls=n.getExternalStyles?.(l)}applyStyles(){this.sharedStylesHost.addStyles(this.styles,this.styleUrls)}destroy(){this.removeStylesOnCompDestroy&&_t.size===0&&this.sharedStylesHost.removeStyles(this.styles,this.styleUrls)}},Zo=class extends $r{contentAttr;hostAttr;constructor(e,t,n,r,i,o,s,l){let a=r+"-"+n.id;super(e,t,n,i,o,s,l,a),this.contentAttr=bm(a),this.hostAttr=wm(a)}applyToHost(e){this.applyStyles(),this.setAttribute(e,this.hostAttr,"")}createElement(e,t){let n=super.createElement(e,t);return super.setAttribute(n,this.contentAttr,""),n}},_m=class Ha extends cm{supportsDOMEvents=!0;static makeCurrent(){um(new Ha)}onAndCancel(t,n,r,i){return t.addEventListener(n,r,i),()=>{t.removeEventListener(n,r,i)}}dispatchEvent(t,n){t.dispatchEvent(n)}remove(t){t.remove()}createElement(t,n){return n=n||this.getDefaultDocument(),n.createElement(t)}createHtmlDocument(){return document.implementation.createHTMLDocument("fakeTitle")}getDefaultDocument(){return document}isElementNode(t){return t.nodeType===Node.ELEMENT_NODE}isShadowRoot(t){return t instanceof DocumentFragment}getGlobalEventTarget(t,n){return n==="window"?window:n==="document"?t:n==="body"?t.body:null}getBaseHref(t){let n=Cm();return n==null?null:xm(n)}resetBaseElement(){ft=null}getUserAgent(){return window.navigator.userAgent}getCookie(t){return dm(document.cookie,t)}},ft=null;function Cm(){return ft=ft||document.head.querySelector("base"),ft?ft.getAttribute("href"):null}function xm(e){return new URL(e,document.baseURI).pathname}var km=(()=>{class e{build(){return new XMLHttpRequest}static \u0275fac=function(n){return new(n||e)};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})(),$o=["alt","control","meta","shift"],Sm={"\b":"Backspace"," ":"Tab","\x7F":"Delete","\x1B":"Escape",Del:"Delete",Esc:"Escape",Left:"ArrowLeft",Right:"ArrowRight",Up:"ArrowUp",Down:"ArrowDown",Menu:"ContextMenu",Scroll:"ScrollLock",Win:"OS"},Em={alt:e=>e.altKey,control:e=>e.ctrlKey,meta:e=>e.metaKey,shift:e=>e.shiftKey},Im=(()=>{class e extends Va{constructor(n){super(n)}supports(n){return e.parseEventName(n)!=null}addEventListener(n,r,i,o){let s=e.parseEventName(r),l=e.eventCallback(s.fullKey,i,this.manager.getZone());return this.manager.getZone().runOutsideAngular(()=>Aa().onAndCancel(n,s.domEventName,l,o))}static parseEventName(n){let r=n.toLowerCase().split("."),i=r.shift();if(r.length===0||!(i==="keydown"||i==="keyup"))return null;let o=e._normalizeKey(r.pop()),s="",l=r.indexOf("code");if(l>-1&&(r.splice(l,1),s="code."),$o.forEach(u=>{let c=r.indexOf(u);c>-1&&(r.splice(c,1),s+=u+".")}),s+=o,r.length!=0||o.length===0)return null;let a={};return a.domEventName=i,a.fullKey=s,a}static matchEventFullKeyCode(n,r){let i=Sm[n.key]||n.key,o="";return r.indexOf("code.")>-1&&(i=n.code,o="code."),i==null||!i?!1:(i=i.toLowerCase(),i===" "?i="space":i==="."&&(i="dot"),$o.forEach(s=>{if(s!==i){let l=Em[s];l(n)&&(o+=s+".")}}),o+=i,o===r)}static eventCallback(n,r,i){return o=>{e.matchEventFullKeyCode(o,n)&&i.runGuarded(()=>r(o))}}static _normalizeKey(n){return n==="esc"?"escape":n}static \u0275fac=function(n){return new(n||e)(C(we))};static \u0275prov=D({token:e,factory:e.\u0275fac})}return e})();async function Tm(e,t){return am(Om(e,t))}function Om(e,t){return{platformRef:t?.platformRef,appProviders:[...Am,...e?.providers??[]],platformProviders:Nm}}function Dm(){_m.makeCurrent()}function Mm(){return new Sn}function Pm(){return Fd(document),document}var Nm=[{provide:kl,useValue:hm},{provide:xl,useValue:Dm,multi:!0},{provide:we,useFactory:Pm}],Am=[{provide:li,useValue:"root"},{provide:Sn,useFactory:Mm},{provide:Ur,useClass:qr,multi:!0},{provide:Ur,useClass:Im,multi:!0},Bo,La,Ra,{provide:Ai,useExisting:Bo},{provide:fm,useClass:km},[]],za=(()=>{class e{static \u0275fac=function(n){return new(n||e)};static \u0275prov=D({token:e,factory:function(n){let r=null;return n?r=new(n||e):r=C(Vm),r},providedIn:"root"})}return e})(),Vm=(()=>{class e extends za{_doc;constructor(n){super(),this._doc=n}sanitize(n,r){if(r==null)return null;switch(n){case he.NONE:return r;case he.HTML:return Ze(r,"HTML")?Ce(r):Rl(this._doc,String(r)).toString();case he.STYLE:return Ze(r,"Style")?Ce(r):r;case he.SCRIPT:if(Ze(r,"Script"))return Ce(r);throw new w(5200,!1);case he.URL:return Ze(r,"URL")?Ce(r):Dl(String(r));case he.RESOURCE_URL:if(Ze(r,"ResourceURL"))return Ce(r);throw new w(5201,!1);default:throw new w(5202,!1)}}bypassSecurityTrustHtml(n){return of(n)}bypassSecurityTrustStyle(n){return sf(n)}bypassSecurityTrustScript(n){return lf(n)}bypassSecurityTrustUrl(n){return af(n)}bypassSecurityTrustResourceUrl(n){return uf(n)}static \u0275fac=function(n){return new(n||e)(C(we))};static \u0275prov=D({token:e,factory:e.\u0275fac,providedIn:"root"})}return e})(),Qo=class Wt{constructor(t){if(this.model=t,t){this.page.set(t.get("page")??0),this.pageSize.set(t.get("page_size")??10),this.maxColumns.set(t.get("max_columns")??0),this.rowCount.set(t.get("row_count")??null),this.tableHtml.set(t.get("table_html")??""),this.sortContext.set(t.get("sort_context")??[]),this.orderableColumns.set(t.get("orderable_columns")??[]);let n=t.get("error_message")??t.get("_error_message")??null;this.errorMessage.set(n),this.startExecution.set(t.get("start_execution")??!1),this.isDeferredMode.set(t.get("is_deferred_mode")??!1),this.dryRunInfo.set(t.get("dry_run_info")??""),this.ping.set(t.get("ping")??0),t.on("change:page",()=>{this.page.set(t.get("page"))}),t.on("change:page_size",()=>{this.pageSize.set(t.get("page_size"))}),t.on("change:max_columns",()=>{this.maxColumns.set(t.get("max_columns"))}),t.on("change:row_count",()=>{this.rowCount.set(t.get("row_count"))}),t.on("change:table_html",()=>{this.tableHtml.set(t.get("table_html"))}),t.on("change:sort_context",()=>{this.sortContext.set(t.get("sort_context"))}),t.on("change:orderable_columns",()=>{this.orderableColumns.set(t.get("orderable_columns"))}),t.on("change:start_execution",()=>{this.startExecution.set(t.get("start_execution")??!1)}),t.on("change:is_deferred_mode",()=>{this.isDeferredMode.set(t.get("is_deferred_mode")??!1)}),t.on("change:dry_run_info",()=>{this.dryRunInfo.set(t.get("dry_run_info")??"")}),t.on("change:ping",()=>{this.ping.set(t.get("ping")??0)});let r=()=>{let i=t.get("error_message")??t.get("_error_message")??null;this.errorMessage.set(i)};t.on("change:error_message",r),t.on("change:_error_message",r)}}page=j(0);pageSize=j(10);maxColumns=j(0);rowCount=j(null);tableHtml=j("");sortContext=j([]);orderableColumns=j([]);errorMessage=j(null);startExecution=j(!1);isDeferredMode=j(!1);dryRunInfo=j("");ping=j(0);setPage(t){this.page.set(t),this.model&&(this.model.set("page",t),this.model.save_changes())}setPageSize(t){this.pageSize.set(t),this.page.set(0),this.model&&(this.model.set("page_size",t),this.model.set("page",0),this.model.save_changes())}setMaxColumns(t){this.maxColumns.set(t),this.model&&(this.model.set("max_columns",t),this.model.save_changes())}setSortContext(t){this.sortContext.set(t),this.model&&(this.model.set("sort_context",t),this.model.save_changes())}setStartExecution(t){this.startExecution.set(t),this.model&&(this.model.set("start_execution",t),this.model.save_changes())}setPing(t){this.ping.set(t),this.model&&(this.model.set("ping",t),this.model.save_changes())}static \u0275fac=function(t){return new(t||Wt)(C("ANYWIDGET_MODEL"))};static \u0275prov=D({token:Wt,factory:Wt.\u0275fac})},Rm=["tableContainer"],Lm=["app-root",""];function jm(e,t){if(e&1&&(P(0,"div",2),B(1),N()),e&2){let n=ee();L(),Le(n.errorMessage())}}function Fm(e,t){e&1&&(ka(0,"span",7),B(1," Run Query "))}function Hm(e,t){e&1&&B(0," Run Query ")}function zm(e,t){if(e&1){let n=Sa();P(0,"div",3)(1,"div",4)(2,"p",5),B(3),N(),P(4,"button",6),xe("click",function(){qe(n);let r=ee();return Ue(r.handleRunQuery())}),Rr(5,Fm,2,0)(6,Hm,1,0),N()()()}if(e&2){let n=ee();L(3),Le(n.dryRunInfo()),L(),te("disabled",n.isLoading()),L(),Lr(n.isLoading()?5:6)}}function Bm(e,t){if(e&1&&(P(0,"option",18),B(1),N()),e&2){let n=t.$implicit;te("value",n),L(),Le(n===0?"All":n)}}function qm(e,t){if(e&1&&(P(0,"option",18),B(1),N()),e&2){let n=t.$implicit;te("value",n),L(),Le(n)}}function Um(e,t){if(e&1){let n=Sa();P(0,"div",8,0),xe("click",function(r){qe(n);let i=ee();return Ue(i.handleTableClick(r))}),N(),P(2,"footer",9)(3,"span",10),B(4),N(),P(5,"div",11)(6,"button",12),xe("click",function(){qe(n);let r=ee();return Ue(r.handlePageChange(-1))}),B(7,"<"),N(),P(8,"span",13),B(9),N(),P(10,"button",12),xe("click",function(){qe(n);let r=ee();return Ue(r.handlePageChange(1))}),B(11,">"),N()(),P(12,"div",14)(13,"div",15)(14,"label",16),B(15,"Max columns:"),N(),P(16,"select",17),xe("change",function(r){qe(n);let i=ee();return Ue(i.handleMaxColumnsChange(r))}),Vo(17,Bm,2,2,"option",18,Ao),N()(),P(19,"div",19)(20,"label",20),B(21,"Page size:"),N(),P(22,"select",21),xe("change",function(r){qe(n);let i=ee();return Ue(i.handlePageSizeChange(r))}),Vo(23,qm,2,2,"option",18,Ao),N()()()()}if(e&2){let n=ee();te("innerHTML",n.sanitizedHtml(),Nf),L(4),Le(n.rowCountText()),L(2),te("disabled",n.prevPageDisabled()),L(3),Le(n.pageIndicatorText()),L(),te("disabled",n.nextPageDisabled()),L(6),te("value",n.maxColumns()),L(),Ro(n.maxColumnOptions),L(5),te("value",n.pageSize()),L(),Ro(n.pageSizeOptions)}}var Zm=class Qr{state=b(Qo);sanitizer=b(za);maxColumnOptions=[5,10,15,20,0];pageSizeOptions=[10,25,50,100];errorMessage=this.state.errorMessage;maxColumns=this.state.maxColumns;pageSize=this.state.pageSize;page=this.state.page;rowCount=this.state.rowCount;isDeferredMode=this.state.isDeferredMode;dryRunInfo=this.state.dryRunInfo;isLoading=j(!1);sanitizedHtml=Be(()=>this.sanitizer.bypassSecurityTrustHtml(this.state.tableHtml()));totalPages=Be(()=>{let t=this.rowCount(),n=this.pageSize();return t!==null&&n>0?Math.ceil(t/n):null});pageIndicatorText=Be(()=>{let t=this.page(),n=this.rowCount(),r=this.totalPages(),i=(t+1).toLocaleString(),o=(r??1).toLocaleString();return`Page ${i} of ${o}`});rowCountText=Be(()=>{let t=this.rowCount();return t===null?"Total rows unknown":t===0?"0 total rows":`${t.toLocaleString()} total rows`});prevPageDisabled=Be(()=>this.page()===0);nextPageDisabled=Be(()=>{let t=this.page(),n=this.rowCount(),r=this.totalPages();return n===null?!1:n===0?!0:r!==null&&t>=r-1});isDarkMode=j(!1);themeObserver=null;tableContainerRef;isHeightInitialized=!1;constructor(){$n(()=>{let t=this.state.tableHtml(),n=this.state.sortContext(),r=this.state.orderableColumns();this.isDeferredMode()&&(this.isHeightInitialized=!1),setTimeout(()=>{this.applySortIndicators(),this.lockInitialHeight()},0)}),$n(()=>{this.state.startExecution()||this.isLoading.set(!1)}),$n(t=>{if(this.state.startExecution()){let n=setInterval(()=>{if(this.state.startExecution()){let r=this.state.ping();this.state.setPing(r+1)}else clearInterval(n)},500);t(()=>{clearInterval(n)})}})}ngOnInit(){this.initThemeDetection()}ngOnDestroy(){this.themeObserver?.disconnect()}handleRunQuery(){this.isLoading.set(!0),this.state.setStartExecution(!0)}handlePageChange(t){let n=this.page()+t;this.state.setPage(n)}handlePageSizeChange(t){let n=t.target,r=Number(n.value);r&&this.state.setPageSize(r)}handleMaxColumnsChange(t){let n=t.target,r=Number(n.value);this.state.setMaxColumns(r)}handleTableClick(t){let n=t.target.closest("th");if(!n)return;let r=n.querySelector("div.bf-header-content");if(!r)return;let i=this.getColumnName(r),o=this.state.orderableColumns();if(!i||!o.includes(i))return;let s=[...this.state.sortContext()],l=s.findIndex(u=>u.column===i),a=[...s];t.shiftKey?l!==-1?a[l].ascending?a[l]=Q($({},a[l]),{ascending:!1}):a.splice(l,1):a.push({column:i,ascending:!0}):l!==-1&&a.length===1?a[l].ascending?a[l]=Q($({},a[l]),{ascending:!1}):a=[]:a=[{column:i,ascending:!0}],this.state.setSortContext(a)}getColumnName(t){let n=t.cloneNode(!0);return n.querySelector(".sort-indicator")?.remove(),n.textContent?.trim()||""}applySortIndicators(){let t=this.tableContainerRef?.nativeElement;if(!t)return;let n=this.state.orderableColumns(),r=this.state.sortContext()||[],i=o=>r.findIndex(s=>s.column===o);t.querySelectorAll("th").forEach(o=>{let s=o.querySelector("div.bf-header-content");if(!s)return;let l=this.getColumnName(s);if(l&&n.includes(l)){let a=s.querySelector(".sort-indicator");a||(a=document.createElement("span"),a.classList.add("sort-indicator"),a.style.paddingLeft="5px",s.appendChild(a));let u=i(l);if(u!==-1){let c=r[u].ascending;a.textContent=c?"\u25B2":"\u25BC",a.style.visibility="visible"}else a.textContent="\u25CF",a.style.visibility="hidden"}})}lockInitialHeight(){if(this.isHeightInitialized)return;let t=this.tableContainerRef?.nativeElement;if(!t)return;let n=t.querySelector("table");if(n&&n.offsetHeight>0){let r=t.offsetHeight;r>0&&(t.style.height=`${r}px`,this.isHeightInitialized=!0)}}initThemeDetection(){this.updateTheme();let t=new MutationObserver(()=>this.updateTheme());t.observe(document.body,{attributes:!0,attributeFilter:["class","data-theme","data-vscode-theme-kind"]}),this.themeObserver=t}updateTheme(){let t=document.body,n=t.classList.contains("vscode-dark")||t.classList.contains("theme-dark")||t.dataset.theme==="dark"||t.getAttribute("data-vscode-theme-kind")==="vscode-dark";this.isDarkMode.set(n)}static \u0275fac=function(t){return new(t||Qr)};static \u0275cmp=Kp({type:Qr,selectors:[["","app-root",""]],viewQuery:function(t,n){if(t&1&&Ea(Rm,5),t&2){let r;Eg(r=Ig())&&(n.tableContainerRef=r.first)}},features:[Yg([Qo])],attrs:Lm,decls:4,vars:4,consts:[["tableContainer",""],[1,"bigframes-widget"],[1,"bigframes-error-message"],[1,"deferred-container"],[1,"deferred-card"],[1,"deferred-estimate"],[1,"run-query-button",3,"click","disabled"],[1,"spinner"],[1,"table-container",3,"click","innerHTML"],[1,"footer"],[1,"row-count"],[1,"pagination"],[3,"click","disabled"],[1,"page-indicator"],[1,"settings"],[1,"max-columns"],["for","max-cols-select"],["id","max-cols-select",3,"change","value"],[3,"value"],[1,"page-size"],["for","page-size-select"],["id","page-size-select",3,"change","value"]],template:function(t,n){t&1&&(P(0,"div",1),Rr(1,jm,2,1,"div",2),Rr(2,zm,7,3,"div",3)(3,Um,25,7),N()),t&2&&(Ia("bigframes-dark-mode",n.isDarkMode()),L(),Lr(n.errorMessage()?1:-1),L(),Lr(n.isDeferredMode()?2:3))},styles:[".bigframes-widget.bigframes-widget[_ngcontent-%COMP%]{--bf-bg: white;--bf-border-color: #ccc;--bf-error-bg: #fbe;--bf-error-border: red;--bf-error-fg: black;--bf-fg: black;--bf-header-bg: #f5f5f5;--bf-null-fg: gray;--bf-row-even-bg: #f5f5f5;--bf-row-odd-bg: white;background-color:var(--bf-bg);box-sizing:border-box;color:var(--bf-fg);display:flex;flex-direction:column;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;margin:0;padding:0;width:100%}.bigframes-widget[_ngcontent-%COMP%] *[_ngcontent-%COMP%]{box-sizing:border-box}@media(prefers-color-scheme:dark){.bigframes-widget.bigframes-widget[_ngcontent-%COMP%]{--bf-bg: var(--vscode-editor-background, #202124);--bf-border-color: #444;--bf-error-bg: #511;--bf-error-border: #f88;--bf-error-fg: #fcc;--bf-fg: white;--bf-header-bg: var(--vscode-editor-background, black);--bf-null-fg: #aaa;--bf-row-even-bg: #202124;--bf-row-odd-bg: #383838}}.bigframes-widget.bigframes-dark-mode.bigframes-dark-mode[_ngcontent-%COMP%]{--bf-bg: var(--vscode-editor-background, #202124);--bf-border-color: #444;--bf-error-bg: #511;--bf-error-border: #f88;--bf-error-fg: #fcc;--bf-fg: white;--bf-header-bg: var(--vscode-editor-background, black);--bf-null-fg: #aaa;--bf-row-even-bg: #202124;--bf-row-odd-bg: #383838}.bigframes-widget[_ngcontent-%COMP%] .table-container[_ngcontent-%COMP%]{background-color:var(--bf-bg);margin:0;overflow:auto;padding:0}.bigframes-widget[_ngcontent-%COMP%] .footer[_ngcontent-%COMP%]{align-items:center;background-color:var(--bf-bg);color:var(--bf-fg);display:flex;font-size:.8rem;justify-content:space-between;padding:8px}.bigframes-widget[_ngcontent-%COMP%] .footer[_ngcontent-%COMP%] > *[_ngcontent-%COMP%]{flex:1}.bigframes-widget[_ngcontent-%COMP%] .pagination[_ngcontent-%COMP%]{align-items:center;display:flex;flex-direction:row;gap:4px;justify-content:center;padding:4px}.bigframes-widget[_ngcontent-%COMP%] .page-indicator[_ngcontent-%COMP%], .bigframes-widget[_ngcontent-%COMP%] .row-count[_ngcontent-%COMP%]{margin:0 8px}.bigframes-widget[_ngcontent-%COMP%] .settings[_ngcontent-%COMP%]{align-items:center;display:flex;flex-direction:row;gap:16px;justify-content:end}.bigframes-widget[_ngcontent-%COMP%] .page-size[_ngcontent-%COMP%], .bigframes-widget[_ngcontent-%COMP%] .max-columns[_ngcontent-%COMP%]{align-items:center;display:flex;flex-direction:row;gap:4px}.bigframes-widget[_ngcontent-%COMP%] .page-size[_ngcontent-%COMP%] label[_ngcontent-%COMP%], .bigframes-widget[_ngcontent-%COMP%] .max-columns[_ngcontent-%COMP%] label[_ngcontent-%COMP%]{margin-right:8px}.bigframes-widget[_ngcontent-%COMP%] table.bigframes-widget-table, .bigframes-widget[_ngcontent-%COMP%] table.dataframe{background-color:var(--bf-bg);border:1px solid var(--bf-border-color);border-collapse:collapse;border-spacing:0;box-shadow:none;color:var(--bf-fg);margin:0;outline:none;text-align:left;width:auto}.bigframes-widget[_ngcontent-%COMP%] tr{border:none}.bigframes-widget[_ngcontent-%COMP%] th{background-color:var(--bf-header-bg);border:1px solid var(--bf-border-color);color:var(--bf-fg);padding:0;position:sticky;text-align:left;top:0;z-index:1}.bigframes-widget[_ngcontent-%COMP%] td{border:1px solid var(--bf-border-color);color:var(--bf-fg);padding:.5em}.bigframes-widget[_ngcontent-%COMP%] table tbody tr:nth-child(odd), .bigframes-widget[_ngcontent-%COMP%] table tbody tr:nth-child(odd) td{background-color:var(--bf-row-odd-bg)}.bigframes-widget[_ngcontent-%COMP%] table tbody tr:nth-child(2n), .bigframes-widget[_ngcontent-%COMP%] table tbody tr:nth-child(2n) td{background-color:var(--bf-row-even-bg)}.bigframes-widget[_ngcontent-%COMP%] .bf-header-content{box-sizing:border-box;height:100%;overflow:auto;padding:.5em;resize:horizontal;width:100%}.bigframes-widget[_ngcontent-%COMP%] th .sort-indicator{padding-left:4px;visibility:hidden}.bigframes-widget[_ngcontent-%COMP%] th:hover .sort-indicator{visibility:visible}.bigframes-widget[_ngcontent-%COMP%] button[_ngcontent-%COMP%]{background-color:transparent;border:1px solid currentColor;border-radius:4px;color:inherit;cursor:pointer;display:inline-block;padding:2px 8px;text-align:center;text-decoration:none;-webkit-user-select:none;user-select:none;vertical-align:middle}.bigframes-widget[_ngcontent-%COMP%] button[_ngcontent-%COMP%]:disabled{opacity:.65;pointer-events:none}.bigframes-widget[_ngcontent-%COMP%] .bigframes-error-message[_ngcontent-%COMP%]{background-color:var(--bf-error-bg);border:1px solid var(--bf-error-border);border-radius:4px;color:var(--bf-error-fg);font-size:14px;margin-bottom:8px;padding:8px}.bigframes-widget[_ngcontent-%COMP%] .cell-align-right{text-align:right}.bigframes-widget[_ngcontent-%COMP%] .cell-align-left{text-align:left}.bigframes-widget[_ngcontent-%COMP%] .null-value{color:var(--bf-null-fg)}.bigframes-widget[_ngcontent-%COMP%] .debug-info{border-top:1px solid var(--bf-border-color)}.bigframes-widget[_ngcontent-%COMP%] .deferred-container[_ngcontent-%COMP%]{align-items:center;display:flex;justify-content:center;min-height:220px;padding:24px;width:100%}.bigframes-widget[_ngcontent-%COMP%] .deferred-card[_ngcontent-%COMP%]{background:linear-gradient(135deg,#fff9,#ffffff4d);border:1px solid rgba(255,255,255,.4);border-radius:16px;box-shadow:0 8px 32px #1f268712;display:flex;flex-direction:column;gap:16px;max-width:500px;padding:32px;text-align:center;transition:all .3s ease-in-out}.bigframes-widget.bigframes-dark-mode[_ngcontent-%COMP%] .deferred-card[_ngcontent-%COMP%]{background:linear-gradient(135deg,#20212499,#2021244d);border:1px solid rgba(255,255,255,.1);box-shadow:0 8px 32px #0000004d}@media(prefers-color-scheme:dark){.bigframes-widget[_ngcontent-%COMP%] .deferred-card[_ngcontent-%COMP%]{background:linear-gradient(135deg,#20212499,#2021244d);border:1px solid rgba(255,255,255,.1);box-shadow:0 8px 32px #0000004d}}.bigframes-widget[_ngcontent-%COMP%] .deferred-title[_ngcontent-%COMP%]{font-size:1.1rem;font-weight:600;margin:0}.bigframes-widget[_ngcontent-%COMP%] .deferred-estimate[_ngcontent-%COMP%]{color:var(--bf-null-fg);font-size:.9rem;margin:0}.bigframes-widget[_ngcontent-%COMP%] .run-query-button[_ngcontent-%COMP%]{align-items:center;background-color:var(--bf-fg);border:1px solid var(--bf-fg);border-radius:8px;color:var(--bf-bg);cursor:pointer;display:inline-flex;font-size:14px;font-weight:600;gap:8px;justify-content:center;padding:10px 20px;transition:transform .2s ease,opacity .2s ease}.bigframes-widget[_ngcontent-%COMP%] .run-query-button[_ngcontent-%COMP%]:hover{opacity:.9;transform:translateY(-1px)}.bigframes-widget[_ngcontent-%COMP%] .run-query-button[_ngcontent-%COMP%]:active{transform:translateY(0)}.bigframes-widget[_ngcontent-%COMP%] .run-query-button[_ngcontent-%COMP%]:disabled{cursor:not-allowed;opacity:.6}.bigframes-widget[_ngcontent-%COMP%] .spinner[_ngcontent-%COMP%]{animation:_ngcontent-%COMP%_spin 1s linear infinite;border:2px solid currentColor;border-radius:50%;border-top-color:transparent;display:inline-block;height:12px;width:12px}@keyframes _ngcontent-%COMP%_spin{to{transform:rotate(360deg)}}"]})};function $m({model:e,el:t}){let n=document.createElement("div");n.setAttribute("app-root",""),t.appendChild(n);let r={providers:[nd(),Xg(),{provide:"ANYWIDGET_MODEL",useValue:e}]};Tm(r).then(i=>{i.bootstrap(Zm,n),n.removeAttribute("app-root")}).catch(i=>console.error(i))}var Gm={render:$m};export{Gm as default}; diff --git a/bigframes/display/table_widget_angular/.editorconfig b/bigframes/display/table_widget_angular/.editorconfig deleted file mode 100644 index f166060da1c..00000000000 --- a/bigframes/display/table_widget_angular/.editorconfig +++ /dev/null @@ -1,17 +0,0 @@ -# Editor configuration, see https://editorconfig.org -root = true - -[*] -charset = utf-8 -indent_style = space -indent_size = 2 -insert_final_newline = true -trim_trailing_whitespace = true - -[*.ts] -quote_type = single -ij_typescript_use_double_quotes = false - -[*.md] -max_line_length = off -trim_trailing_whitespace = false diff --git a/bigframes/display/table_widget_angular/.gitignore b/bigframes/display/table_widget_angular/.gitignore deleted file mode 100644 index 854acd5fc03..00000000000 --- a/bigframes/display/table_widget_angular/.gitignore +++ /dev/null @@ -1,44 +0,0 @@ -# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. - -# Compiled output -/dist -/tmp -/out-tsc -/bazel-out - -# Node -/node_modules -npm-debug.log -yarn-error.log - -# IDEs and editors -.idea/ -.project -.classpath -.c9/ -*.launch -.settings/ -*.sublime-workspace - -# Visual Studio Code -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -!.vscode/mcp.json -.history/* - -# Miscellaneous -/.angular/cache -.sass-cache/ -/connect.lock -/coverage -/libpeerconnection.log -testem.log -/typings -__screenshots__/ - -# System files -.DS_Store -Thumbs.db diff --git a/bigframes/display/table_widget_angular/.prettierrc b/bigframes/display/table_widget_angular/.prettierrc deleted file mode 100644 index d6c16d7ee77..00000000000 --- a/bigframes/display/table_widget_angular/.prettierrc +++ /dev/null @@ -1,12 +0,0 @@ -{ - "printWidth": 100, - "singleQuote": true, - "overrides": [ - { - "files": "*.html", - "options": { - "parser": "angular" - } - } - ] -} diff --git a/bigframes/display/table_widget_angular/README.md b/bigframes/display/table_widget_angular/README.md deleted file mode 100644 index db09b5b9f56..00000000000 --- a/bigframes/display/table_widget_angular/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# TableWidgetAngular - -This project is the Angular-based interactive Table Widget frontend for BigQuery DataFrames (``bigframes``). It is integrated into the Python backend using ``anywidget``. - -This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 21.2.9. - -## Getting Started - -Ensure you have [Node.js](https://nodejs.org/) installed. - -1. Install dependencies: - ```bash - npm install - ``` - -2. Start the local development server: - ```bash - npm run start - ``` - Navigate to `http://localhost:4200/`. The application will automatically reload when you modify the source files under `src/`. - -## Development & Code Scaffolding - -To generate a new component, directive, or service: -```bash -ng generate component component-name -``` - -For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run: -```bash -ng generate --help -``` - -## Running Tests - -To execute unit tests: -```bash -npm run test -``` - -## Packaging for Python - -Before testing the widget inside a Jupyter notebook or committing changes, compile the Angular app and bundle it so that the Python backend can load it: -```bash -npm run build:widget -``` - -This command compiles the project in production mode and then triggers `bundle.js` (via `esbuild`) to bundle the browser artifacts into a single unified ES module file at `../table_widget_angular.js`. diff --git a/bigframes/display/table_widget_angular/angular.json b/bigframes/display/table_widget_angular/angular.json deleted file mode 100644 index 497168c4c95..00000000000 --- a/bigframes/display/table_widget_angular/angular.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "$schema": "./node_modules/@angular/cli/lib/config/schema.json", - "version": 1, - "cli": { - "packageManager": "npm" - }, - "newProjectRoot": "projects", - "projects": { - "table-widget-angular": { - "projectType": "application", - "schematics": {}, - "root": "", - "sourceRoot": "src", - "prefix": "app", - "architect": { - "build": { - "builder": "@angular/build:application", - "options": { - "browser": "src/main.ts", - "tsConfig": "tsconfig.app.json", - "assets": [ - { - "glob": "**/*", - "input": "public" - } - ], - "styles": [ - "src/styles.css" - ] - }, - "configurations": { - "production": { - "budgets": [ - { - "type": "initial", - "maximumWarning": "500kB", - "maximumError": "1MB" - }, - { - "type": "anyComponentStyle", - "maximumWarning": "4kB", - "maximumError": "8kB" - } - ], - "outputHashing": "all" - }, - "development": { - "optimization": false, - "extractLicenses": false, - "sourceMap": true - } - }, - "defaultConfiguration": "production" - }, - "serve": { - "builder": "@angular/build:dev-server", - "configurations": { - "production": { - "buildTarget": "table-widget-angular:build:production" - }, - "development": { - "buildTarget": "table-widget-angular:build:development" - } - }, - "defaultConfiguration": "development" - }, - "test": { - "builder": "@angular/build:unit-test" - } - } - } - } -} diff --git a/bigframes/display/table_widget_angular/bundle.js b/bigframes/display/table_widget_angular/bundle.js deleted file mode 100644 index fb97ab8a376..00000000000 --- a/bigframes/display/table_widget_angular/bundle.js +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 esbuild = require('esbuild'); -const path = require('path'); - -const banner = `/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -`; - -esbuild.build({ - entryPoints: [path.resolve(__dirname, 'dist/table-widget-angular/browser/main.js')], - bundle: true, - outfile: path.resolve(__dirname, '../table_widget_angular.js'), - format: 'esm', - logLevel: 'info', - minify: true, - banner: { - js: banner, - }, -}).catch(() => process.exit(1)); diff --git a/bigframes/display/table_widget_angular/package-lock.json b/bigframes/display/table_widget_angular/package-lock.json deleted file mode 100644 index 33540ab8512..00000000000 --- a/bigframes/display/table_widget_angular/package-lock.json +++ /dev/null @@ -1,9591 +0,0 @@ -{ - "name": "table-widget-angular", - "version": "0.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "table-widget-angular", - "version": "0.0.0", - "dependencies": { - "@angular/common": "^22.1.0", - "@angular/compiler": "^22.1.0", - "@angular/core": "^22.1.0", - "@angular/forms": "^22.1.0", - "@angular/platform-browser": "^22.1.0", - "@angular/router": "^22.1.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0" - }, - "devDependencies": { - "@angular/build": "^22.1.2", - "@angular/cli": "^21.2.16", - "@angular/compiler-cli": "^22.1.0", - "esbuild": "^0.28.0", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", - "typescript": "~5.9.2", - "vitest": "^4.0.8" - } - }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@algolia/abtesting": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.14.1.tgz", - "integrity": "sha512-Dkj0BgPiLAaim9sbQ97UKDFHJE/880wgStAM18U++NaJ/2Cws34J5731ovJifr6E3Pv4T2CqvMXf8qLCC417Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.48.1.tgz", - "integrity": "sha512-LV5qCJdj+/m9I+Aj91o+glYszrzd7CX6NgKaYdTOj4+tUYfbS62pwYgUfZprYNayhkQpVFcrW8x8ZlIHpS23Vw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.48.1.tgz", - "integrity": "sha512-/AVoMqHhPm14CcHq7mwB+bUJbfCv+jrxlNvRjXAuO+TQa+V37N8k1b0ijaRBPdmSjULMd8KtJbQyUyabXOu6Kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.48.1.tgz", - "integrity": "sha512-VXO+qu2Ep6ota28ktvBm3sG53wUHS2n7bgLWmce5jTskdlCD0/JrV4tnBm1l7qpla1CeoQb8D7ShFhad+UoSOw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.48.1.tgz", - "integrity": "sha512-zl+Qyb0nLg+Y5YvKp1Ij+u9OaPaKg2/EPzTwKNiVyOHnQJlFxmXyUZL1EInczAZsEY8hVpPCLtNfhMhfxluXKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.48.1.tgz", - "integrity": "sha512-r89Qf9Oo9mKWQXumRu/1LtvVJAmEDpn8mHZMc485pRfQUMAwSSrsnaw1tQ3sszqzEgAr1c7rw6fjBI+zrAXTOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.48.1.tgz", - "integrity": "sha512-TPKNPKfghKG/bMSc7mQYD9HxHRUkBZA4q1PEmHgICaSeHQscGqL4wBrKkhfPlDV1uYBKW02pbFMUhsOt7p4ZpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.48.1.tgz", - "integrity": "sha512-4Fu7dnzQyQmMFknYwTiN/HxPbH4DyxvQ1m+IxpPp5oslOgz8m6PG5qhiGbqJzH4HiT1I58ecDiCAC716UyVA8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.48.1.tgz", - "integrity": "sha512-/RFq3TqtXDUUawwic/A9xylA2P3LDMO8dNhphHAUOU51b1ZLHrmZ6YYJm3df1APz7xLY1aht6okCQf+/vmrV9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.48.1", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.48.1.tgz", - "integrity": "sha512-Of0jTeAZRyRhC7XzDSjJef0aBkgRcvRAaw0ooYRlOw57APii7lZdq+layuNdeL72BRq1snaJhoMMwkmLIpJScw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.48.1.tgz", - "integrity": "sha512-bE7JcpFXzxF5zHwj/vkl2eiCBvyR1zQ7aoUdO+GDXxGp0DGw7nI0p8Xj6u8VmRQ+RDuPcICFQcCwRIJT5tDJFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.48.1.tgz", - "integrity": "sha512-MK3wZ2koLDnvH/AmqIF1EKbJlhRS5j74OZGkLpxI4rYvNi9Jn/C7vb5DytBnQ4KUWts7QsmbdwHkxY5txQHXVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.48.1.tgz", - "integrity": "sha512-2oDT43Y5HWRSIQMPQI4tA/W+TN/N2tjggZCUsqQV440kxzzoPGsvv9QP1GhQ4CoDa+yn6ygUsGp6Dr+a9sPPSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.48.1.tgz", - "integrity": "sha512-xcaCqbhupVWhuBP1nwbk1XNvwrGljozutEiLx06mvqDf3o8cHyEgQSHS4fKJM+UAggaWVnnFW+Nne5aQ8SUJXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@angular-devkit/architect": { - "version": "0.2102.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2102.16.tgz", - "integrity": "sha512-FDUKPpq70nJwGK4CICPD31XmesBEGv57Z+JBCPWrTa5mVZIXCQkeo5waIaNfzAnLdbpd74ULJJ3MDNVt4iaGZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.16", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular-devkit/core": { - "version": "21.2.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.16.tgz", - "integrity": "sha512-bRot0dqonxdSuGzXyOYtVJis/u9CJycrfC/aaxLeMF37gKtWIyCR2KFkMRXAoiV/AKk5/NuuqDNqcQS9w5G3Fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.4", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular-devkit/schematics": { - "version": "21.2.16", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.16.tgz", - "integrity": "sha512-3wTn2N6iWxYLrRaFDk3J3a6P3OxL+yvYGoDA7pNKfI+Nu0PpTK8BBwhNQD8L5P3US/QGWTkMNbzZ7XxBBfFP/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.16", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.21", - "ora": "9.3.0", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/build": { - "version": "22.1.2", - "resolved": "https://registry.npmjs.org/@angular/build/-/build-22.1.2.tgz", - "integrity": "sha512-DE/3o17JTel4EBt2BA4DqJYeBBuz5Ef/kf1jL9YZTyJu4SrLr/HI79K14jFr0VRIxzcqG92FdIzfLDxbOesQsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "2.3.0", - "@angular-devkit/architect": "0.2201.2", - "@babel/core": "8.0.1", - "@babel/helper-annotate-as-pure": "8.0.0", - "@babel/helper-split-export-declaration": "7.24.7", - "@inquirer/confirm": "6.1.1", - "@vitejs/plugin-basic-ssl": "2.3.0", - "beasties": "0.4.3", - "browserslist": "^4.26.0", - "esbuild": "0.28.1", - "https-proxy-agent": "9.1.0", - "jsonc-parser": "3.3.1", - "listr2": "10.2.2", - "magic-string": "1.0.0", - "mrmime": "2.0.1", - "oxc-parser": "0.142.0", - "parse5-html-rewriting-stream": "8.0.1", - "picomatch": "4.0.5", - "piscina": "5.2.0", - "rolldown": "1.2.0", - "sass": "1.101.0", - "semver": "7.8.5", - "source-map-support": "0.5.21", - "tinyglobby": "0.2.17", - "vite": "8.1.5", - "watchpack": "2.5.2" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "optionalDependencies": { - "lmdb": "3.5.6" - }, - "peerDependencies": { - "@angular/compiler": "^22.0.0", - "@angular/compiler-cli": "^22.0.0", - "@angular/core": "^22.0.0", - "@angular/localize": "^22.0.0", - "@angular/platform-browser": "^22.0.0", - "@angular/platform-server": "^22.0.0", - "@angular/service-worker": "^22.0.0", - "@angular/ssr": "^22.1.2", - "istanbul-lib-instrument": "^6.0.0", - "karma": "^6.4.0", - "less": "^4.2.0", - "ng-packagr": "^22.0.0", - "postcss": "^8.4.0", - "rollup": "^4.0.0", - "tailwindcss": "^2.0.0 || ^3.0.0 || ^4.0.0", - "tslib": "^2.3.0", - "typescript": ">=6.0 <6.1", - "vitest": "^4.0.8" - }, - "peerDependenciesMeta": { - "@angular/core": { - "optional": true - }, - "@angular/localize": { - "optional": true - }, - "@angular/platform-browser": { - "optional": true - }, - "@angular/platform-server": { - "optional": true - }, - "@angular/service-worker": { - "optional": true - }, - "@angular/ssr": { - "optional": true - }, - "istanbul-lib-instrument": { - "optional": true - }, - "karma": { - "optional": true - }, - "less": { - "optional": true - }, - "ng-packagr": { - "optional": true - }, - "postcss": { - "optional": true - }, - "rollup": { - "optional": true - }, - "tailwindcss": { - "optional": true - }, - "vitest": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/@angular-devkit/architect": { - "version": "0.2201.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.2201.2.tgz", - "integrity": "sha512-RRG3JA3hPH0ypbDIyquZt9DDTP5pOMPgqQ/iLSkok1MZdKiOgpk6FGfXCa1ei72SwlX7lnJdq94d6WWdqpbyKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "22.1.2", - "rxjs": "7.8.2" - }, - "bin": { - "architect": "bin/cli.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/build/node_modules/@angular-devkit/core": { - "version": "22.1.2", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.1.2.tgz", - "integrity": "sha512-tF1oEE7KPs8I08HJQmH5e4GkLUB3+MXXy8t6gMJULaLFxZYP9K1oXRFLappMpdm9OIbEXOChk23hrho0By9aYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "8.20.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.5", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/@inquirer/ansi": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", - "integrity": "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@angular/build/node_modules/@inquirer/confirm": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-6.1.1.tgz", - "integrity": "sha512-eb8DBZcz/2qHWQda4rk2JiQk5h9QV/cVHi1yjt0f69WFZMRFn0sJTye3EAP8icut8UDMjQPsaH5KbcOogefrFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^11.2.1", - "@inquirer/type": "^4.0.7" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/@inquirer/core": { - "version": "11.2.1", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-11.2.1.tgz", - "integrity": "sha512-Qd6GJT1yVyrZZCfN8W2qKF5ApmqryXRhRKCuip8h01x2w/esJQ2XIYc6f9abMIHgKQdBfFTSOdbHRLAhuM09UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^2.0.7", - "@inquirer/figures": "^2.0.7", - "@inquirer/type": "^4.0.7", - "cli-width": "^4.1.0", - "fast-wrap-ansi": "^0.2.0", - "mute-stream": "^3.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/@inquirer/figures": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-2.0.7.tgz", - "integrity": "sha512-aJ8TBPOGB6f/2qziPfElISTCEd5XOYTFckA2SGjhNmiKzfK/u4ot3v0DUzGVdUnKjN10EqnnEPck36BkyfLnJw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - } - }, - "node_modules/@angular/build/node_modules/@inquirer/type": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-4.0.7.tgz", - "integrity": "sha512-t28inv14nMQ1PhKpsJPY+kEs/c00qzeCOS2gTNRyTjG5d6qsVA2fItxW4hkvGZ5lvanGLdtCzVIx5dwdRpN1+g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=23.5.0 || ^22.13.0 || ^20.17.0" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@angular/build/node_modules/agent-base": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-9.0.0.tgz", - "integrity": "sha512-TQf59BsZnytt8GdJKLPfUZ54g/iaUL2OWDSFCCvMOhsHduDQxO8xC4PNeyIkVcA5KwL2phPSv0douC0fgWzmnA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@angular/build/node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/@angular/build/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/@angular/build/node_modules/https-proxy-agent": { - "version": "9.1.0", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-9.1.0.tgz", - "integrity": "sha512-ag87y7cJJ9/3+GxFr8Oy4O5faDsGRGnBGsJj/YjOSsSx/5eadKLYTMPlzuR6obgoCDDm0abAAZitXXQkMOPSpA==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "9.0.0", - "debug": "^4.3.4", - "proxy-agent-negotiate": "1.1.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@angular/build/node_modules/listr2": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.2.tgz", - "integrity": "sha512-JtNtbZj8q5BnDMR7trpwvwk3RIrANtIVzEUm8w7amp6xelLgyuq+4WZoTH913XaQAoH/cNdYhaNzBPA2U3xbDw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.2.0", - "eventemitter3": "^5.0.4", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^10.0.0" - }, - "engines": { - "node": ">=22.13.0" - } - }, - "node_modules/@angular/build/node_modules/magic-string": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.0.0.tgz", - "integrity": "sha512-CGvjzMN08iv6w1mm4/x3Gh1hLb4VnyRUA15FFpl6CsCIGGoe36k7kY5KNz9QDbSBN5I/fWHM6ZlIkUTa5xdUEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@angular/build/node_modules/mute-stream": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-3.0.0.tgz", - "integrity": "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@angular/build/node_modules/parse5-html-rewriting-stream": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.1.tgz", - "integrity": "sha512-NaRku2aMpUN1Sh1Gyk1KWUh2A7EJx2c6qYzvwsPtqhoHoaURshdrceYK3LunVCm3WHhm6FS7Vcczbvdh3/UIVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0", - "parse5": "^8.0.0", - "parse5-sax-parser": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/@angular/build/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/@angular/build/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@angular/build/node_modules/wrap-ansi": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", - "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "string-width": "^8.2.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/@angular/cli": { - "version": "21.2.16", - "resolved": "https://registry.npmjs.org/@angular/cli/-/cli-21.2.16.tgz", - "integrity": "sha512-/O2Bsy4jae/op06ejyfsL6K4cD4yo7TEH9iesD4UPEvcWTnV8lCdmE2oxbc1WGT3DIsZ00yBQhURSbetDPGFCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/architect": "0.2102.16", - "@angular-devkit/core": "21.2.16", - "@angular-devkit/schematics": "21.2.16", - "@inquirer/prompts": "7.10.1", - "@listr2/prompt-adapter-inquirer": "3.0.5", - "@modelcontextprotocol/sdk": "1.26.0", - "@schematics/angular": "21.2.16", - "@yarnpkg/lockfile": "1.1.0", - "algoliasearch": "5.48.1", - "ini": "6.0.0", - "jsonc-parser": "3.3.1", - "listr2": "9.0.5", - "npm-package-arg": "13.0.2", - "pacote": "21.5.1", - "parse5-html-rewriting-stream": "8.0.0", - "semver": "7.7.4", - "yargs": "18.0.0", - "zod": "4.3.6" - }, - "bin": { - "ng": "bin/ng.js" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@angular/common": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.1.0.tgz", - "integrity": "sha512-67L8AS00egxwEKnoMhNDxy+TY+eKOwvwa+os0Odq8nLm7+Qh7JnMVeub8hfncpenOFqlC/RUjO2W9H7Gd2veNA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/core": "22.1.0", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/compiler": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.1.0.tgz", - "integrity": "sha512-WCmuPnuXgqnqrkbrwqQRyldi1k3rlzNLVDl8ntINF7XWuJh0KfQLEkRK0FCCmBztWJkbGug4RBVnKTWlKhRzCQ==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - } - }, - "node_modules/@angular/compiler-cli": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-22.1.0.tgz", - "integrity": "sha512-jL89dbzkrV8AeLaxedBgT7ErMnbfi2dvwDJuCrUgm3eCNfbcOpGbNxzH+wDgvbRg2Lhj11/u3JPbW50xA6rvvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "8.0.1", - "@jridgewell/sourcemap-codec": "^1.4.14", - "chokidar": "^5.0.0", - "convert-source-map": "^1.5.1", - "reflect-metadata": "^0.2.0", - "semver": "^7.0.0", - "tslib": "^2.3.0", - "yargs": "^18.0.0" - }, - "bin": { - "ng-xi18n": "bundles/src/bin/ng_xi18n.js", - "ngc": "bundles/src/bin/ngc.js" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/compiler": "22.1.0", - "typescript": ">=6.0 <6.1" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@angular/core": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.1.0.tgz", - "integrity": "sha512-X5UaMuOCI4HAvSQIs3QtM+5e0Cni16DRaHUIL3BIBd4ZQNnSH3pZ25TsKQ8Jlu/3hAQ9rzV278kNQcecooGJ7g==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/compiler": "22.1.0", - "rxjs": "^6.5.3 || ^7.4.0", - "zone.js": "~0.15.0 || ~0.16.0" - }, - "peerDependenciesMeta": { - "@angular/compiler": { - "optional": true - }, - "zone.js": { - "optional": true - } - } - }, - "node_modules/@angular/forms": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-22.1.0.tgz", - "integrity": "sha512-nWlSM/pPp78Sx/fBM/tFEgZxdfZe50LkCE2/hkO22Fi1UM2maGc43LDsu/s6l0q9hFep4Wj+xa30KXDBS7Cn8A==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "tslib": "^2.3.0", - "zod": "^4.0.10" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.1.0", - "@angular/core": "22.1.0", - "@angular/platform-browser": "22.1.0", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@angular/platform-browser": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.1.0.tgz", - "integrity": "sha512-gqUYDUiPfwbaLYdH8WLnOLl3feo3OcNpnMO08HBHaUdi4TLNkC28xwa9fC6ANyYD22QZ5A3abSg8fmR6upWMwg==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/animations": "22.1.0", - "@angular/common": "22.1.0", - "@angular/core": "22.1.0" - }, - "peerDependenciesMeta": { - "@angular/animations": { - "optional": true - } - } - }, - "node_modules/@angular/router": { - "version": "22.1.0", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-22.1.0.tgz", - "integrity": "sha512-42Bs0g+tV2gE70Lqnt+VD/+DWbvWwQcg8QgXkTIu3A504tYknrZG/wmvki2AJGyZhmyQ46B4pfXLG4WDP8MFSA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": "^22.22.3 || ^24.15.0 || >=26.0.0" - }, - "peerDependencies": { - "@angular/common": "22.1.0", - "@angular/core": "22.1.0", - "@angular/platform-browser": "22.1.0", - "rxjs": "^6.5.3 || ^7.4.0" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-8.0.0.tgz", - "integrity": "sha512-dYYg153EyN2Ekbqw2zAsbd6/JR+9N2SEoC7YV2GyyqMM7x9bLDTjBD6XBhSMLH0wtIVyJj03jWNriQhaN+eoCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^8.0.0", - "js-tokens": "^10.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/code-frame/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-8.0.0.tgz", - "integrity": "sha512-DOjnob/cXOUgDOozCDeq/aK2p5y8dUIVdf6tNhEV1HQRd6I8aQ4f4fbtHRVEvb6lP3BGomrKHiS8ICAASSVQSw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-8.0.1.tgz", - "integrity": "sha512-5FgxM4dLQpMJHSiVATk8foW263dVHQHBVpXYiimNECVWG01f4nFyEbQixeT6Mwvg7TayREJ2gpKl3o2RoMdnqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-compilation-targets": "^8.0.0", - "@babel/helpers": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/template": "^8.0.0", - "@babel/traverse": "^8.0.0", - "@babel/types": "^8.0.0", - "@types/gensync": "^1.0.5", - "convert-source-map": "^2.0.0", - "empathic": "^2.0.1", - "gensync": "^1.0.0-beta.2", - "import-meta-resolve": "^4.2.0", - "json5": "^2.2.3", - "obug": "^2.1.1", - "semver": "^7.7.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/core/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/@babel/generator": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz", - "integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "@types/jsesc": "^2.5.0", - "jsesc": "^3.0.2" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/generator/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-8.0.0.tgz", - "integrity": "sha512-NSpMkMsvvZqzThJ0p1B02cbtA2ObEyfBvq950bmNkyxsxvcxwhvvCB036rKhlEnuBBo30bOrk13u3FzlKSoRrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-8.0.0.tgz", - "integrity": "sha512-JwculLABZvyPvyLBpwU/E/IbH2uM3mnxNtIJpxnIfb24y1PrdVxK5Dqjle4DpgqpGRnwgC7G8IkzPdSXZrO1Ew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^8.0.0", - "@babel/helper-validator-option": "^8.0.0", - "browserslist": "^4.24.0", - "lru-cache": "^11.0.0", - "semver": "^7.7.3" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-8.0.0.tgz", - "integrity": "sha512-lLozHOM6sWWlxNo8CYqHy4MBZeTvHXNgVPBfPOGsjPKUzHC2Az9QwB6gxdQmpwHl6GlQtbGgS+lj5887guDiLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "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": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-8.0.0.tgz", - "integrity": "sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-8.0.0.tgz", - "integrity": "sha512-wfbi91pM3py96oIiJEz7qIpyXDytgr9zQC1HEWwlGNVRAEmItuU/0a41ZUKu1sJGyhhOIpc4t5vk4PYzt8wpsg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/helpers/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz", - "integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^8.0.4" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/parser/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-8.0.0.tgz", - "integrity": "sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/parser": "^8.0.0", - "@babel/types": "^8.0.0" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/template/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-8.0.4.tgz", - "integrity": "sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^8.0.0", - "@babel/generator": "^8.0.0", - "@babel/helper-globals": "^8.0.0", - "@babel/parser": "^8.0.4", - "@babel/template": "^8.0.0", - "@babel/types": "^8.0.4", - "obug": "^2.1.1" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-string-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz", - "integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/helper-validator-identifier": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz", - "integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/traverse/node_modules/@babel/types": { - "version": "8.0.4", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz", - "integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^8.0.0", - "@babel/helper-validator-identifier": "^8.0.4" - }, - "engines": { - "node": "^22.18.0 || >=24.11.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "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/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/@emnapi/core": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", - "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", - "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@gar/promise-retry": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@gar/promise-retry/-/promise-retry-1.0.3.tgz", - "integrity": "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@harperfast/extended-iterable": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@harperfast/extended-iterable/-/extended-iterable-1.0.3.tgz", - "integrity": "sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==", - "dev": true, - "license": "Apache-2.0", - "optional": true - }, - "node_modules/@hono/node-server": { - "version": "1.19.14", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", - "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.14.1" - }, - "peerDependencies": { - "hono": "^4" - } - }, - "node_modules/@inquirer/ansi": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", - "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/checkbox": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", - "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/confirm": { - "version": "5.1.21", - "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", - "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/core": { - "version": "10.3.2", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", - "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "cli-width": "^4.1.0", - "mute-stream": "^2.0.0", - "signal-exit": "^4.1.0", - "wrap-ansi": "^6.2.0", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/editor": { - "version": "4.2.23", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", - "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/external-editor": "^1.0.3", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/expand": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", - "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/external-editor": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", - "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^2.1.1", - "iconv-lite": "^0.7.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/figures": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", - "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@inquirer/input": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", - "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/number": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", - "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/password": { - "version": "4.0.23", - "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", - "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/prompts": { - "version": "7.10.1", - "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", - "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/checkbox": "^4.3.2", - "@inquirer/confirm": "^5.1.21", - "@inquirer/editor": "^4.2.23", - "@inquirer/expand": "^4.0.23", - "@inquirer/input": "^4.3.1", - "@inquirer/number": "^3.0.23", - "@inquirer/password": "^4.0.23", - "@inquirer/rawlist": "^4.1.11", - "@inquirer/search": "^3.2.2", - "@inquirer/select": "^4.4.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/rawlist": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", - "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/search": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", - "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/select": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", - "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/ansi": "^1.0.2", - "@inquirer/core": "^10.3.2", - "@inquirer/figures": "^1.0.15", - "@inquirer/type": "^3.0.10", - "yoctocolors-cjs": "^2.1.3" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@inquirer/type": { - "version": "3.0.10", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", - "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/node": ">=18" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - } - }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=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/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/@listr2/prompt-adapter-inquirer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.5.tgz", - "integrity": "sha512-WELs+hj6xcilkloBXYf9XXK8tYEnKsgLj01Xl5ONUJpKjmT5hGVUzNUS5tooUxs7pGMrw+jFD/41WpqW4V3LDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@inquirer/type": "^3.0.8" - }, - "engines": { - "node": ">=20.0.0" - }, - "peerDependencies": { - "@inquirer/prompts": ">= 3 < 8", - "listr2": "9.0.5" - } - }, - "node_modules/@lmdb/lmdb-darwin-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", - "integrity": "sha512-mY5FG4TjPAkY4P0w+OhHaUka5mDh2TX2WKYIwuKzJ1zeW3VvRgxdam/lGJTquI+bthTx5CSHDW+BAQCnNAzkEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-darwin-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.5.6.tgz", - "integrity": "sha512-foa+pwitysO8k+xhs7psBFfTKnVgR69NlZRRTHaFVDqphh7AdGpLeyRzKw/ofatr/sN6TiHRRW6mmop0ZrrppQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.5.6.tgz", - "integrity": "sha512-QR4YRyR5h5Z8eGXrNQjiyo2NNDfqi3tCc9dQG5Is1blCt+qWw1ZoBWhlWAr5d+jshkifMIJjVHzHGKbkKzF8Tw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.5.6.tgz", - "integrity": "sha512-HmiyFFdJa38s1heCMSooSPaBSFTHJ3C+ERPp28xAPlDX1YiALJVOgbry065nXd8Y7KISWjnw05zpG1RX8IfftA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-linux-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.5.6.tgz", - "integrity": "sha512-ADzCuCF2cTNiX9kDScqcz1fjnAkxPpQNneV3KFTdV3wWtVlI2sTGzySoMTgDpinkMMFj1NTJlxA6XR8fwc4hlA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lmdb/lmdb-win32-arm64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.5.6.tgz", - "integrity": "sha512-J7A9aEQsQiv0TYtBGL7NDIPp2lOS8nnl+zm4sWZm1xlsTTaQ4PgD096Adzdrk27rw3UxCkDXdCUa4ax41oztBQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@lmdb/lmdb-win32-x64": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.5.6.tgz", - "integrity": "sha512-1g7G0knRX2iV/voDu54yxrGqw5Dk0w2oIYb7dgJq8IkOi+m7wbD8Q3QpPFjh0C01G58S88dqGn03len6UPCXsg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@hono/node-server": "^1.19.9", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", - "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", - "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", - "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", - "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", - "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", - "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@napi-rs/nice": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice/-/nice-1.1.1.tgz", - "integrity": "sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "optionalDependencies": { - "@napi-rs/nice-android-arm-eabi": "1.1.1", - "@napi-rs/nice-android-arm64": "1.1.1", - "@napi-rs/nice-darwin-arm64": "1.1.1", - "@napi-rs/nice-darwin-x64": "1.1.1", - "@napi-rs/nice-freebsd-x64": "1.1.1", - "@napi-rs/nice-linux-arm-gnueabihf": "1.1.1", - "@napi-rs/nice-linux-arm64-gnu": "1.1.1", - "@napi-rs/nice-linux-arm64-musl": "1.1.1", - "@napi-rs/nice-linux-ppc64-gnu": "1.1.1", - "@napi-rs/nice-linux-riscv64-gnu": "1.1.1", - "@napi-rs/nice-linux-s390x-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-gnu": "1.1.1", - "@napi-rs/nice-linux-x64-musl": "1.1.1", - "@napi-rs/nice-openharmony-arm64": "1.1.1", - "@napi-rs/nice-win32-arm64-msvc": "1.1.1", - "@napi-rs/nice-win32-ia32-msvc": "1.1.1", - "@napi-rs/nice-win32-x64-msvc": "1.1.1" - } - }, - "node_modules/@napi-rs/nice-android-arm-eabi": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz", - "integrity": "sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-android-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz", - "integrity": "sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz", - "integrity": "sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-darwin-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz", - "integrity": "sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-freebsd-x64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz", - "integrity": "sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm-gnueabihf": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz", - "integrity": "sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz", - "integrity": "sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-arm64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz", - "integrity": "sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-ppc64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz", - "integrity": "sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-riscv64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz", - "integrity": "sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-s390x-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz", - "integrity": "sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-gnu": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz", - "integrity": "sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-linux-x64-musl": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz", - "integrity": "sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-openharmony-arm64": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz", - "integrity": "sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-arm64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz", - "integrity": "sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-ia32-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz", - "integrity": "sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/nice-win32-x64-msvc": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz", - "integrity": "sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.2.tgz", - "integrity": "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.3" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=23.5.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", - "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" - } - }, - "node_modules/@npmcli/agent": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.2.tgz", - "integrity": "sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==", - "dev": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/fs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", - "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", - "dev": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/git": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-7.0.2.tgz", - "integrity": "sha512-oeolHDjExNAJAnlYP2qzNjMX/Xi9bmu78C9dIGr4xjobrSKbuMYCph8lTzn4vnW3NjIqVmw/f8BCfouqyJXlRg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "ini": "^6.0.0", - "lru-cache": "^11.2.1", - "npm-pick-manifest": "^11.0.1", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/git/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/@npmcli/git/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/installed-package-contents": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz", - "integrity": "sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^5.0.0", - "npm-normalize-package-bin": "^5.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz", - "integrity": "sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/package-json": { - "version": "7.0.5", - "resolved": "https://registry.npmjs.org/@npmcli/package-json/-/package-json-7.0.5.tgz", - "integrity": "sha512-iVuTlG3ORq2iaVa1IWUxAO/jIp77tUKBhoMjuzYW2kL4MLN1bi/ofqkZ7D7OOwh8coAx1/S2ge0rMdGv8sLSOQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "glob": "^13.0.0", - "hosted-git-info": "^9.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.5.3", - "spdx-expression-parse": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz", - "integrity": "sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q==", - "dev": true, - "license": "ISC", - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/redact": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/redact/-/redact-4.0.0.tgz", - "integrity": "sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-10.0.4.tgz", - "integrity": "sha512-mGUWr1uMnf0le2TwfOZY4SFxZGXGfm4Jtay/nwAa2FLNAKXUoUwaGwBMNH36UHPtinWfTSJ3nqFQr0091CxVGg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz", - "integrity": "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.142.0.tgz", - "integrity": "sha512-WZkvGRLNQTz8lR9zP5nLjUdlroRCopBu3g9zF1p/laE6DzT1UbQo8Rdz5MWhaJUPYg/6gp+jo7HUgsyKaN1FtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.142.0.tgz", - "integrity": "sha512-l4khS8LQOOVYsGRVARo1gSaCT/aBSceUVXgtovWc2+drnxVuDr082WA3OCHVdVzIz5JIrP/y9CWsSKxBDNmYGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.142.0.tgz", - "integrity": "sha512-QBsNF3nqlXmcH2B1YOPqQYmCJoy4HuIjUxGbBO/k5JAJUl68ghU2psRY2zPk+RyBaWqKP/qfL4oaFgEMCdwskA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.142.0.tgz", - "integrity": "sha512-b7Q7m4Cqc6XqNhri3R+QhU+GVy646Pn+bkdhrDdWym/Fdi0ZUa+d73H9dm5H91JtbtAQ/z1d8XKMW3oOV8a4tQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.142.0.tgz", - "integrity": "sha512-3riVS5IhdH3uCZj1Y9ftDQlR0dvLsIlw/edrRqk8JhgNd5K0XSs+UBtgh50N13CAlW9/TXj6sVGXaKNBocd0Yg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.142.0.tgz", - "integrity": "sha512-NmXUOpgpTSkhl795TiXmWppTwmSJ92RC1qvD6e4XOF+slgmo3e6Ah+kEu+6AN8s7NAOEwqGmir58MgSQSWmBSA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.142.0.tgz", - "integrity": "sha512-gc0EXsKtXgerujmU2Bql3u1L1HsSQ2774R83idq/FoNMPVV/RY/1ErFsvnit7KoiP/sLvzQixeUo4Ut0ic0wmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.142.0.tgz", - "integrity": "sha512-F2XvmWSE0uWpie+jHKKIFgdVOe9ypGhkEZxKx5DuW215K6cbAC274yYaPkcM7EqY4Df3Weyhpcz3lsURyH2LVg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.142.0.tgz", - "integrity": "sha512-wLMbT21U/QxknQsk+VvNF0b9D2/aGWhcaQQQ+VYlE8FwD5+GoWZIPPXNzyHmkYyhm0KB3itL+TBavjMatqNnYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.142.0.tgz", - "integrity": "sha512-+G8F/4ckwT7FCJV4H2bt09xEzJbjNCfuL4Sp1AYNaFtFMVtgIGMuJlteT82U+K0UIZ/DzAR/LDlMFnEuajG7Kw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.142.0.tgz", - "integrity": "sha512-hTsHtTLxMAfCo+rpF5K3qZJKW2NpPN/CHd4mYB3y7XlSdspHkd2gehDIofP64AacA9nWQw2tY3O7wR6UY8IVOA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.142.0.tgz", - "integrity": "sha512-6y7qYY3TCUDYjqswImdTGl92y+KA/80twALegQPN27kfY+bG7Ib1+L3jbmrCZQx6wrVnai9IPsEZp07I0hx7JQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.142.0.tgz", - "integrity": "sha512-i69kAWU+2LgoH5bR+zWiiu+UzAw7Oxkwv7COeJTeY19pn4e70nKQcr9Pm6cL2Z0Z54d+gl9qADlK/0yyuCPiBA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.142.0.tgz", - "integrity": "sha512-4SQs678MmjYVrmhAgCWD4o0vpaFszXw9xLX5p2Z9MMFcltxiLkA88wQjh80YHjPrXtpyZ2CWI5m+1yNKM0m2Pw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.142.0.tgz", - "integrity": "sha512-YHpx9N7Ln3a++Tc8rv+H7mrK1zyJQOAwCFg8LZ3lTs1T5afGWeZrLPhPT9HLnIwSjCyJqPWVMIrMxbjcmBr2oQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.142.0.tgz", - "integrity": "sha512-3pLDyY3+oogW73RM5uehNgAiR/Xfb7fvO2Q1Z1gIqZ2+50XDVQmBVlRkHXZTU4gKnQHpwETNsYQVsJ3joVB2iA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.142.0.tgz", - "integrity": "sha512-Had/VeVY28Oyb0K+Q4FV8KCzoBycIh93oDK6pCbya9lkzdq+ikMHMgBubsdqqlybjJmQRawCQRrnBRHyQwYvcQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.142.0.tgz", - "integrity": "sha512-GGi3+YphVHavvgs6gum2UXoNCqzHAmPt/nXkn8ZQZstV2Q1qZD1Mn8fz/nWrDkefHQtrG/+1/XrbMxsBTo6Svw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.142.0.tgz", - "integrity": "sha512-Ny/Wv4Us1LGC/ljwNTp+Hx3r/pH15EFfeDF0p+n898gt+TtRd6C9SccHcuUhDiNTb8s5tt7jdeAMDRQZ4Vq6hg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.142.0.tgz", - "integrity": "sha512-7W+2q5AKQVU36fkaryontrHn3YDt1RyUYXatw9i5H8ocYe2sPKSFB6eS8WNPeRKiN1qAWWZUPm7gwFzJGrccqQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@parcel/watcher": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", - "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.3", - "is-glob": "^4.0.3", - "node-addon-api": "^7.0.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "@parcel/watcher-android-arm64": "2.5.6", - "@parcel/watcher-darwin-arm64": "2.5.6", - "@parcel/watcher-darwin-x64": "2.5.6", - "@parcel/watcher-freebsd-x64": "2.5.6", - "@parcel/watcher-linux-arm-glibc": "2.5.6", - "@parcel/watcher-linux-arm-musl": "2.5.6", - "@parcel/watcher-linux-arm64-glibc": "2.5.6", - "@parcel/watcher-linux-arm64-musl": "2.5.6", - "@parcel/watcher-linux-x64-glibc": "2.5.6", - "@parcel/watcher-linux-x64-musl": "2.5.6", - "@parcel/watcher-win32-arm64": "2.5.6", - "@parcel/watcher-win32-ia32": "2.5.6", - "@parcel/watcher-win32-x64": "2.5.6" - } - }, - "node_modules/@parcel/watcher-android-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", - "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", - "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-darwin-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", - "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-freebsd-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", - "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", - "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", - "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", - "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-arm64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", - "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-glibc": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", - "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-linux-x64-musl": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", - "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-arm64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", - "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-ia32": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", - "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher-win32-x64": { - "version": "2.5.6", - "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", - "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/@parcel/watcher/node_modules/node-addon-api": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", - "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.0.tgz", - "integrity": "sha512-9yB1l95IrJuNGDFdOYe79vdApdz6WWBCObE+rQ2LUliYUlcyFwSYIb2xb5/Ifw7dAtMy2ZqNyd8QTSOc7duAKw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.0.tgz", - "integrity": "sha512-pexNaW9ACLUOaBITOpU6qVu4VrsOFIjTv6bzgu0YUATo4eUJx0V605PxwZfndpPOn0ilqGqvGQ0M8UW0IE24jg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.0.tgz", - "integrity": "sha512-NqKYaq0355ZmNMG4QGpxtEDxsc7tGDhjhCm4PpE0cwnBW+5Il95LJyq414niEiaKLVjnVHBEjSo1wngKxJNiFw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.0.tgz", - "integrity": "sha512-3vPoHzh6eBTz9IbB0/qZdSr0Qeks2echn+I4cHu2joV74VriPDdldswksEDzrl1mBB+oPRi+67+3Ib59paxIPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.0.tgz", - "integrity": "sha512-E6NNefZ1bUVmKJq2tJkf45J4Zyczj7qm9rUT7NY+Xo2474Y13qWAwc2tvBt0BAVbmtXR1llkxXg0Ou1jbDf2SQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.0.tgz", - "integrity": "sha512-D+TgkdgM1vu+7/Fpf8+v0ARW+RXEP9Ccazgm8zQ4JFFd9Q7SrYQ2TakU5S5ihazQDgpKyAgZDOcIFsvoHmTZ8w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.0.tgz", - "integrity": "sha512-wUqdwJBbAv0APN87GecstdMUtLjjNTs0hBALpxETD73mccFxdmt/XeizXDtN5RAlBwNKmI+Tg+blect2G+8IeQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.0.tgz", - "integrity": "sha512-9DtF35qR9/NrfhM4oxLplCzVVjE+KKm8Pjemi0i/sdhAWkUasjmSo8WTTubNJClhSHCfyk2yeyoXDQEDPtDAAw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.0.tgz", - "integrity": "sha512-RzuHrBh8X8Hntd2N4VR02QGEciq/9JhcZoTpR/Cee6otRrlILGCf3cg2ygHuih+ZebUnWmMrDX6ITI85btO6rQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.0.tgz", - "integrity": "sha512-MK7L0018jjh1jR3mh21G2j1zAVcpscJBlPo2z19pRjv2XOYGRhaV4LyiD8HO6nCDdZln9IFgCMIV5yt4E3klGQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.0.tgz", - "integrity": "sha512-gyrxLQ9NfGb/9LoVnC4kb9miUghw1mghnkfYvNHSnVIXriabnfgGPUP4RLcJm87q3KgYz4FYUG8IDiWUT+CpSw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.0.tgz", - "integrity": "sha512-/6VFMQGRmrhP77KXDC+StIxGzcNp5JOIyYtw0CQ8gPlzhpiIRucYfoM5FaFamHd5BJYIdH86yfP46l1p3WdrFA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.2.0.tgz", - "integrity": "sha512-rwdbUL465kisF24WEJLvP3JrEG6E5GRuIHt5wpMwHGERtHe4Wm2CIvtf5gTBgr2tGOHKh5NdKEAFS2VkOPE91g==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.2", - "@emnapi/runtime": "1.11.2", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.0.tgz", - "integrity": "sha512-+5suHwRiKGmhwyUaNT8a5QbrBvLFh2DbO910TEmGRH1aSxwrCezodvGQnulv4uiWEIv1Kq4ypRsJ5+O+ry1DiA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.0.tgz", - "integrity": "sha512-WfFv6/qGufotqBSBzBYwgpCkJBk8Nj7697LL9vTz/XWc67e0r3oewu8iMRwQj3AUL45GVD7wVsPjCsAAtW66Wg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@schematics/angular": { - "version": "21.2.16", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.16.tgz", - "integrity": "sha512-ctvsRartACu77VAM416VlNV3mag7FhU08I/734f4+sS/UZmnhuTM5a4tTTWEI1U7iPeJoBtjreh6LgeP+QZLbQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "21.2.16", - "@angular-devkit/schematics": "21.2.16", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@sigstore/bundle": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", - "integrity": "sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/core": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/core/-/core-3.2.1.tgz", - "integrity": "sha512-qRsxPnCrbC/puegGxKuynfnxgLiHqWStrSjxkoB4YKqq3Z3s4cyZyj42ZdWFAEblNP65C+rBH8EuREHIXoi83g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.5.1.tgz", - "integrity": "sha512-/ScWUhhoFasJsSRGTVBwId1loQjjnjAfE4djL6ZhrXRpNCmPTnUKF5Jokd58ILseOMjzET3UrMOtJPS9sYeI0g==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-4.1.1.tgz", - "integrity": "sha512-Hf4xglukg0XXQ2RiD5vSoLjdPe8OBUPA8XeVjUObheuDcWdYWrnH/BNmxZCzkAy68MzmNCxXLeurJvs6hcP2OQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@gar/promise-retry": "^1.0.2", - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.0", - "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.4", - "proc-log": "^6.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-4.0.2.tgz", - "integrity": "sha512-TCAzTy0xzdP79EnxSjq9KQ3eaR7+FmudLC6eRKknVKZbV7ZNlGLClAAQb/HMNJ5n2OBNk2GT1tEmU0xuPr+SLQ==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@sigstore/verify": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sigstore/verify/-/verify-3.1.1.tgz", - "integrity": "sha512-qv7+G3J2cc6wwFj3yKvXOamzqhMwSk1ogPGmhpS8iXllcPrJaIIBA+4HbttlHVu1pqWTdmaCH/WE7UOC51kdoA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.1", - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, - "node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz", - "integrity": "sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-4.1.0.tgz", - "integrity": "sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^10.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", - "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/gensync": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/gensync/-/gensync-1.0.5.tgz", - "integrity": "sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/jsesc": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz", - "integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitejs/plugin-basic-ssl": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.3.0.tgz", - "integrity": "sha512-bdyo8rB3NnQbikdMpHaML9Z1OZPBu6fFOBo+OtxsBlvMJtysWskmBcnbIDhUqgC8tcxNv/a+BcV5U+2nQMm1OQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "peerDependencies": { - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/expect": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", - "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", - "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.5", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", - "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", - "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.5", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", - "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "@vitest/utils": "4.1.5", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", - "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", - "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.5", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils/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/@yarnpkg/lockfile": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz", - "integrity": "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/abbrev": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", - "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "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/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, - "node_modules/algoliasearch": { - "version": "5.48.1", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.48.1.tgz", - "integrity": "sha512-Rf7xmeuIo7nb6S4mp4abW2faW8DauZyE2faBIKFaUfP3wnpOvNSbiI5AwVhqBNj0jPgBWEvhyCu0sLjN2q77Rg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.14.1", - "@algolia/client-abtesting": "5.48.1", - "@algolia/client-analytics": "5.48.1", - "@algolia/client-common": "5.48.1", - "@algolia/client-insights": "5.48.1", - "@algolia/client-personalization": "5.48.1", - "@algolia/client-query-suggestions": "5.48.1", - "@algolia/client-search": "5.48.1", - "@algolia/ingestion": "1.48.1", - "@algolia/monitoring": "1.48.1", - "@algolia/recommend": "5.48.1", - "@algolia/requester-browser-xhr": "5.48.1", - "@algolia/requester-fetch": "5.48.1", - "@algolia/requester-node-http": "5.48.1" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", - "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/beasties": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/beasties/-/beasties-0.4.3.tgz", - "integrity": "sha512-fIIeLOcbAB/K1kb1HBVJoiq1alHL4RCYBSo5e7HzrNkkgMggXR1Vqt/Z9JWnkfe/qdCo66Ux3QRwZioAIBdWRA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "css-select": "^6.0.0", - "css-what": "^7.0.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "htmlparser2": "^10.0.0", - "picocolors": "^1.1.1", - "postcss": "^8.4.49", - "postcss-media-query-parser": "^0.2.3", - "postcss-safe-parser": "^7.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/body-parser": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", - "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^1.0.5", - "debug": "^4.4.3", - "http-errors": "^2.0.0", - "iconv-lite": "^0.7.0", - "on-finished": "^2.4.1", - "qs": "^6.14.1", - "raw-body": "^3.0.1", - "type-is": "^2.0.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "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.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "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/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/cacache": { - "version": "20.0.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.4.tgz", - "integrity": "sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "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/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "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/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/chardet": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", - "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/cli-cursor": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", - "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "restore-cursor": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", - "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "slice-ansi": "^8.0.0", - "string-width": "^8.2.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-width": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", - "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">= 12" - } - }, - "node_modules/cliui": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", - "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^7.2.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cliui/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "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/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/content-disposition": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", - "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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-select": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-6.0.0.tgz", - "integrity": "sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^7.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "nth-check": "^2.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-7.0.0.tgz", - "integrity": "sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/detect-libc": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", - "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=8" - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "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/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.349", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", - "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, - "node_modules/empathic": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.1.tgz", - "integrity": "sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - } - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", - "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", - "dev": true, - "license": "MIT" - }, - "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/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "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/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "dev": true, - "license": "MIT" - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/eventemitter3": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", - "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", - "dev": true, - "license": "MIT" - }, - "node_modules/eventsource": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", - "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", - "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/exponential-backoff": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", - "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.6.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.1.tgz", - "integrity": "sha512-0D493aP61w0TJ2A0wy27riRsO7FMQ7FK+KUHOKCSfPvYo0R55aiC6emCVgFUeShH0fq0ICPVzNcgoS+BsbXQCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-truncated-width": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", - "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-string-width": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", - "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-truncated-width": "^3.0.2" - } - }, - "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, - "node_modules/fast-wrap-ansi": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", - "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-string-width": "^3.0.2" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "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-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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-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/glob": { - "version": "13.0.6", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", - "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.2.2", - "minipass": "^7.1.3", - "path-scurry": "^2.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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-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/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/hono": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.1.tgz", - "integrity": "sha512-kdJoFVv2xmayw6cY09H7AbMJMt8Jn5jdlEdXsP7AGBdF2DIptVlKlOLKXP41yPip4/a3yQPv9gVcJYI8YY04dw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.9.0" - } - }, - "node_modules/hosted-git-info": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", - "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", - "dev": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/htmlparser2": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", - "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.2.2", - "entities": "^7.0.1" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/http-cache-semantics": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", - "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", - "dev": true, - "license": "BSD-2-Clause" - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "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/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/ignore-walk": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-8.0.0.tgz", - "integrity": "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/immutable": { - "version": "5.1.9", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", - "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", - "dev": true, - "license": "MIT" - }, - "node_modules/import-meta-resolve": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", - "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "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/ini": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", - "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-interactive": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", - "integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "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-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-unicode-supported": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", - "integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "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/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/panva" - } - }, - "node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", - "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "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": "5.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz", - "integrity": "sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true, - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "dev": true, - "license": "BSD-2-Clause" - }, - "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/jsonc-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", - "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsonparse": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", - "integrity": "sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg==", - "dev": true, - "engines": [ - "node >= 0.2.0" - ], - "license": "MIT" - }, - "node_modules/lightningcss": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", - "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", - "dev": true, - "license": "MPL-2.0", - "dependencies": { - "detect-libc": "^2.0.3" - }, - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - }, - "optionalDependencies": { - "lightningcss-android-arm64": "1.33.0", - "lightningcss-darwin-arm64": "1.33.0", - "lightningcss-darwin-x64": "1.33.0", - "lightningcss-freebsd-x64": "1.33.0", - "lightningcss-linux-arm-gnueabihf": "1.33.0", - "lightningcss-linux-arm64-gnu": "1.33.0", - "lightningcss-linux-arm64-musl": "1.33.0", - "lightningcss-linux-x64-gnu": "1.33.0", - "lightningcss-linux-x64-musl": "1.33.0", - "lightningcss-win32-arm64-msvc": "1.33.0", - "lightningcss-win32-x64-msvc": "1.33.0" - } - }, - "node_modules/lightningcss-android-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", - "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-arm64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", - "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-darwin-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", - "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-freebsd-x64": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", - "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", - "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", - "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", - "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", - "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-linux-x64-musl": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", - "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", - "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.33.0", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", - "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MPL-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 12.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/parcel" - } - }, - "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", - "log-update": "^6.1.0", - "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/listr2/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lmdb": { - "version": "3.5.6", - "resolved": "https://registry.npmjs.org/lmdb/-/lmdb-3.5.6.tgz", - "integrity": "sha512-j3uE8ReKNyUWDjhfEFSJqE/1DLtfTR5Z8yFzVHvBjAk37wNg7HdScjcv8ttPHRvrdgPQMPWxFFI0SsdBzI5lBw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@harperfast/extended-iterable": "^1.0.3", - "msgpackr": "^1.11.2", - "node-addon-api": "^6.1.0", - "node-gyp-build-optional-packages": "5.2.2", - "ordered-binary": "^1.5.3", - "weak-lru-cache": "^1.2.2" - }, - "bin": { - "download-lmdb-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@lmdb/lmdb-darwin-arm64": "3.5.6", - "@lmdb/lmdb-darwin-x64": "3.5.6", - "@lmdb/lmdb-linux-arm": "3.5.6", - "@lmdb/lmdb-linux-arm64": "3.5.6", - "@lmdb/lmdb-linux-x64": "3.5.6", - "@lmdb/lmdb-win32-arm64": "3.5.6", - "@lmdb/lmdb-win32-x64": "3.5.6" - } - }, - "node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/lru-cache": { - "version": "11.5.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", - "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/make-fetch-happen": { - "version": "15.0.6", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.6.tgz", - "integrity": "sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/agent": "^4.0.0", - "@npmcli/redact": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "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/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/media-typer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", - "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/mimic-function": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", - "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", - "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-fetch": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.2.tgz", - "integrity": "sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^2.0.0", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "iconv-lite": "^0.7.2" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.7.tgz", - "integrity": "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-flush/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true, - "license": "ISC" - }, - "node_modules/minipass-sized": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", - "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minizlib": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", - "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mrmime": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", - "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "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/msgpackr": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.12.1.tgz", - "integrity": "sha512-4EUH9tQHnMmEgzW/MdAP0KIfa1T9AF+htl0ffe2n5vb2EKn9y2co8ccpgWko6S52Jy1PQZKwRnx5/KkYjtd9MQ==", - "dev": true, - "license": "MIT", - "optional": true, - "optionalDependencies": { - "msgpackr-extract": "^3.0.2" - } - }, - "node_modules/msgpackr-extract": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", - "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build-optional-packages": "5.2.2" - }, - "bin": { - "download-msgpackr-prebuilds": "bin/download-prebuilds.js" - }, - "optionalDependencies": { - "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", - "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" - } - }, - "node_modules/mute-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", - "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/node-gyp": { - "version": "12.4.0", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", - "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.4", - "tinyglobby": "^0.2.12", - "undici": "^6.25.0", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-gyp-build-optional-packages": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", - "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "detect-libc": "^2.0.1" - }, - "bin": { - "node-gyp-build-optional-packages": "bin.js", - "node-gyp-build-optional-packages-optional": "optional.js", - "node-gyp-build-optional-packages-test": "build-test.js" - } - }, - "node_modules/node-gyp/node_modules/isexe": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", - "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=20" - } - }, - "node_modules/node-gyp/node_modules/undici": { - "version": "6.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", - "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18.17" - } - }, - "node_modules/node-gyp/node_modules/which": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", - "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^4.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nopt": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", - "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", - "dev": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-bundled": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-5.0.0.tgz", - "integrity": "sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-install-checks": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-8.0.0.tgz", - "integrity": "sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz", - "integrity": "sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-package-arg": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-13.0.2.tgz", - "integrity": "sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA==", - "dev": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-packlist": { - "version": "10.0.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-10.0.4.tgz", - "integrity": "sha512-uMW73iajD8hiH4ZBxEV3HC+eTnppIqwakjOYuvgddnalIw2lJguKviK1pcUJDlIWm1wSJkchpDZDSVVsZEYRng==", - "dev": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz", - "integrity": "sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "19.1.1", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz", - "integrity": "sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/obug": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", - "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT" - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "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": "7.0.0", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", - "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "mimic-function": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ora": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ordered-binary": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", - "integrity": "sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/oxc-parser": { - "version": "0.142.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.142.0.tgz", - "integrity": "sha512-kKR+jPiRJYJDexVoziIg/FVGvr1fT1FZSSJOk6tVoMKKSlsf1Cso+cgGCJkOEDWOP174vRntCPFKg+AS7InWvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.142.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.142.0", - "@oxc-parser/binding-android-arm64": "0.142.0", - "@oxc-parser/binding-darwin-arm64": "0.142.0", - "@oxc-parser/binding-darwin-x64": "0.142.0", - "@oxc-parser/binding-freebsd-x64": "0.142.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.142.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.142.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.142.0", - "@oxc-parser/binding-linux-arm64-musl": "0.142.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.142.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.142.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-gnu": "0.142.0", - "@oxc-parser/binding-linux-x64-musl": "0.142.0", - "@oxc-parser/binding-openharmony-arm64": "0.142.0", - "@oxc-parser/binding-wasm32-wasi": "0.142.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.142.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.142.0", - "@oxc-parser/binding-win32-x64-msvc": "0.142.0" - } - }, - "node_modules/p-map": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", - "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pacote": { - "version": "21.5.1", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-21.5.1.tgz", - "integrity": "sha512-KvcJ9iy3crysCsgqc4+PknH/w6jkrp8JN36mpZBPwNaDRwTfMZD37YzRazNstiZUOhuF5pno9f78n9mEJBavwg==", - "dev": true, - "license": "ISC", - "dependencies": { - "@gar/promise-retry": "^1.0.0", - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz", - "integrity": "sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0", - "parse5": "^8.0.0", - "parse5-sax-parser": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5-html-rewriting-stream/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/parse5-sax-parser": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", - "integrity": "sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw==", - "dev": true, - "license": "MIT", - "dependencies": { - "parse5": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/parse5/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "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-scurry": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", - "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "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": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/piscina": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/piscina/-/piscina-5.2.0.tgz", - "integrity": "sha512-DszUCKeVN/5G5QKo6jAVHL8fmKnkJvQ0ACiVgY7YGCq3TUB2oznAOayvZPIAdEThvhczkXR+qm3IHsNXpFCYfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.x" - }, - "optionalDependencies": { - "@napi-rs/nice": "^1.0.4" - } - }, - "node_modules/pkce-challenge": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", - "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16.20.0" - } - }, - "node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-media-query-parser": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", - "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", - "dev": true, - "license": "MIT" - }, - "node_modules/postcss-safe-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/postcss-safe-parser/-/postcss-safe-parser-7.0.1.tgz", - "integrity": "sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss-safe-parser" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "engines": { - "node": ">=18.0" - }, - "peerDependencies": { - "postcss": "^8.4.31" - } - }, - "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/proc-log": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", - "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/proxy-agent-negotiate": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-agent-negotiate/-/proxy-agent-negotiate-1.1.0.tgz", - "integrity": "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20" - }, - "peerDependencies": { - "kerberos": "^2.0.0" - }, - "peerDependenciesMeta": { - "kerberos": { - "optional": true - } - } - }, - "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/qs": { - "version": "6.15.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", - "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/reflect-metadata": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", - "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/restore-cursor": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", - "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", - "dev": true, - "license": "MIT", - "dependencies": { - "onetime": "^7.0.0", - "signal-exit": "^4.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rolldown": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.0.tgz", - "integrity": "sha512-u7tgm5l4Yw1iTqUL4EcYOAt7fFvCgQMLeidrnD4GALlC6aOznCjezYajgxeyKw27u0Q5N7fwgCzjVyPIWzwuBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.140.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.2.0", - "@rolldown/binding-darwin-arm64": "1.2.0", - "@rolldown/binding-darwin-x64": "1.2.0", - "@rolldown/binding-freebsd-x64": "1.2.0", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.0", - "@rolldown/binding-linux-arm64-gnu": "1.2.0", - "@rolldown/binding-linux-arm64-musl": "1.2.0", - "@rolldown/binding-linux-ppc64-gnu": "1.2.0", - "@rolldown/binding-linux-s390x-gnu": "1.2.0", - "@rolldown/binding-linux-x64-gnu": "1.2.0", - "@rolldown/binding-linux-x64-musl": "1.2.0", - "@rolldown/binding-openharmony-arm64": "1.2.0", - "@rolldown/binding-wasm32-wasi": "1.2.0", - "@rolldown/binding-win32-arm64-msvc": "1.2.0", - "@rolldown/binding-win32-x64-msvc": "1.2.0" - } - }, - "node_modules/rolldown/node_modules/@oxc-project/types": { - "version": "0.140.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.140.0.tgz", - "integrity": "sha512-h5LUOzGArYemnW1NMz/DuuQhBi96J6JL2Bk8zE4kvqxB5Sg3jxmCiH4uyOWHDkiKSt5vWlG4FIwCR/DbstcNRQ==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/rxjs": { - "version": "7.8.2", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", - "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.1.0" - } - }, - "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/sass": { - "version": "1.101.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.101.0.tgz", - "integrity": "sha512-OL3GoQyoUdDt843DpVmDO6y2k1sc5IhUDSpu8XucEI+35neq5QivZ1iuegnpraEVTJXlQGK1gl27zKcTLEPbQw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^5.0.0", - "immutable": "^5.1.5", - "source-map-js": ">=0.6.2 <2.0.0" - }, - "bin": { - "sass": "sass.js" - }, - "engines": { - "node": ">=20.19.0" - }, - "optionalDependencies": { - "@parcel/watcher": "^2.4.1" - } - }, - "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.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, - "license": "ISC" - }, - "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/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sigstore": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-4.1.1.tgz", - "integrity": "sha512-endqECJkfhozrXMK5ngu/UAA0xVcVEFdnHJCElGaExypjW+HK5i6zu3NteLoaX/iFbRUbC3+DjttQs0GARr+5w==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.2.1", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.1.1", - "@sigstore/tuf": "^4.0.2", - "@sigstore/verify": "^3.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/slice-ansi": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", - "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.3", - "is-fullwidth-code-point": "^5.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/smart-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", - "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", - "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.1.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/socks-proxy-agent": { - "version": "8.0.5", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", - "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/source-map-support/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/spdx-exceptions": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", - "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", - "dev": true, - "license": "CC-BY-3.0" - }, - "node_modules/spdx-expression-parse": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", - "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.23", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", - "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/ssri": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.1.tgz", - "integrity": "sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==", - "dev": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/std-env": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", - "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/stdin-discarder": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", - "integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", - "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "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/tar": { - "version": "7.5.20", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.20.tgz", - "integrity": "sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", - "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.30" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" - }, - "node_modules/tuf-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-4.1.0.tgz", - "integrity": "sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "4.1.0", - "debug": "^4.4.3", - "make-fetch-happen": "^15.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "content-type": "^1.0.5", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "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/validate-npm-package-name": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz", - "integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/vite": { - "version": "8.1.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz", - "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.17", - "rolldown": "~1.1.5", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/vite/node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite/node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, - "node_modules/vitest": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", - "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.5", - "@vitest/mocker": "4.1.5", - "@vitest/pretty-format": "4.1.5", - "@vitest/runner": "4.1.5", - "@vitest/snapshot": "4.1.5", - "@vitest/spy": "4.1.5", - "@vitest/utils": "4.1.5", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.5", - "@vitest/browser-preview": "4.1.5", - "@vitest/browser-webdriverio": "4.1.5", - "@vitest/coverage-istanbul": "4.1.5", - "@vitest/coverage-v8": "4.1.5", - "@vitest/ui": "4.1.5", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "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/watchpack": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", - "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/weak-lru-cache": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", - "integrity": "sha512-DEAoo25RfSYMuTGc9vPJzZcZullwIqRDSI9LOy+fkCJPi6hykCnfKaXTuPBDuXAUcqHXyOgFtHNp/kB2FjYHbw==", - "dev": true, - "license": "MIT", - "optional": true - }, - "node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/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/wrap-ansi/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/wrap-ansi/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/wrap-ansi/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/wrap-ansi/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/wrap-ansi/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/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/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/yargs": { - "version": "18.0.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", - "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cliui": "^9.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "string-width": "^7.2.0", - "y18n": "^5.0.5", - "yargs-parser": "^22.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs-parser": { - "version": "22.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", - "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", - "dev": true, - "license": "ISC", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=23" - } - }, - "node_modules/yargs/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/yoctocolors-cjs": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", - "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.2", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", - "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", - "dev": true, - "license": "ISC", - "peerDependencies": { - "zod": "^3.25.28 || ^4" - } - } - } -} diff --git a/bigframes/display/table_widget_angular/package.json b/bigframes/display/table_widget_angular/package.json deleted file mode 100644 index 5008d5829e6..00000000000 --- a/bigframes/display/table_widget_angular/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "table-widget-angular", - "version": "0.0.0", - "scripts": { - "ng": "ng", - "start": "ng serve", - "build": "ng build", - "watch": "ng build --watch --configuration development", - "test": "ng test", - "build:widget": "ng build --output-hashing none && node bundle.js" - }, - "private": true, - "packageManager": "npm@11.7.0", - "dependencies": { - "@angular/common": "^22.1.0", - "@angular/compiler": "^22.1.0", - "@angular/core": "^22.1.0", - "@angular/forms": "^22.1.0", - "@angular/platform-browser": "^22.1.0", - "@angular/router": "^22.1.0", - "rxjs": "~7.8.0", - "tslib": "^2.3.0" - }, - "devDependencies": { - "@angular/build": "^22.1.2", - "@angular/cli": "^21.2.16", - "@angular/compiler-cli": "^22.1.0", - "esbuild": "^0.28.0", - "jsdom": "^28.0.0", - "prettier": "^3.8.1", - "typescript": "~5.9.2", - "vitest": "^4.0.8" - } -} diff --git a/bigframes/display/table_widget_angular/public/favicon.ico b/bigframes/display/table_widget_angular/public/favicon.ico deleted file mode 100644 index 57614f9c967..00000000000 Binary files a/bigframes/display/table_widget_angular/public/favicon.ico and /dev/null differ diff --git a/bigframes/display/table_widget_angular/src/app/app.spec.ts b/bigframes/display/table_widget_angular/src/app/app.spec.ts deleted file mode 100644 index 75ccf03e436..00000000000 --- a/bigframes/display/table_widget_angular/src/app/app.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { TestBed } from '@angular/core/testing'; -import { App } from './app'; - -describe('App', () => { - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [App], - providers: [{ provide: 'ANYWIDGET_MODEL', useValue: null }] - }).compileComponents(); - }); - - it('should create the app', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - expect(app).toBeTruthy(); - }); - - it('should render the table container', async () => { - const fixture = TestBed.createComponent(App); - fixture.detectChanges(); - const compiled = fixture.nativeElement as HTMLElement; - expect(compiled.querySelector('.table-container')).toBeTruthy(); - }); -}); diff --git a/bigframes/display/table_widget_angular/src/app/app.ts b/bigframes/display/table_widget_angular/src/app/app.ts deleted file mode 100644 index 60b94d30e78..00000000000 --- a/bigframes/display/table_widget_angular/src/app/app.ts +++ /dev/null @@ -1,704 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { Component, ElementRef, ViewChild, computed, effect, inject, signal } from '@angular/core'; -import { DomSanitizer } from '@angular/platform-browser'; -import { WidgetStateService } from './widget-state.service'; - -@Component({ - selector: '[app-root]', - standalone: true, - imports: [], - providers: [WidgetStateService], - template: ` -
- @if (errorMessage()) { -
{{ errorMessage() }}
- } - - @if (isDeferredMode()) { -
-
-

{{ dryRunInfo() }}

- -
-
- } @else { -
-
- -
- {{ rowCountText() }} - - - -
-
- - -
- -
- - -
-
-
- } -
- `, - styles: [` - /* Increase specificity to override framework styles without !important */ - .bigframes-widget.bigframes-widget { - /* Default Light Mode Variables */ - --bf-bg: white; - --bf-border-color: #ccc; - --bf-error-bg: #fbe; - --bf-error-border: red; - --bf-error-fg: black; - --bf-fg: black; - --bf-header-bg: #f5f5f5; - --bf-null-fg: gray; - --bf-row-even-bg: #f5f5f5; - --bf-row-odd-bg: white; - - background-color: var(--bf-bg); - box-sizing: border-box; - color: var(--bf-fg); - display: flex; - flex-direction: column; - font-family: - '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', sans-serif; - margin: 0; - padding: 0; - width: 100%; - } - - .bigframes-widget * { - box-sizing: border-box; - } - - /* Dark Mode Overrides */ - @media (prefers-color-scheme: dark) { - .bigframes-widget.bigframes-widget { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; - } - } - - .bigframes-widget.bigframes-dark-mode.bigframes-dark-mode { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; - } - - .bigframes-widget .table-container { - background-color: var(--bf-bg); - margin: 0; - overflow: auto; - padding: 0; - } - - .bigframes-widget .footer { - align-items: center; - background-color: var(--bf-bg); - color: var(--bf-fg); - display: flex; - 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 .settings { - align-items: center; - display: flex; - flex-direction: row; - gap: 16px; - justify-content: end; - } - - .bigframes-widget .page-size, - .bigframes-widget .max-columns { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - } - - .bigframes-widget .page-size label, - .bigframes-widget .max-columns label { - margin-right: 8px; - } - - /* Dynamic internal elements styles */ - .bigframes-widget ::ng-deep table.bigframes-widget-table, - .bigframes-widget ::ng-deep table.dataframe { - background-color: var(--bf-bg); - border: 1px solid var(--bf-border-color); - border-collapse: collapse; - border-spacing: 0; - box-shadow: none; - color: var(--bf-fg); - margin: 0; - outline: none; - text-align: left; - width: auto; - } - - .bigframes-widget ::ng-deep tr { - border: none; - } - - .bigframes-widget ::ng-deep th { - background-color: var(--bf-header-bg); - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0; - position: sticky; - text-align: left; - top: 0; - z-index: 1; - } - - .bigframes-widget ::ng-deep td { - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0.5em; - } - - .bigframes-widget ::ng-deep table tbody tr:nth-child(odd), - .bigframes-widget ::ng-deep table tbody tr:nth-child(odd) td { - background-color: var(--bf-row-odd-bg); - } - - .bigframes-widget ::ng-deep table tbody tr:nth-child(even), - .bigframes-widget ::ng-deep table tbody tr:nth-child(even) td { - background-color: var(--bf-row-even-bg); - } - - .bigframes-widget ::ng-deep .bf-header-content { - box-sizing: border-box; - height: 100%; - overflow: auto; - padding: 0.5em; - resize: horizontal; - width: 100%; - } - - .bigframes-widget ::ng-deep th .sort-indicator { - padding-left: 4px; - visibility: hidden; - } - - .bigframes-widget ::ng-deep th:hover .sort-indicator { - visibility: visible; - } - - .bigframes-widget button { - background-color: transparent; - border: 1px solid currentColor; - border-radius: 4px; - color: inherit; - cursor: pointer; - display: inline-block; - padding: 2px 8px; - 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: var(--bf-error-bg); - border: 1px solid var(--bf-error-border); - border-radius: 4px; - color: var(--bf-error-fg); - font-size: 14px; - margin-bottom: 8px; - padding: 8px; - } - - .bigframes-widget ::ng-deep .cell-align-right { - text-align: right; - } - - .bigframes-widget ::ng-deep .cell-align-left { - text-align: left; - } - - .bigframes-widget ::ng-deep .null-value { - color: var(--bf-null-fg); - } - - .bigframes-widget ::ng-deep .debug-info { - border-top: 1px solid var(--bf-border-color); - } - - .bigframes-widget .deferred-container { - align-items: center; - display: flex; - justify-content: center; - min-height: 220px; - padding: 24px; - width: 100%; - } - - .bigframes-widget .deferred-card { - background: linear-gradient( - 135deg, - rgba(255, 255, 255, 0.6), - rgba(255, 255, 255, 0.3) - ); - border: 1px solid rgba(255, 255, 255, 0.4); - border-radius: 16px; - box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07); - display: flex; - flex-direction: column; - gap: 16px; - max-width: 500px; - padding: 32px; - text-align: center; - transition: all 0.3s ease-in-out; - } - - .bigframes-widget.bigframes-dark-mode .deferred-card { - background: linear-gradient( - 135deg, - rgba(32, 33, 36, 0.6), - rgba(32, 33, 36, 0.3) - ); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); - } - - @media (prefers-color-scheme: dark) { - .bigframes-widget .deferred-card { - background: linear-gradient( - 135deg, - rgba(32, 33, 36, 0.6), - rgba(32, 33, 36, 0.3) - ); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3); - } - } - - .bigframes-widget .deferred-title { - font-size: 1.1rem; - font-weight: 600; - margin: 0; - } - - .bigframes-widget .deferred-estimate { - color: var(--bf-null-fg); - font-size: 0.9rem; - margin: 0; - } - - .bigframes-widget .run-query-button { - align-items: center; - background-color: var(--bf-fg); - border: 1px solid var(--bf-fg); - border-radius: 8px; - color: var(--bf-bg); - cursor: pointer; - display: inline-flex; - font-size: 14px; - font-weight: 600; - gap: 8px; - justify-content: center; - padding: 10px 20px; - transition: transform 0.20s ease, opacity 0.20s ease; - } - - .bigframes-widget .run-query-button:hover { - opacity: 0.90; - transform: translateY(-1px); - } - - .bigframes-widget .run-query-button:active { - transform: translateY(0); - } - - .bigframes-widget .run-query-button:disabled { - cursor: not-allowed; - opacity: 0.60; - } - - .bigframes-widget .spinner { - animation: spin 1s linear infinite; - border: 2px solid currentColor; - border-radius: 50%; - border-top-color: transparent; - display: inline-block; - height: 12px; - width: 12px; - } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - `] -}) -export class App { - protected readonly state = inject(WidgetStateService); - private readonly sanitizer = inject(DomSanitizer); - - protected readonly maxColumnOptions = [5, 10, 15, 20, 0]; - protected readonly pageSizeOptions = [10, 25, 50, 100]; - - // State signals - protected readonly errorMessage = this.state.errorMessage; - protected readonly maxColumns = this.state.maxColumns; - protected readonly pageSize = this.state.pageSize; - protected readonly page = this.state.page; - protected readonly rowCount = this.state.rowCount; - protected readonly isDeferredMode = this.state.isDeferredMode; - protected readonly dryRunInfo = this.state.dryRunInfo; - protected readonly isLoading = signal(false); - - // Computed properties for formatting and display states - protected readonly sanitizedHtml = computed(() => - this.sanitizer.bypassSecurityTrustHtml(this.state.tableHtml()) - ); - - protected readonly totalPages = computed(() => { - const count = this.rowCount(); - const size = this.pageSize(); - return count !== null && size > 0 ? Math.ceil(count / size) : null; - }); - - protected readonly pageIndicatorText = computed(() => { - const currentPage = this.page(); - const count = this.rowCount(); - const total = this.totalPages(); - const currentStr = (currentPage + 1).toLocaleString(); - const totalStr = (total ?? 1).toLocaleString(); - return `Page ${currentStr} of ${totalStr}`; - }); - - protected readonly rowCountText = computed(() => { - const count = this.rowCount(); - if (count === null) { - return 'Total rows unknown'; - } - if (count === 0) { - return '0 total rows'; - } - return `${count.toLocaleString()} total rows`; - }); - - protected readonly prevPageDisabled = computed(() => this.page() === 0); - - protected readonly nextPageDisabled = computed(() => { - const currentPage = this.page(); - const count = this.rowCount(); - const total = this.totalPages(); - if (count === null) { - return false; - } - if (count === 0) { - return true; - } - return total !== null && currentPage >= total - 1; - }); - - protected readonly isDarkMode = signal(false); - private themeObserver: MutationObserver | null = null; - - @ViewChild('tableContainer') - tableContainerRef!: ElementRef; - - private isHeightInitialized = false; - - constructor() { - effect(() => { - // Setup dependencies for reactive effect - const _html = this.state.tableHtml(); - const _sort = this.state.sortContext(); - const _orderable = this.state.orderableColumns(); - const deferred = this.isDeferredMode(); - if (deferred) { - this.isHeightInitialized = false; - } - - // Schedule DOM post-processing once the innerHTML render completes - setTimeout(() => { - this.applySortIndicators(); - this.lockInitialHeight(); - }, 0); - }); - - effect(() => { - if (!this.state.startExecution()) { - this.isLoading.set(false); - } - }); - - effect((onCleanup) => { - const executing = this.state.startExecution(); - if (executing) { - const intervalId = setInterval(() => { - if (this.state.startExecution()) { - const currentPing = this.state.ping(); - this.state.setPing(currentPing + 1); - } else { - clearInterval(intervalId); - } - }, 500); - onCleanup(() => { - clearInterval(intervalId); - }); - } - }); - } - - ngOnInit() { - this.initThemeDetection(); - } - - ngOnDestroy() { - this.themeObserver?.disconnect(); - } - - protected handleRunQuery() { - this.isLoading.set(true); - this.state.setStartExecution(true); - } - - protected handlePageChange(direction: number) { - const nextPage = this.page() + direction; - this.state.setPage(nextPage); - } - - protected handlePageSizeChange(event: Event) { - const select = event.target as HTMLSelectElement; - const newSize = Number(select.value); - if (newSize) { - this.state.setPageSize(newSize); - } - } - - protected handleMaxColumnsChange(event: Event) { - const select = event.target as HTMLSelectElement; - const maxCols = Number(select.value); - this.state.setMaxColumns(maxCols); - } - - protected handleTableClick(event: MouseEvent) { - const target = event.target as HTMLElement; - const header = target.closest('th'); - if (!header) return; - - const headerDiv = header.querySelector( - 'div.bf-header-content' - ) as HTMLElement | null; - if (!headerDiv) return; - - const columnName = this.getColumnName(headerDiv); - const sortableColumns = this.state.orderableColumns(); - if (!columnName || !sortableColumns.includes(columnName)) return; - - const currentSortContext = [...this.state.sortContext()]; - const sortIndex = currentSortContext.findIndex( - (item) => item.column === columnName - ); - let newContext = [...currentSortContext]; - - if (event.shiftKey) { - if (sortIndex !== -1) { - // Toggle: Asc -> Desc -> Unsorted - if (newContext[sortIndex].ascending) { - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false - }; - } else { - newContext.splice(sortIndex, 1); - } - } else { - newContext.push({ column: columnName, ascending: true }); - } - } else { - // Single column sort mode - if (sortIndex !== -1 && newContext.length === 1) { - // Toggle: Asc -> Desc -> Unsorted - if (newContext[sortIndex].ascending) { - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false - }; - } else { - newContext = []; - } - } else { - newContext = [{ column: columnName, ascending: true }]; - } - } - - this.state.setSortContext(newContext); - } - - private getColumnName(headerDiv: HTMLElement): string { - const clone = headerDiv.cloneNode(true) as HTMLElement; - clone.querySelector('.sort-indicator')?.remove(); - return clone.textContent?.trim() || ''; - } - - private applySortIndicators() { - const container = this.tableContainerRef?.nativeElement; - if (!container) return; - - const sortableColumns = this.state.orderableColumns(); - const currentSortContext = this.state.sortContext() || []; - - const getSortIndex = (colName: string) => - currentSortContext.findIndex((item) => item.column === colName); - - const headers = container.querySelectorAll('th'); - headers.forEach((header: HTMLElement) => { - const headerDiv = header.querySelector( - 'div.bf-header-content' - ) as HTMLElement | null; - if (!headerDiv) return; - - const columnName = this.getColumnName(headerDiv); - if (columnName && sortableColumns.includes(columnName)) { - - let indicatorSpan = headerDiv.querySelector( - '.sort-indicator' - ) as HTMLElement; - if (!indicatorSpan) { - indicatorSpan = document.createElement('span'); - indicatorSpan.classList.add('sort-indicator'); - indicatorSpan.style.paddingLeft = '5px'; - headerDiv.appendChild(indicatorSpan); - } - - const sortIndex = getSortIndex(columnName); - if (sortIndex !== -1) { - const isAscending = currentSortContext[sortIndex].ascending; - indicatorSpan.textContent = isAscending ? '▲' : '▼'; - indicatorSpan.style.visibility = 'visible'; - } else { - indicatorSpan.textContent = '●'; - indicatorSpan.style.visibility = 'hidden'; - } - } - }); - } - - private lockInitialHeight() { - if (this.isHeightInitialized) return; - const container = this.tableContainerRef?.nativeElement; - if (!container) return; - - const table = container.querySelector('table'); - if (table && (table as HTMLElement).offsetHeight > 0) { - const currentHeight = container.offsetHeight; - if (currentHeight > 0) { - container.style.height = `${currentHeight}px`; - this.isHeightInitialized = true; - } - } - } - - private initThemeDetection() { - this.updateTheme(); - const observer = new MutationObserver(() => this.updateTheme()); - observer.observe(document.body, { - attributes: true, - attributeFilter: ['class', 'data-theme', 'data-vscode-theme-kind'], - }); - this.themeObserver = observer; - } - - private updateTheme() { - const body = document.body; - const isDark = - body.classList.contains('vscode-dark') || - body.classList.contains('theme-dark') || - body.dataset['theme'] === 'dark' || - body.getAttribute('data-vscode-theme-kind') === 'vscode-dark'; - this.isDarkMode.set(isDark); - } -} diff --git a/bigframes/display/table_widget_angular/src/app/widget-state.service.spec.ts b/bigframes/display/table_widget_angular/src/app/widget-state.service.spec.ts deleted file mode 100644 index 563f9fa75a5..00000000000 --- a/bigframes/display/table_widget_angular/src/app/widget-state.service.spec.ts +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { TestBed } from '@angular/core/testing'; -import { vi } from 'vitest'; -import { WidgetStateService } from './widget-state.service'; - -describe('WidgetStateService', () => { - let service: WidgetStateService; - let mockModel: any; - let mockListeners: { [key: string]: Function }; - - beforeEach(() => { - mockListeners = {}; - mockModel = { - get: vi.fn().mockImplementation((prop: string) => { - if (prop === 'page') return 2; - if (prop === 'page_size') return 25; - if (prop === 'max_columns') return 10; - if (prop === 'row_count') return 150; - if (prop === 'table_html') return '
'; - if (prop === 'sort_context') { - return [{ column: 'col1', ascending: true }]; - } - if (prop === 'orderable_columns') { - return ['col1', 'col2']; - } - if (prop === 'error_message') return 'initial error'; - return null; - }), - set: vi.fn(), - save_changes: vi.fn(), - on: vi.fn().mockImplementation( - (event: string, callback: Function) => { - mockListeners[event] = callback; - } - ) - }; - - TestBed.configureTestingModule({ - providers: [ - WidgetStateService, - { provide: 'ANYWIDGET_MODEL', useValue: mockModel } - ] - }); - service = TestBed.inject(WidgetStateService); - }); - - it('should be created', () => { - expect(service).toBeTruthy(); - }); - - it('should initialize signals from model values', () => { - expect(service.page()).toBe(2); - expect(service.pageSize()).toBe(25); - expect(service.maxColumns()).toBe(10); - expect(service.rowCount()).toBe(150); - expect(service.tableHtml()).toBe('
'); - expect(service.sortContext()).toEqual([ - { column: 'col1', ascending: true } - ]); - expect(service.orderableColumns()).toEqual(['col1', 'col2']); - expect(service.errorMessage()).toBe('initial error'); - }); - - it('should update signals when model triggers change events', () => { - mockModel.get.mockImplementation((prop: string) => { - if (prop === 'page') return 5; - if (prop === 'page_size') return 50; - return null; - }); - - mockListeners['change:page'](); - mockListeners['change:page_size'](); - - expect(service.page()).toBe(5); - expect(service.pageSize()).toBe(50); - }); - - it('should support dual-listen pattern for error messages', () => { - // 1. Check error_message change - mockModel.get.mockImplementation((prop: string) => { - if (prop === 'error_message') return 'new error'; - return null; - }); - mockListeners['change:error_message'](); - expect(service.errorMessage()).toBe('new error'); - - // 2. Check _error_message change - mockModel.get.mockImplementation((prop: string) => { - if (prop === '_error_message') return 'new private error'; - return null; - }); - mockListeners['change:_error_message'](); - expect(service.errorMessage()).toBe('new private error'); - }); - - it('should write updates back to model on setter methods', () => { - service.setPage(4); - expect(mockModel.set).toHaveBeenCalledWith('page', 4); - expect(mockModel.save_changes).toHaveBeenCalled(); - - service.setPageSize(100); - expect(mockModel.set).toHaveBeenCalledWith('page_size', 100); - expect(mockModel.set).toHaveBeenCalledWith('page', 0); - - service.setMaxColumns(15); - expect(mockModel.set).toHaveBeenCalledWith('max_columns', 15); - - service.setSortContext([{ column: 'col2', ascending: false }]); - expect(mockModel.set).toHaveBeenCalledWith( - 'sort_context', - [{ column: 'col2', ascending: false }] - ); - }); -}); diff --git a/bigframes/display/table_widget_angular/src/app/widget-state.service.ts b/bigframes/display/table_widget_angular/src/app/widget-state.service.ts deleted file mode 100644 index 54eff6eb948..00000000000 --- a/bigframes/display/table_widget_angular/src/app/widget-state.service.ts +++ /dev/null @@ -1,157 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { Injectable, Inject, signal } from '@angular/core'; - -export interface SortItem { - column: string; - ascending: boolean; -} - -@Injectable() -export class WidgetStateService { - readonly page = signal(0); - readonly pageSize = signal(10); - readonly maxColumns = signal(0); - readonly rowCount = signal(null); - readonly tableHtml = signal(''); - readonly sortContext = signal([]); - readonly orderableColumns = signal([]); - readonly errorMessage = signal(null); - readonly startExecution = signal(false); - readonly isDeferredMode = signal(false); - readonly dryRunInfo = signal(''); - readonly ping = signal(0); - - constructor(@Inject('ANYWIDGET_MODEL') private model: any) { - if (model) { - // Initialize from the model - this.page.set(model.get('page') ?? 0); - this.pageSize.set(model.get('page_size') ?? 10); - this.maxColumns.set(model.get('max_columns') ?? 0); - this.rowCount.set(model.get('row_count') ?? null); - this.tableHtml.set(model.get('table_html') ?? ''); - this.sortContext.set(model.get('sort_context') ?? []); - this.orderableColumns.set(model.get('orderable_columns') ?? []); - const initialError = - model.get('error_message') ?? - model.get('_error_message') ?? - null; - this.errorMessage.set(initialError); - this.startExecution.set(model.get('start_execution') ?? false); - this.isDeferredMode.set(model.get('is_deferred_mode') ?? false); - this.dryRunInfo.set(model.get('dry_run_info') ?? ''); - this.ping.set(model.get('ping') ?? 0); - - // Register event listeners for anywidget updates - model.on('change:page', () => { - this.page.set(model.get('page')); - }); - model.on('change:page_size', () => { - this.pageSize.set(model.get('page_size')); - }); - model.on('change:max_columns', () => { - this.maxColumns.set(model.get('max_columns')); - }); - model.on('change:row_count', () => { - this.rowCount.set(model.get('row_count')); - }); - model.on('change:table_html', () => { - this.tableHtml.set(model.get('table_html')); - }); - model.on('change:sort_context', () => { - this.sortContext.set(model.get('sort_context')); - }); - model.on('change:orderable_columns', () => { - this.orderableColumns.set(model.get('orderable_columns')); - }); - model.on('change:start_execution', () => { - this.startExecution.set(model.get('start_execution') ?? false); - }); - model.on('change:is_deferred_mode', () => { - this.isDeferredMode.set(model.get('is_deferred_mode') ?? false); - }); - model.on('change:dry_run_info', () => { - this.dryRunInfo.set(model.get('dry_run_info') ?? ''); - }); - model.on('change:ping', () => { - this.ping.set(model.get('ping') ?? 0); - }); - - // Robust dual-listen pattern for error messages (with/without underscore) - const handleErrorChange = () => { - const err = - model.get('error_message') ?? - model.get('_error_message') ?? - null; - this.errorMessage.set(err); - }; - model.on('change:error_message', handleErrorChange); - model.on('change:_error_message', handleErrorChange); - } - } - - setPage(page: number) { - this.page.set(page); - if (this.model) { - this.model.set('page', page); - this.model.save_changes(); - } - } - - setPageSize(pageSize: number) { - this.pageSize.set(pageSize); - this.page.set(0); - if (this.model) { - this.model.set('page_size', pageSize); - // Reset to page 0 on page size change - this.model.set('page', 0); - this.model.save_changes(); - } - } - - setMaxColumns(maxColumns: number) { - this.maxColumns.set(maxColumns); - if (this.model) { - this.model.set('max_columns', maxColumns); - this.model.save_changes(); - } - } - - setSortContext(context: SortItem[]) { - this.sortContext.set(context); - if (this.model) { - this.model.set('sort_context', context); - this.model.save_changes(); - } - } - - setStartExecution(startExecution: boolean) { - this.startExecution.set(startExecution); - if (this.model) { - this.model.set('start_execution', startExecution); - this.model.save_changes(); - } - } - - setPing(ping: number) { - this.ping.set(ping); - if (this.model) { - this.model.set('ping', ping); - this.model.save_changes(); - } - } -} diff --git a/bigframes/display/table_widget_angular/src/index.html b/bigframes/display/table_widget_angular/src/index.html deleted file mode 100644 index f5dda01b48a..00000000000 --- a/bigframes/display/table_widget_angular/src/index.html +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - TableWidgetAngular - - - - - -
- - diff --git a/bigframes/display/table_widget_angular/src/main.ts b/bigframes/display/table_widget_angular/src/main.ts deleted file mode 100644 index 3d515bb3d34..00000000000 --- a/bigframes/display/table_widget_angular/src/main.ts +++ /dev/null @@ -1,43 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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 { createApplication } from '@angular/platform-browser'; -import { App } from './app/app'; -import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZonelessChangeDetection } from '@angular/core'; - -function render({ model, el }: { model: any, el: HTMLElement }) { - // Create a container for the Angular app - const appRoot = document.createElement('div'); - appRoot.setAttribute('app-root', ''); - el.appendChild(appRoot); - - const appConfig: ApplicationConfig = { - providers: [ - provideBrowserGlobalErrorListeners(), - provideZonelessChangeDetection(), - { provide: 'ANYWIDGET_MODEL', useValue: model } - ] - }; - - createApplication(appConfig) - .then((appRef) => { - appRef.bootstrap(App, appRoot); - appRoot.removeAttribute('app-root'); - }) - .catch((err) => console.error(err)); -} - -export default { render }; diff --git a/bigframes/display/table_widget_angular/src/styles.css b/bigframes/display/table_widget_angular/src/styles.css deleted file mode 100644 index 95b248dae0a..00000000000 --- a/bigframes/display/table_widget_angular/src/styles.css +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/* You can add global styles to this file, and also import other style files */ diff --git a/bigframes/display/table_widget_angular/tsconfig.app.json b/bigframes/display/table_widget_angular/tsconfig.app.json deleted file mode 100644 index 264f459bf87..00000000000 --- a/bigframes/display/table_widget_angular/tsconfig.app.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/app", - "types": [] - }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] -} diff --git a/bigframes/display/table_widget_angular/tsconfig.json b/bigframes/display/table_widget_angular/tsconfig.json deleted file mode 100644 index 2ab7442758f..00000000000 --- a/bigframes/display/table_widget_angular/tsconfig.json +++ /dev/null @@ -1,33 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "compileOnSave": false, - "compilerOptions": { - "strict": true, - "noImplicitOverride": true, - "noPropertyAccessFromIndexSignature": true, - "noImplicitReturns": true, - "noFallthroughCasesInSwitch": true, - "skipLibCheck": true, - "isolatedModules": true, - "experimentalDecorators": true, - "importHelpers": true, - "target": "ES2022", - "module": "preserve" - }, - "angularCompilerOptions": { - "enableI18nLegacyMessageIdFormat": false, - "strictInjectionParameters": true, - "strictInputAccessModifiers": true, - "strictTemplates": true - }, - "files": [], - "references": [ - { - "path": "./tsconfig.app.json" - }, - { - "path": "./tsconfig.spec.json" - } - ] -} diff --git a/bigframes/display/table_widget_angular/tsconfig.spec.json b/bigframes/display/table_widget_angular/tsconfig.spec.json deleted file mode 100644 index d38370633f6..00000000000 --- a/bigframes/display/table_widget_angular/tsconfig.spec.json +++ /dev/null @@ -1,15 +0,0 @@ -/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ -/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "outDir": "./out-tsc/spec", - "types": [ - "vitest/globals" - ] - }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] -} diff --git a/bigframes/dtypes.py b/bigframes/dtypes.py index 3cc7e918aa0..ef1b9e78716 100644 --- a/bigframes/dtypes.py +++ b/bigframes/dtypes.py @@ -14,12 +14,11 @@ """Mappings for Pandas dtypes supported by BigQuery DataFrames package""" +from dataclasses import dataclass import datetime import decimal import textwrap import typing -import warnings -from dataclasses import dataclass from typing import Any, Dict, List, Literal, Sequence, Union import bigframes_vendored.constants as constants @@ -31,8 +30,6 @@ import pyarrow as pa import shapely.geometry # type: ignore -import bigframes.exceptions - # Type hints for Pandas dtypes supported by BigQuery DataFrame Dtype = Union[ pd.BooleanDtype, @@ -65,8 +62,7 @@ # No arrow equivalent GEO_DTYPE = gpd.array.GeometryDtype() # JSON -# TODO(https://github.com/pandas-dev/pandas/issues/60958): switch to -# pyarrow.json_(pyarrow.string()) when pandas 3+ and pyarrow 18+ is installed. +# TODO: switch to pyarrow.json_(pyarrow.string()) when available. JSON_ARROW_TYPE = db_dtypes.JSONArrowType() JSON_DTYPE = pd.ArrowDtype(JSON_ARROW_TYPE) OBJ_REF_DTYPE = pd.ArrowDtype( @@ -117,21 +113,6 @@ ] LOCAL_SCALAR_TYPES = typing.get_args(LOCAL_SCALAR_TYPE) -SUPPORTED_LITERAL_TYPE = typing.Union[ - bytes, - str, - int, - bool, - float, - datetime.datetime, - datetime.date, - datetime.time, - decimal.Decimal, - list, - shapely.geometry.base.BaseGeometry, -] -SUPPORTED_LITERAL_TYPES = typing.get_args(SUPPORTED_LITERAL_TYPE) - # Will have a few dtype variants: simple(eg. int, string, bool), complex (eg. list, struct), and virtual (eg. micro intervals, categorical) @dataclass(frozen=True) @@ -354,40 +335,14 @@ 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 -def is_json_encoding_type(type_: ExpressionType, strict: bool = False) -> bool: +def is_json_encoding_type(type_: ExpressionType) -> bool: # Types can be converted into JSON. # https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#json_encodings - if is_array_like(type_): - return is_json_encoding_type(get_array_inner_type(type_), strict=strict) - if is_struct_like(type_): - return all( - is_json_encoding_type(field_type, strict=strict) - for field_type in get_struct_fields(type_).values() - ) - - if strict: - # Strict are the types (mostly) defined by json spec, with no/minimal - # encoding/decoding involved. So no temporal types. - return type_ in ( - INT_DTYPE, - FLOAT_DTYPE, - BOOL_DTYPE, - STRING_DTYPE, - JSON_DTYPE, - ) - else: - # GoogleSQL implementation handles anything but GEO - return type_ != GEO_DTYPE + return type_ != GEO_DTYPE def is_numeric(type_: ExpressionType, include_bool: bool = True) -> bool: @@ -403,24 +358,13 @@ def is_comparable(type_: ExpressionType) -> bool: return (type_ is not None) and is_orderable(type_) -def can_compare(type1: ExpressionType, type2: ExpressionType) -> bool: - try: - coerced_type = coerce_to_common(type1, type2) - return is_comparable(coerced_type) - except TypeError: - return False - - 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] = {} - - # Local import to break circular dependency with core.backports - import bigframes.core.backports - - for field in bigframes.core.backports.pyarrow_struct_type_fields(struct_type): + for field_no in range(struct_type.num_fields): + field = struct_type.field(field_no) result[field.name] = arrow_dtype_to_bigframes_dtype(field.type) return result @@ -468,12 +412,7 @@ def is_clusterable(type_: ExpressionType) -> bool: def is_bool_coercable(type_: ExpressionType) -> bool: # TODO: Implement more bool coercions - return ( - (type_ is None) - or is_numeric(type_) - or is_string_like(type_) - or is_array_like(type_) - ) + return (type_ is None) or is_numeric(type_) or is_string_like(type_) BIGFRAMES_STRING_TO_BIGFRAMES: Dict[DtypeString, Dtype] = { @@ -562,10 +501,6 @@ def arrow_dtype_to_bigframes_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}" @@ -599,42 +534,15 @@ def bigframes_dtype_to_arrow_dtype( ) -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) - - # Local import to break circular dependency with core.backports - import bigframes.core.backports - - 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( arrow_type: pa.DataType, ) -> Any: """Create a representative literal value for an arrow type.""" if pa.types.is_list(arrow_type): return [arrow_type_to_literal(arrow_type.value_type)] - - # Local import to break circular dependency with core.backports - import bigframes.core.backports - if pa.types.is_struct(arrow_type): return { - field.name: arrow_type_to_literal(field.type) - for field in bigframes.core.backports.pyarrow_struct_type_fields(arrow_type) + field.name: arrow_type_to_literal(field.type) for field in arrow_type.fields } if pa.types.is_string(arrow_type): return "string" @@ -733,9 +641,6 @@ 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""" @@ -747,9 +652,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"), - pandas.ArrowDtype(pa.date32()), pandas.ArrowDtype(pa.time64("us")), - pandas.ArrowDtype(pa.timestamp("us")), - pandas.ArrowDtype(pa.timestamp("us", tz="UTC")). + pd.ArrowDtype(pa.date32()), pd.ArrowDtype(pa.time64("us")), + pd.ArrowDtype(pa.timestamp("us")), + pd.ArrowDtype(pa.timestamp("us", tz="UTC")). {constants.FEEDBACK_LINK} """ ) @@ -760,6 +665,11 @@ 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) if pd.api.types.is_dict_like(literal): fields = [] for key in literal.keys(): @@ -770,10 +680,6 @@ def infer_literal_type(literal) -> typing.Optional[Dtype]: pa.field(key, field_type, nullable=(not pa.types.is_list(field_type))) ) return pd.ArrowDtype(pa.struct(fields)) - if pd.api.types.is_list_like(literal): - element_types = [infer_literal_type(i) for i in literal] - common_type = lcd_type(*element_types) - return list_type(common_type) if pd.isna(literal): return None # Null value without a definite type # Make sure to check datetime before date as datetimes are also dates @@ -808,13 +714,6 @@ def convert_schema_field( ) -> typing.Tuple[str, Dtype]: is_repeated = field.mode == "REPEATED" if field.field_type == "RECORD": - if field.description == OBJ_REF_DESCRIPTION_TAG: - bf_dtype = OBJ_REF_DTYPE # type: ignore - if is_repeated: - pa_type = pa.list_(bigframes_dtype_to_arrow_dtype(bf_dtype)) - bf_dtype = pd.ArrowDtype(pa_type) - return field.name, bf_dtype - mapped_fields = map(convert_schema_field, field.fields) fields = [] for name, dtype in mapped_fields: @@ -858,14 +757,10 @@ def convert_to_schema_field( ) 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, - description=inner_field.description, + name, inner_field.field_type, mode="REPEATED", fields=inner_field.fields ) if pa.types.is_struct(bigframes_dtype.pyarrow_dtype): - inner_fields: list[google.cloud.bigquery.SchemaField] = [] + inner_fields: list[pa.Field] = [] struct_type = typing.cast(pa.StructType, bigframes_dtype.pyarrow_dtype) for i in range(struct_type.num_fields): field = struct_type.field(i) @@ -874,14 +769,6 @@ def convert_to_schema_field( convert_to_schema_field(field.name, inner_bf_type, overrides) ) - if bigframes_dtype == OBJ_REF_DTYPE: - return google.cloud.bigquery.SchemaField( - name, - "RECORD", - fields=inner_fields, - description=OBJ_REF_DESCRIPTION_TAG, - ) - return google.cloud.bigquery.SchemaField( name, "RECORD", fields=inner_fields ) @@ -896,7 +783,7 @@ def convert_to_schema_field( def bf_type_from_type_kind( - bq_schema: Sequence[google.cloud.bigquery.SchemaField], + bq_schema: list[google.cloud.bigquery.SchemaField], ) -> typing.Dict[str, Dtype]: """Converts bigquery sql type to the default bigframes dtype.""" return {name: dtype for name, dtype in map(convert_schema_field, bq_schema)} @@ -970,16 +857,11 @@ def is_compatible(scalar: typing.Any, dtype: Dtype) -> typing.Optional[Dtype]: def lcd_type(*dtypes: Dtype) -> Dtype: if len(dtypes) < 1: raise ValueError("at least one dypes should be provided") - + if len(dtypes) == 1: + return dtypes[0] unique_dtypes = set(dtypes) - if None in unique_dtypes: - unique_dtypes.remove(None) - - if len(unique_dtypes) == 0: - return None if len(unique_dtypes) == 1: - return next(iter(unique_dtypes)) - + return unique_dtypes.pop() # Implicit conversion currently only supported for numeric types hierarchy: list[Dtype] = [ BOOL_DTYPE, @@ -988,9 +870,9 @@ def lcd_type(*dtypes: Dtype) -> Dtype: BIGNUMERIC_DTYPE, FLOAT_DTYPE, ] - if any([dtype not in hierarchy for dtype in unique_dtypes]): + if any([dtype not in hierarchy for dtype in dtypes]): return None - lcd_index = max([hierarchy.index(dtype) for dtype in unique_dtypes]) + lcd_index = max([hierarchy.index(dtype) for dtype in dtypes]) return hierarchy[lcd_index] @@ -1026,44 +908,3 @@ def lcd_type_or_throw(dtype1: Dtype, dtype2: Dtype) -> Dtype: TIMEDELTA_DESCRIPTION_TAG = "#microseconds" -OBJ_REF_DESCRIPTION_TAG = "bigframes_dtype: OBJ_REF_DTYPE" - - -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): - # Local import to break circular dependency with core.backports - import bigframes.core.backports - - 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 3aaf6020206..fd7b5545bb2 100644 --- a/bigframes/enums.py +++ b/bigframes/enums.py @@ -16,11 +16,12 @@ # NOTE: This module should not depend on any others in the package. + import enum class OrderingMode(enum.Enum): - """Values used to determine the ordering mode. + """[Preview] Values used to determine the ordering mode. Default is 'strict'. """ @@ -36,6 +37,5 @@ 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 dea8a55f9b5..8236a1a2f66 100644 --- a/bigframes/exceptions.py +++ b/bigframes/exceptions.py @@ -30,7 +30,7 @@ class UnknownLocationWarning(Warning): class CleanupFailedWarning(Warning): - """Bigframes failed to clean up a table or function resource.""" + """Bigframes failed to clean up a table resource.""" class DefaultIndexWarning(Warning): @@ -75,10 +75,6 @@ class MaximumResultRowsExceeded(RuntimeError): """Maximum number of rows in the result was exceeded.""" -class TranspilationError(RuntimeError): - """Failed to transpile a Python function to BigFrames Expression.""" - - class TimeTravelDisabledWarning(Warning): """A query was reattempted without time travel.""" @@ -88,11 +84,7 @@ class TimeTravelCacheWarning(Warning): class AmbiguousWindowWarning(Warning): - """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. - """ + """A query may produce nondeterministic results as the window may be ambiguously ordered.""" class UnknownDataTypeWarning(Warning): @@ -115,10 +107,6 @@ 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.""" @@ -130,14 +118,8 @@ class FunctionPackageVersionWarning(PreviewWarning): """ -class PythonTranspilerPreviewWarning(PreviewWarning): - """Python Transpiler is a preview feature.""" - - def format_message(message: str, fill: bool = True): - """[Private] Formats a warning message. - - :meta private: + """Formats a warning message with ANSI color codes for the warning color. Args: message: The warning message string. diff --git a/bigframes/extensions/__init__.py b/bigframes/extensions/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/bigframes/extensions/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/extensions/bigframes/__init__.py b/bigframes/extensions/bigframes/__init__.py deleted file mode 100644 index 439a8189ded..00000000000 --- a/bigframes/extensions/bigframes/__init__.py +++ /dev/null @@ -1,27 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.extensions.bigframes.dataframe_accessor import ( - BigframesAIAccessor, - BigframesBigQueryDataFrameAccessor, -) -from bigframes.extensions.bigframes.series_accessor import ( - BigframesBigQuerySeriesAccessor, -) - -__all__ = [ - "BigframesAIAccessor", - "BigframesBigQueryDataFrameAccessor", - "BigframesBigQuerySeriesAccessor", -] diff --git a/bigframes/extensions/bigframes/dataframe_accessor.py b/bigframes/extensions/bigframes/dataframe_accessor.py deleted file mode 100644 index f706c19ef2d..00000000000 --- a/bigframes/extensions/bigframes/dataframe_accessor.py +++ /dev/null @@ -1,71 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 TypeVar, cast - -import bigframes.dataframe -import bigframes.extensions.core.dataframe_accessor as core_accessor -import bigframes.series -from bigframes.core.logging import log_adapter - -T = TypeVar("T", bound="bigframes.dataframe.DataFrame") -S = TypeVar("S", bound="bigframes.series.Series") - - -@log_adapter.class_logger -class BigframesAIAccessor(core_accessor.AIAccessor[T, S]): - """ - BigFrames DataFrame accessor for BigQuery AI functions. - """ - - def __init__(self, bf_obj: T): - super().__init__(bf_obj) - - def _bf_from_dataframe( - self, session: bigframes.session.Session | None - ) -> bigframes.dataframe.DataFrame: - return self._obj - - def _to_dataframe(self, bf_df: bigframes.dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: bigframes.series.Series) -> S: - return cast(S, bf_series) - - -@log_adapter.class_logger -class BigframesBigQueryDataFrameAccessor(core_accessor.BigQueryDataFrameAccessor[T, S]): - """ - BigFrames DataFrame accessor for BigQuery DataFrames functionality. - """ - - def __init__(self, bf_obj: T): - super().__init__(bf_obj) - - @property - def ai(self) -> BigframesAIAccessor: - return BigframesAIAccessor(self._obj) - - def _bf_from_dataframe( - self, session: bigframes.session.Session | None - ) -> bigframes.dataframe.DataFrame: - return self._obj - - def _to_dataframe(self, bf_df: bigframes.dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: bigframes.series.Series) -> S: - return cast(S, bf_series) diff --git a/bigframes/extensions/bigframes/series_accessor.py b/bigframes/extensions/bigframes/series_accessor.py deleted file mode 100644 index c9026595d97..00000000000 --- a/bigframes/extensions/bigframes/series_accessor.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: scripts/generate_bigframes_bigquery.py -# - -from __future__ import annotations - -from typing import Optional, TypeVar, cast - -from bigframes import dataframe, series, session -from bigframes.core.logging import log_adapter -from bigframes.extensions.core import series_accessor as core_accessor - -T = TypeVar("T", bound="dataframe.DataFrame") -S = TypeVar("S", bound="series.Series") - - -@log_adapter.class_logger -class BigframesBigQuerySeriesAccessor(core_accessor.BigQuerySeriesAccessor[T, S]): - def __init__(self, bf_obj: S): - super().__init__(bf_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - return self._obj - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series) - - @property - def aead(self) -> BigframesAeadSeriesAccessor[T, S]: - return BigframesAeadSeriesAccessor(self._obj) - - @property - def ai(self) -> BigframesAiSeriesAccessor[T, S]: - return BigframesAiSeriesAccessor(self._obj) - - -@log_adapter.class_logger -class BigframesAeadSeriesAccessor(core_accessor.AeadSeriesAccessor[T, S]): - def __init__(self, bf_obj: S): - super().__init__(bf_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - return self._obj - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series) - - -@log_adapter.class_logger -class BigframesAiSeriesAccessor(core_accessor.AiSeriesAccessor[T, S]): - def __init__(self, bf_obj: S): - super().__init__(bf_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - return self._obj - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series) diff --git a/bigframes/extensions/core/__init__.py b/bigframes/extensions/core/__init__.py deleted file mode 100644 index 41b554c99ef..00000000000 --- a/bigframes/extensions/core/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.extensions.core.dataframe_accessor import ( - AIAccessor, - BigQueryDataFrameAccessor, -) - -__all__ = ["AIAccessor", "BigQueryDataFrameAccessor"] diff --git a/bigframes/extensions/core/abstract_series_accessor.py b/bigframes/extensions/core/abstract_series_accessor.py deleted file mode 100644 index 22d09861877..00000000000 --- a/bigframes/extensions/core/abstract_series_accessor.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: scripts/generate_bigframes_bigquery.py -# - -from __future__ import annotations - -import abc -from typing import ( - Generic, - Optional, - TypeVar, -) - -from bigframes import dataframe, series, session - -T = TypeVar("T") -S = TypeVar("S") - - -class AbstractBigQuerySeriesAccessor(abc.ABC, Generic[T, S]): - def __init__(self, obj: S): - self._obj = obj - - @abc.abstractmethod - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - """Convert the accessor's object to a BigFrames Series.""" - - @abc.abstractmethod - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - """Convert a BigFrames DataFrame to the accessor's object type.""" - - @abc.abstractmethod - def _to_series(self, bf_series: series.Series) -> S: - """Convert a BigFrames Series to the accessor's object type.""" diff --git a/bigframes/extensions/core/dataframe_accessor.py b/bigframes/extensions/core/dataframe_accessor.py deleted file mode 100644 index e490aa907dc..00000000000 --- a/bigframes/extensions/core/dataframe_accessor.py +++ /dev/null @@ -1,340 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 ( - TYPE_CHECKING, - Any, - Generic, - Iterable, - List, - Literal, - Mapping, - Tuple, - TypeVar, - Union, -) - -if TYPE_CHECKING: - import pandas as pd - - import bigframes.dataframe - import bigframes.series - import bigframes.session - - PROMPT_TYPE = Union[ - str, - bigframes.series.Series, - pd.Series, - List[Union[str, bigframes.series.Series, pd.Series]], - Tuple[Union[str, bigframes.series.Series, pd.Series], ...], - ] -else: - PROMPT_TYPE = Any - -T = TypeVar("T") -S = TypeVar("S") - - -class AbstractBigQueryDataFrameAccessor(abc.ABC, Generic[T, S]): - @abc.abstractmethod - def _bf_from_dataframe( - self, session: bigframes.session.Session | None - ) -> bigframes.dataframe.DataFrame: - """Convert the accessor's object to a BigFrames DataFrame.""" - - @abc.abstractmethod - def _to_dataframe(self, bf_df: bigframes.dataframe.DataFrame) -> T: - """Convert a BigFrames DataFrame to the accessor's object type.""" - - @abc.abstractmethod - def _to_series(self, bf_series: bigframes.series.Series) -> S: - """Convert a BigFrames Series to the accessor's object type.""" - - -class AIAccessor(AbstractBigQueryDataFrameAccessor[T, S]): - """ - DataFrame accessor for BigQuery AI functions. - """ - - def __init__(self, obj: T): - self._obj = obj - - def forecast( - self, - *, - 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, - output_historical_time_series: bool = False, - session: bigframes.session.Session | None = None, - ) -> T: - """ - Forecast time series at future horizon using BigQuery AI.FORECAST. - - This is an accessor for :func:`bigframes.bigquery.ai.forecast`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - bf_df = self._bf_from_dataframe(session) - result = bigframes.bigquery.ai.forecast( - bf_df, - data_col=data_col, - timestamp_col=timestamp_col, - model=model, - id_cols=id_cols, - horizon=horizon, - confidence_level=confidence_level, - context_window=context_window, - output_historical_time_series=output_historical_time_series, - ) - return self._to_dataframe(result) - - def generate( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - request_type: Literal["dedicated", "shared", "unspecified"] | None = None, - model_params: Mapping[Any, Any] | None = None, - output_schema: Mapping[str, str] | None = None, - ) -> S: - """ - Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. - - This is an accessor for :func:`bigframes.bigquery.ai.generate`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.generate( - prompt, - connection_id=connection_id, - endpoint=endpoint, - request_type=request_type, - model_params=model_params, - output_schema=output_schema, - ) - return self._to_series(result) - - def generate_bool( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - request_type: Literal["dedicated", "shared", "unspecified"] | None = None, - model_params: Mapping[Any, Any] | None = None, - ) -> S: - """ - Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_bool`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.generate_bool( - prompt, - connection_id=connection_id, - endpoint=endpoint, - request_type=request_type, - model_params=model_params, - ) - return self._to_series(result) - - def generate_int( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - request_type: Literal["dedicated", "shared", "unspecified"] | None = None, - model_params: Mapping[Any, Any] | None = None, - ) -> S: - """ - Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_int`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.generate_int( - prompt, - connection_id=connection_id, - endpoint=endpoint, - request_type=request_type, - model_params=model_params, - ) - return self._to_series(result) - - def generate_double( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - request_type: Literal["dedicated", "shared", "unspecified"] | None = None, - model_params: Mapping[Any, Any] | None = None, - ) -> S: - """ - Returns the AI analysis based on the prompt, which can be any combination of text and unstructured data. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_double`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.generate_double( - prompt, - connection_id=connection_id, - endpoint=endpoint, - request_type=request_type, - model_params=model_params, - ) - return self._to_series(result) - - def classify( - self, - input: PROMPT_TYPE, - categories: tuple[str, ...] | list[str], - *, - examples: list[tuple[str, str]] - | list[tuple[str, list[str] | tuple[str, ...]]] - | None = None, - connection_id: str | None = None, - endpoint: str | None = None, - output_mode: Literal["single", "multi"] | None = None, - optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None, - max_error_ratio: float | None = None, - ) -> S: - """ - Classifies a given input into one of the specified categories. It will always return one of the provided categories best fit the prompt input. - - This is an accessor for :func:`bigframes.bigquery.ai.classify`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.classify( - input, - categories, - examples=examples, - connection_id=connection_id, - endpoint=endpoint, - output_mode=output_mode, - optimization_mode=optimization_mode, - max_error_ratio=max_error_ratio, - ) - return self._to_series(result) - - def if_( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - optimization_mode: Literal["minimize_cost", "maximize_quality"] | None = None, - max_error_ratio: float | None = None, - ) -> S: - """ - 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. - - This is an accessor for :func:`bigframes.bigquery.ai.if_`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.if_( - prompt, - connection_id=connection_id, - endpoint=endpoint, - optimization_mode=optimization_mode, - max_error_ratio=max_error_ratio, - ) - return self._to_series(result) - - def score( - self, - prompt: PROMPT_TYPE, - *, - connection_id: str | None = None, - endpoint: str | None = None, - max_error_ratio: float | None = None, - ) -> S: - """ - Computes a score based on rubrics described in natural language. It will return a double value. - - This is an accessor for :func:`bigframes.bigquery.ai.score`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery.ai - - result = bigframes.bigquery.ai.score( - prompt, - connection_id=connection_id, - endpoint=endpoint, - max_error_ratio=max_error_ratio, - ) - return self._to_series(result) - - -class BigQueryDataFrameAccessor(AbstractBigQueryDataFrameAccessor[T, S]): - """ - DataFrame accessor for BigQuery DataFrames functionality. - """ - - def __init__(self, obj: T): - self._obj = obj - - @property - @abc.abstractmethod - def ai(self) -> AIAccessor: - """ - Accessor for BigQuery AI functions. - - Returns: - AIAccessor: Accessor for BigQuery AI functions. - """ - - def sql_scalar( - self, - sql_template: str, - *, - output_dtype=None, - session: bigframes.session.Session | None = None, - ) -> S: - """ - Compute a new Series by applying a SQL scalar function to the DataFrame. - - This is an accessor for :func:`bigframes.bigquery.sql_scalar`. See that - function's documentation for detailed parameter descriptions and examples. - """ - import bigframes.bigquery - - bf_df = self._bf_from_dataframe(session) - result = bigframes.bigquery.sql_scalar( - sql_template, bf_df, output_dtype=output_dtype - ) - return self._to_series(result) diff --git a/bigframes/extensions/core/series_accessor.py b/bigframes/extensions/core/series_accessor.py deleted file mode 100644 index 5aa50905c8f..00000000000 --- a/bigframes/extensions/core/series_accessor.py +++ /dev/null @@ -1,1229 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: scripts/generate_bigframes_bigquery.py -# - -from __future__ import annotations - -import abc -import datetime -from typing import ( - Any, - Literal, - Optional, - TypeVar, - Union, - cast, -) - -from bigframes import series, session -from bigframes.core import col, sentinels -from bigframes.extensions.core import abstract_series_accessor, series_mixins - -T = TypeVar("T") -S = TypeVar("S") - - -class BigQuerySeriesAccessor( - abstract_series_accessor.AbstractBigQuerySeriesAccessor[T, S] -): - """Series accessor for BigQuery functions.""" - - @property - @abc.abstractmethod - def aead(self) -> AeadSeriesAccessor[T, S]: - """Accessor for BigQuery aead functions.""" - - @property - @abc.abstractmethod - def ai(self) -> AiSeriesAccessor[T, S]: - """Accessor for BigQuery ai functions.""" - - def deterministic_decrypt_bytes( - self, - ciphertext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Uses the matching key from `keyset` to decrypt `ciphertext` and verifies the integrity of the data using `additional_data`. Returns an error if decryption fails.""" - from bigframes.operations.googlesql.global_namespace.aead_encryption import ( - deterministic_decrypt_bytes as deterministic_decrypt_bytes_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - ciphertext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = deterministic_decrypt_bytes_impl( - bf_series, - ciphertext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - def deterministic_decrypt_string( - self, - ciphertext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Like `DETERMINISTIC_DECRYPT_BYTES`, but where plaintext is of type STRING.""" - from bigframes.operations.googlesql.global_namespace.aead_encryption import ( - deterministic_decrypt_string as deterministic_decrypt_string_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - ciphertext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = deterministic_decrypt_string_impl( - bf_series, - ciphertext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - def deterministic_encrypt( - self, - plaintext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Encrypts `plaintext` using the primary cryptographic key in `keyset` using deterministic AEAD. The algorithm of the primary key must be `DETERMINISTIC_AEAD_AES_SIV_CMAC_256`. Binds the ciphertext to the context defined by `additional_data`. Returns `NULL` if any input is `NULL`.""" - from bigframes.operations.googlesql.global_namespace.aead_encryption import ( - deterministic_encrypt as deterministic_encrypt_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - plaintext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = deterministic_encrypt_impl( - bf_series, - plaintext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - def array_concat( - self, - array_expression_2: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Concatenates one or more arrays with the same element type into a single array.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_concat as array_concat_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - array_expression_2, - ) - - bf_series = self._bf_from_series(session) - result = array_concat_impl( - bf_series, - array_expression_2, - ) - return self._to_series(cast(series.Series, result)) - - def array_first( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array and returns the first element in the array.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_first as array_first_impl, - ) - - bf_series = self._bf_from_series(session) - result = array_first_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def array_first_n( - self, - n: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns a prefix of `input_array` consisting of the first `n` elements.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_first_n as array_first_n_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - n, - ) - - bf_series = self._bf_from_series(session) - result = array_first_n_impl( - bf_series, - n, - ) - return self._to_series(cast(series.Series, result)) - - def array_includes( - self, - search_value: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array and returns `TRUE` if there is an element in the array that is equal to the search_value.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_includes as array_includes_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - search_value, - ) - - bf_series = self._bf_from_series(session) - result = array_includes_impl( - bf_series, - search_value, - ) - return self._to_series(cast(series.Series, result)) - - def array_includes_all( - self, - search_values: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array to search and an array of search values. Returns `TRUE` if all search values are in the array to search, otherwise returns `FALSE`.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_includes_all as array_includes_all_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - search_values, - ) - - bf_series = self._bf_from_series(session) - result = array_includes_all_impl( - bf_series, - search_values, - ) - return self._to_series(cast(series.Series, result)) - - def array_includes_any( - self, - search_values: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array to search and an array of search values. Returns `TRUE` if any search values are in the array to search, otherwise returns `FALSE`.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_includes_any as array_includes_any_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - search_values, - ) - - bf_series = self._bf_from_series(session) - result = array_includes_any_impl( - bf_series, - search_values, - ) - return self._to_series(cast(series.Series, result)) - - def array_is_distinct( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns `TRUE` if the array contains no repeated elements, using the same equality comparison logic as `SELECT DISTINCT`.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_is_distinct as array_is_distinct_impl, - ) - - bf_series = self._bf_from_series(session) - result = array_is_distinct_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def array_last( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array and returns the last element in the array.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_last as array_last_impl, - ) - - bf_series = self._bf_from_series(session) - result = array_last_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def array_length( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Compute the length of each array element in the Series. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> bbq.array_length(s) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> ps.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also apply this function directly to Series using `apply`. - - >>> s.apply(bbq.array_length, by_row=False) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - Args: - series (bigframes.series.Series): A Series with array columns. - - Returns: - bigframes.series.Series: A Series of integer values indicating - the length of each element in the Series. - """ - from bigframes.operations.googlesql.global_namespace.array import ( - array_length as array_length_impl, - ) - - bf_series = self._bf_from_series(session) - result = array_length_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def array_reverse( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns the input `ARRAY` with elements in reverse order.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_reverse as array_reverse_impl, - ) - - bf_series = self._bf_from_series(session) - result = array_reverse_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def array_slice( - self, - start_offset: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - end_offset: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns an array containing zero or more consecutive elements from the input array.""" - from bigframes.operations.googlesql.global_namespace.array import ( - array_slice as array_slice_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - start_offset, - end_offset, - ) - - bf_series = self._bf_from_series(session) - result = array_slice_impl( - bf_series, - start_offset, - end_offset, - ) - return self._to_series(cast(series.Series, result)) - - def array_to_string( - self, - delimiter: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - null_text: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts array elements within a Series into delimited strings. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([["H", "i", "!"], ["Hello", "World"], np.nan, [], ["Hi"]]) - >>> bbq.array_to_string(s, delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([["H", "i", "!"], ["Hello", "World"], None, [], ["Hi"]]) - >>> ps.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - Args: - series (bigframes.series.Series): A Series containing arrays. - delimiter (str): The string used to separate array elements. - null_text (str, optional): The string to replace any NULL values in the array with. - - Returns: - bigframes.series.Series: A Series containing delimited strings. - """ - from bigframes.operations.googlesql.global_namespace.array import ( - array_to_string as array_to_string_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - delimiter, - null_text, - ) - - bf_series = self._bf_from_series(session) - result = array_to_string_impl( - bf_series, - delimiter, - null_text, - ) - return self._to_series(cast(series.Series, result)) - - def flatten( - self, - depth: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Takes an array of nested data and flattens a specific part of it into a single, flat array with the [array elements field access operator][array-el-field-operator]. Returns `NULL` if the input value is `NULL`.""" - from bigframes.operations.googlesql.global_namespace.array import ( - flatten as flatten_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - depth, - ) - - bf_series = self._bf_from_series(session) - result = flatten_impl( - bf_series, - depth, - ) - return self._to_series(cast(series.Series, result)) - - def bit_count( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """The input, `expression`, must be an integer or `BYTES`. Returns the number of bits that are set in the input expression. For signed integers, this is the number of bits in two's complement form.""" - from bigframes.operations.googlesql.global_namespace.bit import ( - bit_count as bit_count_impl, - ) - - bf_series = self._bf_from_series(session) - result = bit_count_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def bool_( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a JSON boolean to a SQL BOOL value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - bool_ as bool__impl, - ) - - bf_series = self._bf_from_series(session) - result = bool__impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def double( - self, - wide_number_mode: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a JSON number to a SQL FLOAT64 value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - double as double_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - wide_number_mode, - ) - - bf_series = self._bf_from_series(session) - result = double_impl( - bf_series, - wide_number_mode, - ) - return self._to_series(cast(series.Series, result)) - - def float64( - self, - wide_number_mode: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a JSON number to a SQL FLOAT64 value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - float64 as float64_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - wide_number_mode, - ) - - bf_series = self._bf_from_series(session) - result = float64_impl( - bf_series, - wide_number_mode, - ) - return self._to_series(cast(series.Series, result)) - - def int64( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a JSON number to a SQL INT64 value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - int64 as int64_impl, - ) - - bf_series = self._bf_from_series(session) - result = int64_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def parse_bignumeric( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a STRING to a BIGNUMERIC value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - parse_bignumeric as parse_bignumeric_impl, - ) - - bf_series = self._bf_from_series(session) - result = parse_bignumeric_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def parse_numeric( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a STRING to a NUMERIC value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - parse_numeric as parse_numeric_impl, - ) - - bf_series = self._bf_from_series(session) - result = parse_numeric_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def string( - self, - timezone: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a value to a STRING value.""" - from bigframes.operations.googlesql.global_namespace.conversion import ( - string as string_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - timezone, - ) - - bf_series = self._bf_from_series(session) - result = string_impl( - bf_series, - timezone, - ) - return self._to_series(cast(series.Series, result)) - - def date( - self, - time_zone_expression: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - year: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - month: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - day: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Constructs or extracts a date.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date as date_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - time_zone_expression, - year, - month, - day, - ) - - bf_series = self._bf_from_series(session) - result = date_impl( - bf_series, - time_zone_expression, - year, - month, - day, - ) - return self._to_series(cast(series.Series, result)) - - def date_add( - self, - int64_expression: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - date_part: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Adds a specified time interval to a DATE.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date_add as date_add_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - int64_expression, - date_part, - ) - - bf_series = self._bf_from_series(session) - result = date_add_impl( - bf_series, - int64_expression, - date_part, - ) - return self._to_series(cast(series.Series, result)) - - def date_diff( - self, - start_date: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - granularity: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Gets the number of unit boundaries between two DATE values (end_date - start_date) at a particular time granularity.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date_diff as date_diff_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - start_date, - granularity, - ) - - bf_series = self._bf_from_series(session) - result = date_diff_impl( - bf_series, - start_date, - granularity, - ) - return self._to_series(cast(series.Series, result)) - - def date_from_unix_date( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Interprets an INT64 expression as the number of days since 1970-01-01.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date_from_unix_date as date_from_unix_date_impl, - ) - - bf_series = self._bf_from_series(session) - result = date_from_unix_date_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def date_sub( - self, - int64_expression: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - date_part: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Subtracts a specified time interval from a DATE.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date_sub as date_sub_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - int64_expression, - date_part, - ) - - bf_series = self._bf_from_series(session) - result = date_sub_impl( - bf_series, - int64_expression, - date_part, - ) - return self._to_series(cast(series.Series, result)) - - def date_trunc( - self, - granularity: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Truncates a DATE, DATETIME, or TIMESTAMP value at a particular granularity.""" - from bigframes.operations.googlesql.global_namespace.date import ( - date_trunc as date_trunc_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - granularity, - ) - - bf_series = self._bf_from_series(session) - result = date_trunc_impl( - bf_series, - granularity, - ) - return self._to_series(cast(series.Series, result)) - - def extract( - self, - part: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - time_zone: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns the value corresponding to the specified date part.""" - from bigframes.operations.googlesql.global_namespace.date import ( - extract as extract_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - part, - time_zone, - ) - - bf_series = self._bf_from_series(session) - result = extract_impl( - bf_series, - part, - time_zone, - ) - return self._to_series(cast(series.Series, result)) - - def format_date( - self, - format_string: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Formats a DATE value according to a specified format string.""" - from bigframes.operations.googlesql.global_namespace.date import ( - format_date as format_date_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - format_string, - ) - - bf_series = self._bf_from_series(session) - result = format_date_impl( - format_string, - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def last_day( - self, - date_part: Union[ - series.Series, - col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns the last day from a date expression. This is commonly used to return the last day of the month.""" - from bigframes.operations.googlesql.global_namespace.date import ( - last_day as last_day_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - date_part, - ) - - bf_series = self._bf_from_series(session) - result = last_day_impl( - bf_series, - date_part, - ) - return self._to_series(cast(series.Series, result)) - - def parse_date( - self, - format_string: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Converts a STRING value to a DATE value.""" - from bigframes.operations.googlesql.global_namespace.date import ( - parse_date as parse_date_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - format_string, - ) - - bf_series = self._bf_from_series(session) - result = parse_date_impl( - format_string, - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - def unix_date( - self, - *, - session: Optional[session.Session] = None, - ) -> S: - """Returns the number of days since 1970-01-01.""" - from bigframes.operations.googlesql.global_namespace.date import ( - unix_date as unix_date_impl, - ) - - bf_series = self._bf_from_series(session) - result = unix_date_impl( - bf_series, - ) - return self._to_series(cast(series.Series, result)) - - -class AeadSeriesAccessor(abstract_series_accessor.AbstractBigQuerySeriesAccessor[T, S]): - """Series accessor for BigQuery aead functions.""" - - def decrypt_bytes( - self, - ciphertext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Uses the matching key from keyset to decrypt ciphertext and verifies the integrity of the data using additional_data. Returns an error if decryption or verification fails.""" - from bigframes.operations.googlesql.aead import ( - decrypt_bytes as decrypt_bytes_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - ciphertext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = decrypt_bytes_impl( - bf_series, - ciphertext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - def decrypt_string( - self, - ciphertext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Like AEAD.DECRYPT_BYTES, but where additional_data is of type STRING.""" - from bigframes.operations.googlesql.aead import ( - decrypt_string as decrypt_string_impl, - ) - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - ciphertext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = decrypt_string_impl( - bf_series, - ciphertext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - def encrypt( - self, - plaintext: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - additional_data: Union[ - series.Series, - col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - *, - session: Optional[session.Session] = None, - ) -> S: - """Encrypts plaintext using the primary cryptographic key in keyset. The algorithm of the primary key must be AEAD_AES_GCM_256. Binds the ciphertext to the context defined by additional_data. Returns NULL if any input is NULL.""" - from bigframes.operations.googlesql.aead import encrypt as encrypt_impl - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - - session = googlesql._find_session( - plaintext, - additional_data, - ) - - bf_series = self._bf_from_series(session) - result = encrypt_impl( - bf_series, - plaintext, - additional_data, - ) - return self._to_series(cast(series.Series, result)) - - -class AiSeriesAccessor(series_mixins.AIMixin[T, S]): - """Series accessor for BigQuery ai functions.""" diff --git a/bigframes/extensions/core/series_mixins.py b/bigframes/extensions/core/series_mixins.py deleted file mode 100644 index 4d1b61ecb0c..00000000000 --- a/bigframes/extensions/core/series_mixins.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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, List, Literal, Mapping, TypeVar - -import pandas as pd - -from bigframes import series -from bigframes import session as bf_session -from bigframes.bigquery import ai -from bigframes.extensions.core import abstract_series_accessor -from bigframes.ml import base as ml_base - -T = TypeVar("T") -S = TypeVar("S") - - -class AIMixin(abstract_series_accessor.AbstractBigQuerySeriesAccessor[T, S]): - def generate_embedding( - self, - model: ml_base.BaseEstimator | str | pd.Series, - *, - output_dimensionality: int | None = None, - task_type: str | None = None, - start_second: float | None = None, - end_second: float | None = None, - interval_seconds: float | None = None, - trial_id: int | None = None, - session: bf_session.Session | None = None, - ) -> T: - """ - Creates embeddings that describe an entity — for example, a piece of text or an image. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_embedding`. See that - function's documentation for detailed parameter descriptions and examples. - """ - - bf_series = self._bf_from_series(session) - result = ai.generate_embedding( - model, - bf_series, - output_dimensionality=output_dimensionality, - task_type=task_type, - start_second=start_second, - end_second=end_second, - interval_seconds=interval_seconds, - trial_id=trial_id, - ) - return self._to_dataframe(result) - - def generate_text( - self, - model: ml_base.BaseEstimator | str | pd.Series, - *, - temperature: float | None = None, - max_output_tokens: int | None = None, - top_k: int | None = None, - top_p: float | None = None, - stop_sequences: List[str] | None = None, - ground_with_google_search: bool | None = None, - request_type: str | None = None, - session: bf_session.Session | None = None, - ) -> T: - """ - Generates text using a BigQuery ML model. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_text`. See that - function's documentation for detailed parameter descriptions and examples. - """ - bf_series = self._bf_from_series(session) - result = ai.generate_text( - model, - bf_series, - temperature=temperature, - max_output_tokens=max_output_tokens, - top_k=top_k, - top_p=top_p, - stop_sequences=stop_sequences, - ground_with_google_search=ground_with_google_search, - request_type=request_type, - ) - return self._to_dataframe(result) - - def generate_table( - self, - model: ml_base.BaseEstimator | str | pd.Series, - *, - output_schema: str | Mapping[str, str], - temperature: float | None = None, - top_p: float | None = None, - max_output_tokens: int | None = None, - stop_sequences: List[str] | None = None, - request_type: str | None = None, - session: bf_session.Session | None = None, - ) -> T: - """ - Generates a table using a BigQuery ML model. - - This is an accessor for :func:`bigframes.bigquery.ai.generate_table`. See that - function's documentation for detailed parameter descriptions and examples. - """ - bf_series = self._bf_from_series(session) - result = ai.generate_table( - model, - bf_series, - output_schema=output_schema, - temperature=temperature, - top_p=top_p, - max_output_tokens=max_output_tokens, - stop_sequences=stop_sequences, - request_type=request_type, - ) - return self._to_dataframe(result) - - def embed( - self, - *, - endpoint: str | None = None, - model: str | None = None, - task_type: ( - Literal[ - "retrieval_query", - "retrieval_document", - "semantic_similarity", - "classification", - "clustering", - "question_answering", - "fact_verification", - "code_retrieval_query", - ] - | None - ) = None, - title: str | None = None, - model_params: Mapping[Any, Any] | None = None, - connection_id: str | None = None, - session: bf_session.Session | None = None, - ) -> S: - """ - Creates embeddings from text or image data in BigQuery. - - This is an accessor for :func:`bigframes.bigquery.ai.embed`. See that - function's documentation for detailed parameter descriptions and examples. - """ - - bf_series = self._bf_from_series(session) - result = ai.embed( - bf_series, - endpoint=endpoint, - model=model, - task_type=task_type, - title=title, - model_params=model_params, - connection_id=connection_id, - ) - return self._to_series(result) - - def similarity( - self, - other: str | series.Series | pd.Series, - *, - endpoint: str | None = None, - model: str | None = None, - model_params: Mapping[Any, Any] | None = None, - connection_id: str | None = None, - session: bf_session.Session | None = None, - ) -> S: - """ - Returns a FLOAT64 value that represents the cosine similarity between the two inputs. - - This is an accessor for :func:`bigframes.bigquery.ai.similarity`. See that - function's documentation for detailed parameter descriptions and examples. - """ - - bf_series = self._bf_from_series(session) - result = ai.similarity( - bf_series, - other, - endpoint=endpoint, - model=model, - model_params=model_params, - connection_id=connection_id, - ) - return self._to_series(result) diff --git a/bigframes/extensions/pandas/__init__.py b/bigframes/extensions/pandas/__init__.py deleted file mode 100644 index 6af1f769b5b..00000000000 --- a/bigframes/extensions/pandas/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 automatically registers a pandas extenstion when imported. -This allows you to use the power of the BigQuery engine with pandas objects -directly. -""" - -from bigframes.extensions.pandas.dataframe_accessor import ( - PandasBigQueryDataFrameAccessor, -) -from bigframes.extensions.pandas.series_accessor import ( - PandasBigQuerySeriesAccessor, -) - -__all__ = [ - "PandasBigQueryDataFrameAccessor", - "PandasBigQuerySeriesAccessor", -] diff --git a/bigframes/extensions/pandas/dataframe_accessor.py b/bigframes/extensions/pandas/dataframe_accessor.py deleted file mode 100644 index 512134cac03..00000000000 --- a/bigframes/extensions/pandas/dataframe_accessor.py +++ /dev/null @@ -1,83 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 TypeVar, cast - -import pandas -import pandas.api.extensions - -import bigframes.core.global_session as bf_session -import bigframes.dataframe -import bigframes.pandas as bpd -from bigframes.core.logging import log_adapter -from bigframes.extensions.core.dataframe_accessor import ( - AIAccessor, - BigQueryDataFrameAccessor, -) - -T = TypeVar("T", bound="pandas.DataFrame") -S = TypeVar("S", bound="pandas.Series") - - -@log_adapter.class_logger -class PandasAIAccessor(AIAccessor[T, S]): - """ - Pandas DataFrame accessor for BigQuery AI functions. - """ - - def __init__(self, pandas_obj: T): - super().__init__(pandas_obj) - - def _bf_from_dataframe( - self, session: bigframes.session.Session | None - ) -> bigframes.dataframe.DataFrame: - if session is None: - session = bf_session.get_global_session() - - return cast(bpd.DataFrame, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: bigframes.dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: bigframes.series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) - - -@pandas.api.extensions.register_dataframe_accessor("bigquery") -@log_adapter.class_logger -class PandasBigQueryDataFrameAccessor(BigQueryDataFrameAccessor[T, S]): - """ - Pandas DataFrame accessor for BigQuery DataFrames functionality. - - This accessor is registered under the ``bigquery`` namespace on pandas DataFrame objects. - """ - - def __init__(self, pandas_obj: T): - super().__init__(pandas_obj) - - @property - def ai(self) -> PandasAIAccessor: - return PandasAIAccessor(self._obj) - - def _bf_from_dataframe(self, session) -> bigframes.dataframe.DataFrame: - if session is None: - session = bf_session.get_global_session() - - return cast(bpd.DataFrame, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: bigframes.dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: bigframes.series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) diff --git a/bigframes/extensions/pandas/series_accessor.py b/bigframes/extensions/pandas/series_accessor.py deleted file mode 100644 index 9c33996c421..00000000000 --- a/bigframes/extensions/pandas/series_accessor.py +++ /dev/null @@ -1,98 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: scripts/generate_bigframes_bigquery.py -# - -from __future__ import annotations - -from typing import Optional, TypeVar, cast - -import pandas -import pandas.api.extensions - -from bigframes import dataframe, series, session -from bigframes.core import global_session as bf_session -from bigframes.core.logging import log_adapter -from bigframes.extensions.core import series_accessor as core_accessor - -T = TypeVar("T", bound="pandas.DataFrame") -S = TypeVar("S", bound="pandas.Series") - - -@pandas.api.extensions.register_series_accessor("bigquery") -@log_adapter.class_logger -class PandasBigQuerySeriesAccessor(core_accessor.BigQuerySeriesAccessor[T, S]): - def __init__(self, pandas_obj: S): - super().__init__(pandas_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - if session is None: - session = bf_session.get_global_session() - return cast(series.Series, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) - - @property - def aead(self) -> PandasAeadSeriesAccessor[T, S]: - return PandasAeadSeriesAccessor(self._obj) - - @property - def ai(self) -> PandasAiSeriesAccessor[T, S]: - return PandasAiSeriesAccessor(self._obj) - - -@log_adapter.class_logger -class PandasAeadSeriesAccessor(core_accessor.AeadSeriesAccessor[T, S]): - def __init__(self, pandas_obj: S): - super().__init__(pandas_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - if session is None: - session = bf_session.get_global_session() - return cast(series.Series, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) - - -@log_adapter.class_logger -class PandasAiSeriesAccessor(core_accessor.AiSeriesAccessor[T, S]): - def __init__(self, pandas_obj: S): - super().__init__(pandas_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - if session is None: - session = bf_session.get_global_session() - return cast(series.Series, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) diff --git a/bigframes/formatting_helpers.py b/bigframes/formatting_helpers.py index 9ab25951932..48afb4fdbd0 100644 --- a/bigframes/formatting_helpers.py +++ b/bigframes/formatting_helpers.py @@ -13,23 +13,19 @@ # limitations under the License. """Shared helper functions for formatting jobs related info.""" - -from __future__ import annotations +# TODO(orrbradford): cleanup up typings and documenttion in this file import datetime -import html import random -from typing import TYPE_CHECKING, Any, Optional, Type, Union +from typing import Any, Optional, Type, Union import bigframes_vendored.constants as constants import google.api_core.exceptions as api_core_exceptions import google.cloud.bigquery as bigquery import humanize - -import bigframes._config - -if TYPE_CHECKING: - import bigframes.core.events +import IPython +import IPython.display as display +import ipywidgets as widgets GenericJob = Union[ bigquery.LoadJob, bigquery.ExtractJob, bigquery.QueryJob, bigquery.CopyJob @@ -47,7 +43,7 @@ def add_feedback_link( exception: Union[ api_core_exceptions.RetryError, api_core_exceptions.GoogleAPICallError - ], + ] ): exception.message = exception.message + f" {constants.FEEDBACK_LINK}" @@ -62,179 +58,132 @@ def create_exception_with_feedback_link( return exception(constants.FEEDBACK_LINK) -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 in html format. Args: - query_job: + query_job (bigquery.QueryJob, Optional): The job representing the execution of the query on the server. Returns: - Formatted string. + Pywidget html table. """ if query_job is None: - return "No job information available" + return display.HTML("No job information available") if query_job.dry_run: - return ( - f"Computation deferred. Computation will process " - f"{get_formatted_bytes(query_job.total_bytes_processed)}" + return display.HTML( + f"Computation deferred. Computation will process {get_formatted_bytes(query_job.total_bytes_processed)}" ) - res = "Query Job Info" + table_html = "" + table_html += "" 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( - project_id=query_job.project, - location=query_job.location, - job_id=query_job.job_id, - ) - }""" + table_html += f"""""" elif key == "Slot Time": - res += f"""{key}: {get_formatted_time(job_val)}""" + table_html += ( + f"""""" + ) elif key == "Bytes Processed": - res += f"""{key}: {get_formatted_bytes(job_val)}""" + table_html += f"""""" else: - res += f"""{key}: {job_val}""" - return res + 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) -def repr_query_job_html(query_job: Optional[bigquery.QueryJob]): - """Return query job as a formatted html string. +def repr_query_job(query_job: Optional[bigquery.QueryJob]): + """Return query job as a formatted string. Args: query_job: The job representing the execution of the query on the server. Returns: - Html string. + Pywidget html table. """ if query_job is None: return "No job information available" if query_job.dry_run: - return ( - f"Computation deferred. Computation will process " - f"{get_formatted_bytes(query_job.total_bytes_processed)}" - ) - - # 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

    " + 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 - url = get_job_url( - project_id=query_job.project, - location=query_job.location, - job_id=query_job.job_id, - ) - res += ( - f'
  • Job: ' - f"{query_job.job_id}
  • " - ) + res += f"""Job url: {get_job_url(query_job)}""" 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 += "
" + res += f"""{key}: {job_val}""" return res -current_display_id: Optional[str] = None - - -def create_progress_callback(): - # bind potentially thread-local config to the callback so that it uses the user thread - # config even if callback is invoked from a worker thread. - display_opts = bigframes._config.options.display - - def progress_callback( - envelope: Any, - ): - """Displays a progress bar while the query is running""" - global current_display_id - - 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 - - # Publisher.publish automatically wraps raw Event objects in an - # EventEnvelope, ensuring subscribers receive a consistent contract. - assert isinstance(envelope, bigframes.core.events.EventEnvelope) - event = envelope.event - progress_bar = envelope.progress_bar - - if progress_bar == bigframes.core.events._DEFAULT: - progress_bar = display_opts.progress_bar - - if progress_bar == "auto": - progress_bar = "notebook" if in_ipython() else "terminal" +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. + """ + if progress_bar == "auto": + progress_bar = "notebook" if in_ipython() else "terminal" + try: if progress_bar == "notebook": - import IPython.display as display - - display_html = None - - if isinstance(event, bigframes.core.events.ExecutionStarted): - # Start a new context for progress output. - current_display_id = None - - elif isinstance(event, bigframes.core.events.BigQuerySentEvent): - display_html = render_bqquery_sent_event_html(event) - - elif isinstance(event, bigframes.core.events.BigQueryRetryEvent): - display_html = render_bqquery_retry_event_html(event) - - elif isinstance(event, bigframes.core.events.BigQueryReceivedEvent): - display_html = render_bqquery_received_event_html(event) - - elif isinstance(event, bigframes.core.events.BigQueryFinishedEvent): - display_html = render_bqquery_finished_event_html(event) - - elif isinstance(event, bigframes.core.events.SessionClosed): - display_html = f"Session {event.session_id} closed." - - if display_html: - if current_display_id: - display.update_display( - display.HTML(display_html), - display_id=current_display_id, - ) - else: - current_display_id = str(random.random()) - display.display( - display.HTML(display_html), - display_id=current_display_id, - ) - + 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 + ) + query_job.reload() + display.update_display( + display.HTML(get_query_job_loading_html(query_job)), + display_id=display_id, + ) elif progress_bar == "terminal": - message = None - - if 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) - - return progress_callback + 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 + ) + 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 + ) + 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 def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): @@ -250,16 +199,13 @@ def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): try: if progress_bar == "notebook": - import IPython.display as display - display_id = str(random.random()) loading_bar = display.HTML(get_base_job_loading_html(job)) display.display(loading_bar, display_id=display_id) job.result() job.reload() display.update_display( - display.HTML(get_base_job_loading_html(job)), - display_id=display_id, + display.HTML(get_base_job_loading_html(job)), display_id=display_id ) elif progress_bar == "terminal": inital_loading_bar = get_base_job_loading_string(job) @@ -288,80 +234,24 @@ def wait_for_job(job: GenericJob, progress_bar: Optional[str] = None): raise -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' [' - 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], -): +def get_job_url(query_job: GenericJob): """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 project_id is None or location is None or job_id is None: + if ( + query_job.project is None + or query_job.location is None + or query_job.job_id is None + ): return None - return ( - f"https://console.cloud.google.com/bigquery?project={project_id}" - f"&j=bq:{location}:{job_id}&page=queryresults" - ) + return f"""https://console.cloud.google.com/bigquery?project={query_job.project}&j=bq:{query_job.location}:{query_job.job_id}&page=queryresults""" -def render_bqquery_sent_event_html( - event: bigframes.core.events.BigQuerySentEvent, -) -> str: +def get_query_job_loading_html(query_job: bigquery.QueryJob): """Return progress bar html string Args: query_job (bigquery.QueryJob): @@ -369,205 +259,18 @@ def render_bqquery_sent_event_html( Returns: Html string. """ - - 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
"
-        f"{html.escape(event.query)}
" - ) - - return f""" - Query started{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)}Open Job""" -def render_bqquery_sent_event_plaintext( - event: bigframes.core.events.BigQuerySentEvent, -) -> str: - """Return progress bar html string +def get_query_job_loading_string(query_job: bigquery.QueryJob): + """Return progress bar string Args: query_job (bigquery.QueryJob): The job representing the execution of the query on the server. Returns: - 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
"
-        f"{html.escape(event.query)}
" - ) - - return f""" - Retrying query{query_id}.{job_link}{query_text_details} - """ - - -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
"
-            f"{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} + String """ - - -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: - size_str = humanize.naturalsize(event.total_bytes_processed) - bytes_str = f" {size_str} 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}" + 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 get_base_job_loading_html(job: GenericJob): @@ -578,15 +281,7 @@ 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): @@ -597,13 +292,7 @@ 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( - project_id=job.job_id, - location=job.location, - job_id=job.job_id, - ) - }""" + return f"""{job.job_type.capitalize()} job {job.job_id} is {job.state}. \n{get_job_url(job)}""" def get_formatted_time(val): @@ -615,8 +304,7 @@ def get_formatted_time(val): Duration string """ try: - delta = datetime.timedelta(milliseconds=float(val)) - return humanize.naturaldelta(delta) + return humanize.naturaldelta(datetime.timedelta(milliseconds=float(val))) except Exception: return val @@ -635,10 +323,7 @@ def get_formatted_bytes(val): def get_bytes_processed_string(val: Any): - """Try to get bytes processed string. - - Return empty if passed non int value. - """ + """Try to get bytes processed string. Return empty if passed non int value""" bytes_processed_string = "" if isinstance(val, int): bytes_processed_string = f"""{get_formatted_bytes(val)} processed. """ @@ -647,8 +332,4 @@ def get_bytes_processed_string(val: Any): def in_ipython(): """Return True iff we're in a colab-like IPython.""" - try: - import IPython - except (ImportError, NameError): - return False return hasattr(IPython.get_ipython(), "kernel") diff --git a/bigframes/functions/__init__.py b/bigframes/functions/__init__.py index 86119717d7b..5f87956a611 100644 --- a/bigframes/functions/__init__.py +++ b/bigframes/functions/__init__.py @@ -11,8 +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 Udf +from bigframes.functions.function import ( + BigqueryCallableRoutine, + BigqueryCallableRowRoutine, +) __all__ = [ - "Udf", + "BigqueryCallableRoutine", + "BigqueryCallableRowRoutine", ] diff --git a/bigframes/functions/_function_client.py b/bigframes/functions/_function_client.py index 69f99b50276..a8c9f9c3016 100644 --- a/bigframes/functions/_function_client.py +++ b/bigframes/functions/_function_client.py @@ -15,26 +15,30 @@ from __future__ import annotations +import inspect import logging import os -import re +import random import shutil +import string import tempfile import textwrap import types -import warnings -from typing import Any, cast +from typing import Any, cast, Optional, Sequence, Tuple, TYPE_CHECKING -import google.api_core.exceptions -import google.api_core.retry import requests -from google.cloud import bigquery, functions_v2 -import bigframes.exceptions as bfe import bigframes.formatting_helpers as bf_formatting import bigframes.functions.function_template as bff_template -import bigframes.functions.udf_def as udf_def -from bigframes.functions import _utils + +if TYPE_CHECKING: + from bigframes.session import Session + +import google.api_core.exceptions +import google.api_core.retry +from google.cloud import bigquery, functions_v2 + +from . import _utils logger = logging.getLogger(__name__) @@ -47,97 +51,96 @@ } ) -# 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. + _iam_wait_seconds = 120 + # TODO(b/392707725): Convert all necessary parameters for cloud function # deployment into method parameters. def __init__( self, - gcp_project_id: str, - bq_location: str, - bq_client: bigquery.Client, + gcp_project_id, + bq_location, + bq_dataset, + bq_client, + bq_connection_id, bq_connection_manager, - cloud_functions_client: functions_v2.FunctionServiceClient, - publisher, + 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._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._publisher = publisher - self._cloud_functions_client = cloud_functions_client - - self._cf_location = _utils.gcf_location_from_bq_location(bq_location) + self._session = session - @property - def cloudfunctions_region(self) -> str: - return self._cf_location - - def _create_bq_connection( - self, - connection_id: str, - bq_project_id: str, - ) -> None: - self._bq_connection_manager.create_bq_connection( - bq_project_id, - self._bq_location, - connection_id, - "run.invoker", - ) + # 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._cloud_build_service_account = cloud_build_service_account + + def _create_bq_connection(self) -> None: + if self._bq_connection_manager: + self._bq_connection_manager.create_bq_connection( + self._gcp_project_id, + self._bq_location, + self._bq_connection_id, + "run.invoker", + ) - def _ensure_dataset_exists(self, dataset_ref: bigquery.DatasetReference) -> None: + 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_ref) + self._bq_client.get_dataset(dataset) except google.api_core.exceptions.NotFound: # This requires bigquery.datasets.create IAM permission. - dataset = bigquery.Dataset(dataset_ref) - dataset.location = self._bq_location 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_job( - self._bq_client, + _, query_job = bigframes.session._io.bigquery.start_query_with_client( + cast(bigquery.Client, self._session.bqclient), create_function_ddl, job_config=bigquery.QueryJobConfig(), - location=self._bq_location, + location=None, project=None, timeout=None, metrics=None, - publisher=self._publisher, + query_with_job=True, ) logger.info(f"Created bigframes function {query_job.ddl_target_routine}") def _format_function_options(self, function_options: dict) -> str: - def format_val(val): - if isinstance(val, str): - return f"'{val}'" - if isinstance(val, (list, tuple)): - return str(list(val)) - return str(val) - return ", ".join( [ - f"{key}={format_val(val)}" + f"{key}='{val}'" if isinstance(val, str) else f"{key}={val}" for key, val in function_options.items() if val is not None ] @@ -145,70 +148,73 @@ def format_val(val): def create_bq_remote_function( self, - routine_ref: bigquery.RoutineReference, - udf_def: udf_def.RemoteFunctionConfig, - maybe_reuse: bool, - try_create_connection: bool, + 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.""" - - if maybe_reuse: - existing_rf_spec = self.get_remote_function_specs(routine_ref) - if existing_rf_spec and existing_rf_spec == udf_def: - logger.info(f"Remote function {str(routine_ref)} already exists.") - return - - if try_create_connection: - self._create_bq_connection(udf_def.connection_id, routine_ref.project) + 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 = [] + bq_function_return_type = output_type + + # We are expecting 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_}") remote_function_options = { - "endpoint": udf_def.endpoint, - "max_batching_rows": udf_def.max_batching_rows, + "endpoint": endpoint, + "max_batching_rows": max_batching_rows, } - if udf_def.bq_metadata: + if metadata: # We are using the description field to store this structured # bigframes specific metadata for the lack of a better option - remote_function_options["description"] = udf_def.bq_metadata + remote_function_options["description"] = metadata remote_function_options_str = self._format_function_options( remote_function_options ) - import bigframes.core.sql - import bigframes.core.utils - - # removes anything that isn't letter, number or underscore - _validate_routine_name(routine_ref.routine_id) - bq_function_name_escaped = bigframes.core.sql.identifier(routine_ref.routine_id) create_function_ddl = f""" - CREATE OR REPLACE FUNCTION `{routine_ref.project}.{routine_ref.dataset_id}`.{bq_function_name_escaped}({udf_def.signature.to_sql_input_signature()}) - RETURNS {udf_def.signature.with_devirtualize().output.sql_type} - REMOTE WITH CONNECTION `{routine_ref.project}.{self._bq_location}.{udf_def.connection_id}` + CREATE OR REPLACE FUNCTION `{self._gcp_project_id}.{self._bq_dataset}`.{bq_function_name}({','.join(bq_function_args)}) + RETURNS {bq_function_return_type} + REMOTE WITH CONNECTION `{self._gcp_project_id}.{self._bq_location}.{self._bq_connection_id}` OPTIONS ({remote_function_options_str})""" logger.info(f"Creating BQ remote function: {create_function_ddl}") - self._ensure_dataset_exists( - bigquery.DatasetReference(routine_ref.project, routine_ref.dataset_id) - ) + self._ensure_dataset_exists() self._create_bq_function(create_function_ddl) def provision_bq_managed_function( self, - routine_ref: bigquery.RoutineReference, - config: udf_def.ManagedFunctionConfig, + 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 ( - config.capture_references + capture_references and (python_version := _utils.get_python_version()) != _MANAGED_FUNC_PYTHON_VERSION ): @@ -218,27 +224,29 @@ def provision_bq_managed_function( ) # Create BQ managed function. - bq_function_args = config.signature.to_sql_input_signature() - bq_function_return_type = config.signature.with_devirtualize().output.sql_type + 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 config.max_batching_rows: - managed_function_options["max_batching_rows"] = config.max_batching_rows - if config.container_cpu: - managed_function_options["container_cpu"] = config.container_cpu - if config.container_memory: - managed_function_options["container_memory"] = config.container_memory + 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( - config.code.package_requirements or [], - config.signature.is_row_processor, - config.capture_references, - ignore_package_version=True, + packages, is_row_processor, capture_references, ignore_package_version=True ) if packages: managed_function_options["packages"] = packages @@ -246,15 +254,27 @@ def provision_bq_managed_function( managed_function_options ) + 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"`{routine_ref.project}.{routine_ref.dataset_id}.{routine_ref.routine_id}`" + f"`{self._gcp_project_id}.{self._bq_dataset}`.{bq_function_name}" ) + udf_name = func.__name__ + with_connection_clause = ( ( - f"WITH CONNECTION `{routine_ref.project}.{self._bq_location}.{config.bq_connection_id}`" + f"WITH CONNECTION `{self._gcp_project_id}.{self._bq_location}.{self._bq_connection_id}`" ) - if config.bq_connection_id + if bq_connection_id else "" ) @@ -262,13 +282,13 @@ def provision_bq_managed_function( # including the user's function, necessary imports, and the BigQuery # handler wrapper. python_code_block = bff_template.generate_managed_function_code( - config.code, config.signature, config.capture_references + func, udf_name, is_row_processor, capture_references ) create_function_ddl = ( textwrap.dedent( f""" - CREATE OR REPLACE FUNCTION {persistent_func_id}({bq_function_args}) + CREATE OR REPLACE FUNCTION {persistent_func_id}({','.join(bq_function_args)}) RETURNS {bq_function_return_type} LANGUAGE python {with_connection_clause} @@ -282,24 +302,28 @@ def provision_bq_managed_function( .replace("__UDF_PLACE_HOLDER__", python_code_block) ) - self._ensure_dataset_exists( - bigquery.DatasetReference(routine_ref.project, routine_ref.dataset_id) - ) + 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." return self._cloud_functions_client.common_location_path( - self._gcp_project_id, self._cf_location + self._gcp_project_id, self._cloud_function_region ) def get_cloud_function_fully_qualified_name(self, name): "Get the fully qualilfied name for a cloud function." return self._cloud_functions_client.function_path( - self._gcp_project_id, self._cf_location, name + self._gcp_project_id, self._cloud_function_region, name ) - def get_cloud_function_endpoint(self, name) -> str | None: + def get_remote_function_fully_qualilfied_name(self, name): + "Get the fully qualilfied name for a BQ remote function." + return f"{self._gcp_project_id}.{self._bq_dataset}.{name}" + + def get_cloud_function_endpoint(self, name): """Get the http endpoint of a cloud function if it exists.""" fully_qualified_name = self.get_cloud_function_fully_qualified_name(name) try: @@ -311,69 +335,62 @@ def get_cloud_function_endpoint(self, name) -> str | None: pass return None - def _generate_cloud_function_code( + def generate_cloud_function_code( self, - code_def: udf_def.CodeDef, + def_, directory, *, - udf_signature: udf_def.UdfSignature, + input_types: Tuple[str], + output_type: str, + package_requirements=None, + is_row_processor=False, ): """Generate the cloud function code for a given user defined function.""" # requirements.txt - if code_def.package_requirements: + if package_requirements: requirements_txt = os.path.join(directory, "requirements.txt") with open(requirements_txt, "w") as f: - f.write("\n".join(code_def.package_requirements)) + f.write("\n".join(package_requirements)) # main.py entry_point = bff_template.generate_cloud_function_main_code( - code_def, + def_, directory, - udf_signature=udf_signature, + input_types=input_types, + output_type=output_type, + is_row_processor=is_row_processor, ) return entry_point - @google.api_core.retry.Retry( - predicate=google.api_core.retry.if_exception_type(ValueError), - initial=1.0, - maximum=10.0, - multiplier=2.0, - deadline=300.0, # Wait up to 5 minutes for propagation - ) - def _get_cloud_function_endpoint_with_retry(self, name): - endpoint = self.get_cloud_function_endpoint(name) - if not endpoint: - # Raising ValueError triggers the retry predicate - raise ValueError(f"Endpoint for {name} not yet available.") - return endpoint - def create_cloud_function( self, - name: str, - func_def: udf_def.CloudRunFunctionConfig, - ) -> str: + def_, + *, + random_name, + input_types: Tuple[str], + output_type: str, + package_requirements=None, + timeout_seconds=600, + max_instance_count=None, + is_row_processor=False, + vpc_connector=None, + memory_mib=1024, + ingress_settings="internal-only", + ): """Create a cloud function from the given user defined function.""" - config = func_def - # Build and deploy folder structure containing cloud function - with tempfile.TemporaryDirectory() as scratch_dir: - # Keep the generated sources in a subdirectory so the archive can be - # written inside the 0700 TemporaryDirectory. shutil.make_archive - # appends ".zip" to base_name, so archiving `directory` into itself - # would leave a world-readable copy of the (pickled) user code as a - # sibling of the temp dir that also survives the cleanup. - directory = os.path.join(scratch_dir, "src") - os.mkdir(directory) - entry_point = self._generate_cloud_function_code( - config.code, + with tempfile.TemporaryDirectory() as directory: + entry_point = self.generate_cloud_function_code( + def_, directory, - udf_signature=config.signature, - ) - archive_path = shutil.make_archive( - os.path.join(scratch_dir, "source"), "zip", directory + package_requirements=package_requirements, + input_types=input_types, + output_type=output_type, + is_row_processor=is_row_processor, ) + archive_path = shutil.make_archive(directory, "zip", directory) # We are creating cloud function source code from the currently running # python version. Use the same version to deploy. This is necessary @@ -386,7 +403,7 @@ def create_cloud_function( # Determine an upload URL for user code upload_url_request = functions_v2.GenerateUploadUrlRequest( - kms_key_name=config.kms_key_name + kms_key_name=self._cloud_function_kms_key_name ) upload_url_request.parent = self.get_cloud_function_fully_qualified_parent() upload_url_response = self._cloud_functions_client.generate_upload_url( @@ -411,9 +428,9 @@ def create_cloud_function( create_function_request.parent = ( self.get_cloud_function_fully_qualified_parent() ) - create_function_request.function_id = name + create_function_request.function_id = random_name function = functions_v2.Function() - function.name = self.get_cloud_function_fully_qualified_name(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 @@ -425,95 +442,57 @@ def create_cloud_function( function.build_config.source.storage_source.object_ = ( upload_url_response.storage_source.object_ ) - if config.docker_repository is not None: - function.build_config.docker_repository = config.docker_repository + function.build_config.docker_repository = ( + self._cloud_function_docker_repository + ) - if config.cloud_build_service_account is not None: + if self._cloud_build_service_account: canonical_cloud_build_service_account = ( - config.cloud_build_service_account - if "/" in config.cloud_build_service_account - else f"projects/{self._gcp_project_id}/serviceAccounts/{config.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 config.memory_mib is not None: - function.service_config.available_memory = f"{config.memory_mib}Mi" - if config.cpus is not None: - function.service_config.available_cpu = str(config.cpus) - if config.timeout_seconds is not None: - if config.timeout_seconds > 1200: + 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 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.", ) - function.service_config.timeout_seconds = config.timeout_seconds - if config.max_instance_count is not None: - function.service_config.max_instance_count = config.max_instance_count - if config.vpc_connector is not None: - function.service_config.vpc_connector = config.vpc_connector - vpc_connector_egress_settings = config.vpc_connector_egress_settings - if config.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 config.vpc_connector_egress_settings not in _VPC_EGRESS_SETTINGS_MAP: - raise bf_formatting.create_exception_with_feedback_link( - ValueError, - f"'{config.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], - ) - if config.cloud_run_service_account: - function.service_config.service_account_email = ( - config.cloud_run_service_account - ) - if config.concurrency: - function.service_config.max_instance_request_concurrency = ( - config.concurrency - ) - - # Functions framework use environment variables to pass config to gunicorn - # See https://github.com/GoogleCloudPlatform/functions-framework-python/issues/241 - # Code: https://github.com/GoogleCloudPlatform/functions-framework-python/blob/v3.10.1/src/functions_framework/_http/gunicorn.py#L37-L43 - env_vars = {} - if config.workers: - env_vars["WORKERS"] = str(config.workers) - if config.threads: - env_vars["THREADS"] = str(config.threads) - if env_vars: - function.service_config.environment_variables = env_vars - - if config.ingress_settings not in _INGRESS_SETTINGS_MAP: + 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 + function.service_config.service_account_email = ( + self._cloud_function_service_account + ) + if ingress_settings not in _INGRESS_SETTINGS_MAP: raise bf_formatting.create_exception_with_feedback_link( ValueError, - f"'{config.ingress_settings}' not one of the supported ingress settings values: {list(_INGRESS_SETTINGS_MAP)}", + 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, - _INGRESS_SETTINGS_MAP[config.ingress_settings], + _INGRESS_SETTINGS_MAP[ingress_settings], ) - if config.kms_key_name: - function.kms_key_name = config.kms_key_name + function.kms_key_name = self._cloud_function_kms_key_name create_function_request.function = function # Create the cloud function and wait for it to be ready to use - endpoint = None try: operation = self._cloud_functions_client.create_function( request=create_function_request ) - # operation.result() returns the Function object upon completion - function_obj = operation.result() - endpoint = function_obj.service_config.uri + operation.result() # Cleanup os.remove(archive_path) @@ -528,55 +507,143 @@ def create_cloud_function( # we created it. This error is safe to ignore. pass - # Fetch the endpoint with retries if it wasn't returned by the operation + # Fetch the endpoint of the just created function + endpoint = self.get_cloud_function_endpoint(random_name) if not endpoint: - try: - endpoint = self._get_cloud_function_endpoint_with_retry(name) - except Exception as e: - raise bf_formatting.create_exception_with_feedback_link( - ValueError, f"Couldn't fetch the http endpoint: {e}" - ) + raise bf_formatting.create_exception_with_feedback_link( + ValueError, "Couldn't fetch the http endpoint." + ) - logger.info(f"Successfully created cloud function {name} with uri ({endpoint})") + logger.info( + f"Successfully created cloud function {random_name} with uri ({endpoint})" + ) return endpoint - def get_remote_function_specs( - self, remote_function_name: bigquery.RoutineReference - ) -> udf_def.RemoteFunctionConfig | None: - """Check whether a remote function already exists for the udf.""" - try: - routine = self._bq_client.get_routine(str(remote_function_name)) - if routine.reference == remote_function_name: - try: - return udf_def.RemoteFunctionConfig.from_bq_routine(routine) - except udf_def.ReturnTypeMissingError: - # The remote function exists, but it's missing a return type. - # Something is wrong with the function, so we should replace it. - return None - except google.api_core.exceptions.NotFound: - # The dataset might not exist, in which case the remote function doesn't, either. - # Note: list_routines doesn't make an API request until we iterate on the response object. - pass - return None + def provision_bq_remote_function( + self, + def_, + input_types, + output_type, + reuse, + name, + package_requirements, + max_batching_rows, + cloud_function_timeout, + cloud_function_max_instance_count, + is_row_processor, + cloud_function_vpc_connector, + cloud_function_memory_mib, + cloud_function_ingress_settings, + bq_metadata, + ): + """Provision a BigQuery remote function.""" + # Augment user package requirements with any internal package + # requirements + package_requirements = _utils.get_updated_package_requirements( + package_requirements, is_row_processor + ) - def delete_routine(self, routine_name: bigquery.RoutineReference) -> None: - self._bq_client.delete_routine(str(routine_name), not_found_ok=True) + # Compute a unique hash representing the user code + 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 + # suffix to the intended function name to make it unique. + uniq_suffix = None + if not reuse: + # use 4 digits as a unique suffix which should suffice for + # uniqueness per session + uniq_suffix = "".join( + random.choices(string.ascii_lowercase + string.digits, k=4) + ) - def delete_cloud_function(self, cloud_function_name: str) -> None: - try: - self._cloud_functions_client.delete_function( - name=self.get_cloud_function_fully_qualified_name(cloud_function_name) + # Derive the name of the cloud function underlying the intended BQ + # remote function. Use the session id to identify the GCF for unnamed + # functions. The named remote functions are treated as a persistant + # artifacts, so let's keep them independent of session id, which also + # makes their naming more stable for the same udf code + session_id = None if name else self._session.session_id + cloud_function_name = _utils.get_cloud_function_name( + function_hash, session_id, uniq_suffix + ) + cf_endpoint = self.get_cloud_function_endpoint(cloud_function_name) + + # Create the cloud function if it does not exist + if not cf_endpoint: + cf_endpoint = self.create_cloud_function( + def_, + random_name=cloud_function_name, + input_types=input_types, + output_type=output_type, + package_requirements=package_requirements, + timeout_seconds=cloud_function_timeout, + max_instance_count=cloud_function_max_instance_count, + is_row_processor=is_row_processor, + vpc_connector=cloud_function_vpc_connector, + memory_mib=cloud_function_memory_mib, + ingress_settings=cloud_function_ingress_settings, ) - except google.api_core.exceptions.NotFound: - # The dataset might not exist, in which case the remote function doesn't, either. - pass + else: + logger.info(f"Cloud function {cloud_function_name} already exists.") + + # Derive the name of the remote function + remote_function_name = name + if not 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) + + # Create the BQ remote function in following circumstances: + # 1. It does not exist + # 2. It exists but the existing remote function has different + # configuration than intended + created_new = False + if not rf_endpoint or ( + rf_endpoint != cf_endpoint or rf_conn != self._bq_connection_id + ): + input_args = inspect.getargs(def_.__code__).args + if len(input_args) != len(input_types): + 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, + input_types, + output_type, + cf_endpoint, + remote_function_name, + max_batching_rows, + bq_metadata, + ) + + created_new = True + else: + logger.info(f"Remote function {remote_function_name} already exists.") + return remote_function_name, cloud_function_name, created_new -def _validate_routine_name(name: str) -> None: - """Validate that the given name is a valid BigQuery routine name.""" - # Routine IDs can contain only letters (a-z, A-Z), numbers (0-9), or underscores (_) - # must also start with a letter or underscore only - if not re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", name): - raise ValueError( - "Routine ID can contain only letters (a-z, A-Z), numbers (0-9), or underscores (_)" + def get_remote_function_specs(self, remote_function_name): + """Check whether a remote function already exists for the udf.""" + http_endpoint = None + bq_connection = None + routines = self._bq_client.list_routines( + f"{self._gcp_project_id}.{self._bq_dataset}" ) + try: + for routine in routines: + routine = cast(bigquery.Routine, routine) + if routine.reference.routine_id == remote_function_name: + rf_options = routine.remote_function_options + if rf_options: + http_endpoint = rf_options.endpoint + bq_connection = rf_options.connection + if bq_connection: + bq_connection = os.path.basename(bq_connection) + break + except google.api_core.exceptions.NotFound: + # The dataset might not exist, in which case the http_endpoint doesn't, either. + # Note: list_routines doesn't make an API request until we iterate on the response object. + pass + return (http_endpoint, bq_connection) diff --git a/bigframes/functions/_function_session.py b/bigframes/functions/_function_session.py index 2bc2b597372..90bfb89c561 100644 --- a/bigframes/functions/_function_session.py +++ b/bigframes/functions/_function_session.py @@ -15,123 +15,159 @@ from __future__ import annotations +import collections.abc import functools -import logging -import random -import string +import inspect +import sys import threading -import time -import warnings from typing import ( - TYPE_CHECKING, + Any, + cast, + Dict, + get_origin, Literal, + Mapping, Optional, Sequence, + TYPE_CHECKING, Union, ) +import warnings +import google.api_core.exceptions from google.cloud import ( bigquery, + bigquery_connection_v1, + functions_v2, + resourcemanager_v3, ) +from bigframes import clients import bigframes.exceptions as bfe import bigframes.formatting_helpers as bf_formatting -from bigframes import clients -from bigframes.functions import _function_client, _utils, udf_def from bigframes.functions import function as bq_functions -from bigframes.functions._utils import ( - _BIGFRAMES_FUNCTION_PREFIX, - _BQ_FUNCTION_NAME_SEPERATOR, - _GCF_FUNCTION_NAME_SEPERATOR, -) +from bigframes.functions import udf_def if TYPE_CHECKING: - from bigframes.session import anonymous_dataset - - -_DEFAULT_FUNCTION_MEMORY_MIB = 1024 + from bigframes.session import Session +import pandas -logger = logging.getLogger(__name__) +from bigframes.functions import _function_client, _utils class FunctionSession: """Session to manage bigframes functions.""" - def __init__( - self, - functions_client: _function_client.FunctionClient, - dataset_manager: anonymous_dataset.AnonymousDatasetManager, - default_connection: str, - location: str, - session_id: str, - manage_connections: bool, - ): - self._temp_cloud_functions: set[str] = set() - self._temp_remote_functions: set[bigquery.RoutineReference] = set() + def __init__(self): + # Session level mapping of function artifacts + self._temp_artifacts: Dict[str, str] = dict() # Lock to synchronize the update of the session artifacts self._artifacts_lock = threading.Lock() - self._deployed_routines: set[bytes] = set() - self._deploying_routines: set[bytes] = set() - - self._function_client: _function_client.FunctionClient = functions_client - self._dataset_manager: anonymous_dataset.AnonymousDatasetManager = ( - dataset_manager - ) - self._default_connection: str = default_connection - self._location: str = location - self._session_id: str = session_id - self._manage_connections: bool = manage_connections + 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 - @property - def session_id(self) -> str: - return self._session_id + 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 - @property - def default_dataset(self) -> bigquery.DatasetReference: - # We defer this as a property since this can actually take a query to determine - # which dataset it is. - return self._dataset_manager.dataset + 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. - """ - return ( - bigquery.DatasetReference.from_string( - dataset, default_project=self.default_dataset.project + """Resolves the dataset reference for the bigframes function.""" + if dataset: + dataset_ref = bigquery.DatasetReference.from_string( + dataset, default_project=bigquery_client.project ) - if dataset - else self.default_dataset - ) + else: + dataset_ref = session._anonymous_dataset + return dataset_ref - def _resolve_routine_reference( + def _resolve_cloud_functions_client( self, - function_name: str, - dataset: Optional[bigquery.DatasetReference] = None, - ) -> bigquery.RoutineReference: - """Resolves the routine reference for a BQ routine.""" - dataset_ref = dataset if dataset else self.default_dataset - return dataset_ref.routine(function_name) + 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 = self._default_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=self._location, + default_location=bq_location, ) # Guaranteed to be the form of .. ( @@ -145,90 +181,41 @@ def _resolve_bigquery_connection_id( "The project_id does not match BigQuery connection " f"gcp_project_id: {dataset_ref.project}.", ) - if bq_connection_location.casefold() != self._location.casefold(): + 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"{self._location}.", + f"{bq_location}.", ) return bq_connection_id - def _add_temp_cloud_function(self, gcf_path: str): - with self._artifacts_lock: - self._temp_cloud_functions.add(gcf_path) - - def _add_temp_remote_function(self, bqrf_routine: bigquery.RoutineReference): + def _update_temp_artifacts(self, bqrf_routine: str, gcf_path: str): + """Update function artifacts in the current session.""" with self._artifacts_lock: - self._temp_remote_functions.add(bqrf_routine) - - def _deploy_managed_function( - self, - config: udf_def.ManagedFunctionConfig, - name: str, - temp: bool, - dataset: Optional[bigquery.DatasetReference] = None, - ) -> udf_def.BigqueryUdf: - routine_ref = self._resolve_routine_reference(name, dataset=dataset) - if temp: - self._add_temp_remote_function(routine_ref) - self._function_client.provision_bq_managed_function( - routine_ref=routine_ref, config=config - ) - return udf_def.BigqueryUdf( - routine_ref=routine_ref, - signature=config.signature, - ) + self._temp_artifacts[bqrf_routine] = gcf_path - def _deploy_udf( + def clean_up( self, - bq_udf: udf_def.PythonUdf, - ) -> udf_def.BigqueryUdf: - """Deploys a UDF to BigQuery if not already deployed.""" - udf_hash = bq_udf.stable_hash() - - config = bq_udf.to_managed_function_config() - bq_function_name = get_managed_function_name(config, self.session_id) - routine_ref = self._resolve_routine_reference(bq_function_name) - while True: - with self._artifacts_lock: - if udf_hash in self._deployed_routines: - return udf_def.BigqueryUdf( - routine_ref=routine_ref, - signature=bq_udf.signature, - ) - - if udf_hash not in self._deploying_routines: - self._deploying_routines.add(udf_hash) - break - - time.sleep(0.1) - try: - self._function_client.provision_bq_managed_function( - routine_ref=routine_ref, config=config - ) - except Exception: - with self._artifacts_lock: - self._deploying_routines.discard(udf_hash) - raise - self._add_temp_remote_function(routine_ref) - with self._artifacts_lock: - self._deploying_routines.discard(udf_hash) - self._deployed_routines.add(udf_hash) - return udf_def.BigqueryUdf( - routine_ref=routine_ref, - signature=bq_udf.signature, - ) - - def clean_up(self): + bqclient: bigquery.Client, + gcfclient: functions_v2.FunctionServiceClient, + session_id: str, + ): """Delete function artifacts in the current session.""" with self._artifacts_lock: - for bqrf_routine in self._temp_remote_functions: - self._function_client.delete_routine(bqrf_routine) - for gcf_name in self._temp_cloud_functions: - self._function_client.delete_cloud_function(gcf_name) + for bqrf_routine, gcf_path in self._temp_artifacts.items(): + # Let's accept the possibility that the function may have been + # deleted directly by the user + bqclient.delete_routine(bqrf_routine, not_found_ok=True) + + 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_remote_functions.clear() - self._temp_cloud_functions.clear() + self._temp_artifacts.clear() # Inspired by @udf decorator implemented in ibis-bigquery package # https://github.com/ibis-project/ibis-bigquery/blob/main/ibis_bigquery/udf/__init__.py @@ -239,6 +226,13 @@ def remote_function( *, input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, + session: Optional[Session] = None, + bigquery_client: Optional[bigquery.Client] = None, + bigquery_connection_client: Optional[ + bigquery_connection_v1.ConnectionServiceClient + ] = None, + cloud_functions_client: Optional[functions_v2.FunctionServiceClient] = None, + resource_manager_client: Optional[resourcemanager_v3.ProjectsClient] = None, dataset: Optional[str] = None, bigquery_connection: Optional[str] = None, reuse: bool = True, @@ -247,15 +241,11 @@ def remote_function( 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] = 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] = None, - cloud_function_cpus: Optional[float] = None, + cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" ] = "internal-only", @@ -324,6 +314,24 @@ def remote_function( 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. + bigquery_connection_client (google.cloud.bigquery_connection_v1.ConnectionServiceClient, Optional): + Client to use for BigQuery connection operations. If this param is + not provided then bigquery connection client from the session would + be used. + cloud_functions_client (google.cloud.functions_v2.FunctionServiceClient, Optional): + Client to use for cloud functions operations. If this param is not + provided then the functions client from the session would be used. + resource_manager_client (google.cloud.resourcemanager_v3.ProjectsClient, Optional): + Client to use for cloud resource management operations, e.g. for + getting and setting IAM roles on cloud resources. If this param is + not provided then resource manager client from the session would be + used. dataset (str, Optional): Dataset in which to create a BigQuery remote function. It should be in `.` or `` format. If this @@ -417,13 +425,6 @@ 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 @@ -433,10 +434,6 @@ def remote_function( default memory of cloud functions be allocated, pass `None`. See for more details https://cloud.google.com/functions/docs/configuring/memory. - cloud_function_cpus (float, Optional): - The number of cpus to allocate for the cloud - function (2nd gen) created. - https://docs.cloud.google.com/run/docs/configuring/services/cpu. cloud_function_ingress_settings (str, Optional): Ingress settings controls dictating what traffic can reach the function. Options are: `all`, `internal-only`, or `internal-and-gclb`. @@ -454,6 +451,9 @@ def remote_function( 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. + session = self._resolve_session(session) + # If the user forces the cloud function service argument to None, throw # an exception if cloud_function_service_account is None: @@ -461,13 +461,36 @@ def remote_function( '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 client is required to perform BQ operations. + bigquery_client = self._resolve_bigquery_client(session, bigquery_client) + + # 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. + resource_manager_client = self._resolve_resource_manager_client( + session, resource_manager_client + ) + # 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. - dataset_ref = self._resolve_dataset_reference(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. # https://cloud.google.com/bigquery/docs/reference/standard-sql/remote-functions#create_a_remote_function bq_connection_id = self._resolve_bigquery_connection_id( - dataset_ref, bigquery_connection + session, dataset_ref, bq_location, bigquery_connection ) # If any CMEK is intended then check that a docker repository is also specified. @@ -481,16 +504,6 @@ def remote_function( " 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( @@ -498,138 +511,166 @@ def remote_function( ) warnings.warn(msg, category=UserWarning, stacklevel=2) + bq_connection_manager = session.bqconnectionmanager + def wrapper(func): nonlocal input_types, output_type - ### Step 1: Validate inputs and package into cloud run function, remote function defs. ### if not callable(func): raise bf_formatting.create_exception_with_feedback_link( TypeError, f"func must be a callable, got {func}" ) - udf_sig = _utils.get_func_signature( - func, - input_types, - output_type, - ).to_remote_function_compatible() + 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 - full_package_requirements = _utils.get_updated_package_requirements( - packages or [], udf_sig.is_row_processor - ) - memory_mib = cloud_function_memory_mib or _DEFAULT_FUNCTION_MEMORY_MIB - - # assumption is most bigframes functions are cpu bound, single-threaded and many won't release GIL - # therefore, want to allocate a worker for each cpu, and allow a concurrent request per worker - expected_milli_cpus = ( - int(cloud_function_cpus * 1000) - if (cloud_function_cpus is not None) - else _infer_milli_cpus_from_memory(memory_mib) + py_sig = inspect.signature( + func, + **signature_kwargs, ) - workers = -( - expected_milli_cpus // -1000 - ) # ceil(cpus) without invoking floats - threads = 4 # (per worker) - # max concurrency==1 for vcpus < 1 hard limit from cloud run - concurrency = (workers * threads) if (expected_milli_cpus >= 1000) else 1 - - ### Step 1: Create resources or fetch existing matching resources. ### - cloud_func_spec = udf_def.CloudRunFunctionConfig( - code=udf_def.CodeDef.from_func(func, full_package_requirements), - signature=udf_sig, - timeout_seconds=cloud_function_timeout, - max_instance_count=cloud_function_max_instances, - vpc_connector=cloud_function_vpc_connector, - vpc_connector_egress_settings=cloud_function_vpc_connector_egress_settings - or "private-ranges-only", - memory_mib=memory_mib, - cpus=cloud_function_cpus, - ingress_settings=cloud_function_ingress_settings, - workers=workers, - threads=threads, - concurrency=concurrency, - kms_key_name=cloud_function_kms_key_name, - docker_repository=cloud_function_docker_repository, + 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 + + remote_function_client = _function_client.FunctionClient( + dataset_ref.project, + bq_location, + dataset_ref.dataset_id, + bigquery_client, + bq_connection_id, + bq_connection_manager, + 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, - cloud_run_service_account=( - None - if (cloud_function_service_account == "default") - else cloud_function_service_account - ), + session=session, # type: ignore ) - uniq_suffix = None - if not reuse: - uniq_suffix = "".join( - random.choices(string.ascii_lowercase + string.digits, k=4) + + # resolve the output type that can be supported in the bigframes, + # ibis, BQ remote functions and cloud functions integration. + bqrf_metadata = None + 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. + bqrf_metadata = _utils.get_bigframes_metadata( + python_output_type=py_sig.return_annotation ) - cf_name = get_cloud_function_name( - cloud_func_spec, - # only session scope a temp unnamed function - session_id=self.session_id if (name is None) else None, - uniq_suffix=uniq_suffix, - ) - if not name: - self._add_temp_cloud_function(cf_name) - - # Create remote function that points at the cloud function - cf_endpoint = None - if reuse is not None: - cf_endpoint = self._function_client.get_cloud_function_endpoint(cf_name) - - # If the endpoint is empty, the function might exist but the URL propagation is pending. - # Running create_cloud_function will handle AlreadyExists and retry endpoint fetching. - if not cf_endpoint: - cf_endpoint = self._function_client.create_cloud_function( - cf_name, cloud_func_spec + post_process_routine = _utils.build_unnest_post_routine( + py_sig.return_annotation ) - else: - logger.info(f"Cloud function {cf_name} already exists.") + py_sig = py_sig.replace(return_annotation=str) - remote_function_config = udf_def.RemoteFunctionConfig( - endpoint=cf_endpoint, - connection_id=bq_connection_id, - max_batching_rows=max_batching_rows or 1000, - signature=udf_sig, - bq_metadata=udf_sig.protocol_metadata, + udf_sig = udf_def.UdfSignature.from_py_signature(py_sig) + + ( + rf_name, + cf_name, + created_new, + ) = remote_function_client.provision_bq_remote_function( + func, + input_types=udf_sig.sql_input_types, + output_type=udf_sig.sql_output_type, + reuse=reuse, + name=name, + package_requirements=packages, + max_batching_rows=max_batching_rows, + cloud_function_timeout=cloud_function_timeout, + 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_memory_mib=cloud_function_memory_mib, + cloud_function_ingress_settings=cloud_function_ingress_settings, + bq_metadata=bqrf_metadata, ) - remote_function_name = name or get_bigframes_function_name( - remote_function_config, - session_id=self.session_id, - uniq_suffix=uniq_suffix, + + bigframes_cloud_function = ( + remote_function_client.get_cloud_function_fully_qualified_name(cf_name) ) - routine_ref = self._resolve_routine_reference( - remote_function_name, dataset=dataset_ref + bigframes_bigquery_function = ( + remote_function_client.get_remote_function_fully_qualilfied_name( + rf_name + ) ) - if not name: - self._add_temp_remote_function(routine_ref) - self._function_client.create_bq_remote_function( - udf_def=remote_function_config, - routine_ref=routine_ref, - maybe_reuse=reuse, - try_create_connection=self._manage_connections, - ) + # If a new remote function was created, update the cloud artifacts + # created in the session. This would be used to clean up any + # resources in the session. Note that we need to do this only for + # the case where an explicit name was not provided by the user and + # we used an internal name. For the cases where the user provided an + # explicit name, we are assuming that the user wants to persist them + # with that name and would directly manage their lifecycle. + if created_new and (not name): + self._update_temp_artifacts( + bigframes_bigquery_function, bigframes_cloud_function + ) udf_definition = udf_def.BigqueryUdf( - routine_ref=routine_ref, + routine_ref=bigquery.RoutineReference.from_string( + bigframes_bigquery_function + ), signature=udf_sig, ) decorator = functools.wraps(func) - if udf_sig.is_row_processor: - msg = bfe.format_message("input_types=Series is in preview.") - warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) - - cf_full_path = ( - self._function_client.get_cloud_function_fully_qualified_name(cf_name) - ) - return decorator( - bq_functions.BigqueryCallableRoutine( - udf_definition, - self._function_client._bq_client, - cloud_function_ref=cf_full_path, - local_func=func, - is_managed=False, + 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 @@ -659,17 +700,17 @@ def deploy_remote_function( def udf( self, - input_types: type | Sequence[type] | None = None, - output_type: type | None = None, - dataset: str | None = None, - bigquery_connection: str | None = None, - name: str | None = None, - packages: Sequence[str] | None = None, - max_batching_rows: int | None = None, + 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, - *, - _force_deploy: bool = False, ): """Decorator to turn a Python user defined function (udf) into a BigQuery managed function. @@ -698,6 +739,12 @@ def udf( 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 `` @@ -749,16 +796,29 @@ def udf( """ 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(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(dataset_ref, bigquery_connection) + 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): @@ -769,57 +829,100 @@ def wrapper(func): TypeError, f"func must be a callable, got {func}" ) - udf_sig = _utils.get_func_signature( + 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, - input_types, - output_type, + **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 ) - code_def = udf_def.CodeDef.from_func(func, package_requirements=packages) - requirements = udf_def.RuntimeRequirements( + 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, - max_batching_rows=max_batching_rows, - packages=tuple(packages) if packages else (), ) - if udf_sig.is_row_processor: - msg = bfe.format_message("input_types=Series is in preview.") - warnings.warn(msg, stacklevel=1, category=bfe.PreviewWarning) - - if ( - not name and not dataset and not _force_deploy - ): # session-owned resource - deferred deployment - udf_definition = udf_def.PythonUdf( - signature=udf_sig, - code=code_def, - requirements=requirements, - ) - return bq_functions.UdfRoutine(func=func, _udf_def=udf_definition) - else: # deploy immediately - config = udf_def.ManagedFunctionConfig( - code=code_def, - signature=udf_sig, - max_batching_rows=max_batching_rows, - container_cpu=container_cpu, - container_memory=container_memory, - bq_connection_id=bq_connection_id, - capture_references=False, - ) - function_name = name or get_managed_function_name( - config, self.session_id + full_rf_name = ( + managed_function_client.get_remote_function_fully_qualilfied_name( + bq_function_name ) - rf_def = self._deploy_managed_function( - config, - name=function_name, - temp=(name is None), - dataset=dataset_ref, + ) + + 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 + ) ) - return bq_functions.BigqueryCallableRoutine( - rf_def, - self._function_client._bq_client, - local_func=func, - is_managed=True, + else: + return decorator( + bq_functions.BigqueryCallableRoutine( + udf_definition, + session, + local_func=func, + is_managed=True, + ) ) return wrapper @@ -846,67 +949,21 @@ def deploy_udf( A wrapped Python user defined function, usable in :meth:`~bigframes.series.Series.apply`. """ - return self.udf(_force_deploy=True, **kwargs)(func) - - -def get_cloud_function_name( - function_def: udf_def.CloudRunFunctionConfig, session_id=None, uniq_suffix=False -): - """ - Get a name for the cloud function for the given user defined function. - - If make_unique is True, append a random suffix to the name. - """ - parts = [_BIGFRAMES_FUNCTION_PREFIX] - if session_id: - parts.append(session_id) - parts.append(function_def.stable_hash().hex()) - if uniq_suffix: - parts.append(uniq_suffix) - return _GCF_FUNCTION_NAME_SEPERATOR.join(parts) - - -def get_bigframes_function_name( - function: udf_def.RemoteFunctionConfig, 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.stable_hash().hex()] - if uniq_suffix: - parts.append(uniq_suffix) - return _BQ_FUNCTION_NAME_SEPERATOR.join(parts) - - -def get_managed_function_name( - function_def: udf_def.ManagedFunctionConfig, - session_id: str | None = None, -): - """Get a name for the bigframes managed function for the given user defined function.""" - parts = [_BIGFRAMES_FUNCTION_PREFIX] - if session_id: - parts.append(session_id) - parts.append(function_def.stable_hash().hex()) - return _BQ_FUNCTION_NAME_SEPERATOR.join(parts) - - -def _infer_milli_cpus_from_memory(memory_mib: int) -> int: - # observed values, not formally documented by cloud run functions - if memory_mib < 128: - raise ValueError("Cloud run supports at minimum 128MiB per instance") - elif memory_mib == 128: - return 83 - elif memory_mib <= 256: - return 167 - elif memory_mib <= 512: - return 333 - elif memory_mib <= 1024: - return 583 - elif memory_mib <= 2048: - return 1000 - elif memory_mib <= 8192: - return 2000 - elif memory_mib <= 16384: - return 4000 - elif memory_mib <= 32768: - return 8000 - else: - raise ValueError("Cloud run supports at most 32768MiB per instance") + # 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: + only_param = next(iter(signature.parameters.values())) + param_type = only_param.annotation + if (param_type == bf_series.Series) or (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=[only_param.replace(annotation=str)]) + return None diff --git a/bigframes/functions/_utils.py b/bigframes/functions/_utils.py index 358f20b2ab4..b6dedeac504 100644 --- a/bigframes/functions/_utils.py +++ b/bigframes/functions/_utils.py @@ -13,26 +13,25 @@ # limitations under the License. -import collections import hashlib import inspect import json import sys import typing +from typing import Any, cast, Optional, Sequence, Set import warnings -from typing import Any, Mapping, Optional, Sequence, Set, cast 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 -from google.cloud import bigquery, functions_v2 -from packaging.requirements import Requirement import bigframes.exceptions as bfe import bigframes.formatting_helpers as bf_formatting -from bigframes.functions import function_typing, udf_def +from bigframes.functions import function_typing # Naming convention for the function artifacts _BIGFRAMES_FUNCTION_PREFIX = "bigframes" @@ -44,19 +43,25 @@ _pickle_protocol_version = 4 -def gcf_location_from_bq_location(bq_location: str) -> str: - """Get the cloud functions region that corresponds to a BQ location.""" - bq_location = bq_location.lower() +def get_remote_function_locations(bq_location): + """Get BQ location and cloud functions region given a BQ client.""" + # TODO(shobs, b/274647164): Find the best way to determine default location. + # For now let's assume that if no BQ location is set in the client then it + # defaults to US multi region + bq_location = bq_location.lower() if bq_location else "us" + + # Cloud function should be in the same region as the bigquery remote function + cloud_function_region = bq_location # BigQuery has multi region but cloud functions does not. # Any region in the multi region that supports cloud functions should work # https://cloud.google.com/functions/docs/locations if bq_location == "us": - return "us-central1" + cloud_function_region = "us-central1" elif bq_location == "eu": - return "europe-west1" + cloud_function_region = "europe-west1" - return bq_location + return bq_location, cloud_function_region def _package_existed(package_requirements: list[str], package: str) -> bool: @@ -70,12 +75,12 @@ def _package_existed(package_requirements: list[str], package: str) -> bool: def get_updated_package_requirements( - package_requirements: Sequence[str] = (), - is_row_processor: bool = False, - capture_references: bool = True, - ignore_package_version: bool = False, -) -> Sequence[str]: - requirements: list[str] = [] + package_requirements=None, + is_row_processor=False, + capture_references=True, + ignore_package_version=False, +): + requirements = [] if capture_references: requirements.append(f"cloudpickle=={cloudpickle.__version__}") @@ -103,14 +108,15 @@ def get_updated_package_requirements( requirements.append(f"numpy=={numpy.__version__}") if not requirements: - return list(package_requirements) + return package_requirements - result = list(package_requirements) + if not package_requirements: + package_requirements = [] for package in requirements: - if not _package_existed(result, package): - result.append(package) + if not _package_existed(package_requirements, package): + package_requirements.append(package) - return sorted(result) + return sorted(package_requirements) def clean_up_by_session_id( @@ -159,7 +165,7 @@ def clean_up_by_session_id( # Now clean up the cloud functions bq_location = bqclient.get_dataset(dataset).location - gcf_location = gcf_location_from_bq_location(bq_location) + bq_location, gcf_location = get_remote_function_locations(bq_location) parent_path = gcfclient.common_location_path( project=dataset.project, location=gcf_location ) @@ -177,11 +183,6 @@ def clean_up_by_session_id( pass -def routine_ref_to_string_for_query(routine_ref: bigquery.RoutineReference) -> str: - return f"`{routine_ref.project}.{routine_ref.dataset_id}`.{routine_ref.routine_id}" - - -# Deprecated: Use CodeDef.stable_hash() instead. 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 @@ -207,28 +208,46 @@ def get_hash(def_, package_requirements=None): return hashlib.md5(def_repr).hexdigest() -def get_python_output_type_str_from_bigframes_metadata( +def routine_ref_to_string_for_query(routine_ref: bigquery.RoutineReference) -> str: + return f"`{routine_ref.project}.{routine_ref.dataset_id}`.{routine_ref.routine_id}" + + +def get_cloud_function_name(function_hash, session_id=None, uniq_suffix=None): + "Get a name for the cloud function for the given user defined function." + parts = [_BIGFRAMES_FUNCTION_PREFIX] + if session_id: + parts.append(session_id) + parts.append(function_hash) + if uniq_suffix: + parts.append(uniq_suffix) + return _GCF_FUNCTION_NAME_SEPERATOR.join(parts) + + +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) + + +def get_python_output_type_from_bigframes_metadata( metadata_text: str, -) -> Optional[str]: +) -> Optional[type]: try: metadata_dict = json.loads(metadata_text) except (TypeError, json.decoder.JSONDecodeError): return None + try: - return metadata_dict["value"]["python_array_output_type"] + output_type = metadata_dict["value"]["python_array_output_type"] except KeyError: return None - -def get_python_output_type_from_bigframes_metadata( - metadata_text: str, -) -> Optional[type]: - output_type_str = get_python_output_type_str_from_bigframes_metadata(metadata_text) - for ( python_output_array_type ) in function_typing.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES: - if python_output_array_type.__name__ == output_type_str: + if python_output_array_type.__name__ == output_type: return list[python_output_array_type] # type: ignore return None @@ -246,9 +265,9 @@ def get_bigframes_metadata(*, python_output_type: Optional[type] = None) -> str: python_output_array_type in function_typing.RF_SUPPORTED_ARRAY_OUTPUT_PYTHON_TYPES ): - inner_metadata["python_array_output_type"] = ( - python_output_array_type.__name__ - ) + inner_metadata[ + "python_array_output_type" + ] = python_output_array_type.__name__ metadata = {"value": inner_metadata} metadata_ser = json.dumps(metadata) @@ -274,6 +293,20 @@ def get_python_version(is_compat: bool = False) -> str: 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], @@ -305,54 +338,3 @@ def has_conflict_output_type( return False return return_annotation != output_type - - -def get_func_signature( - func, - input_types: type | Sequence[type] | None = None, - output_type: type | None = None, -) -> udf_def.UdfSignature: - 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 = resolve_signature( - inspect.signature(func, **signature_kwargs), - input_types, - output_type, - ) - return udf_def.UdfSignature.from_py_signature(py_sig) - - -def resolve_signature( - py_sig: inspect.Signature, - input_types: type | Sequence[type] | None = None, - output_type: type | None = None, -) -> inspect.Signature: - if input_types is not None: - if not isinstance(input_types, collections.abc.Sequence): - input_types = [input_types] - if 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 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) - - return py_sig diff --git a/bigframes/functions/function.py b/bigframes/functions/function.py index b3a56dafcef..a62da57075b 100644 --- a/bigframes/functions/function.py +++ b/bigframes/functions/function.py @@ -14,19 +14,19 @@ from __future__ import annotations -import dataclasses import logging -from typing import TYPE_CHECKING, Callable, Optional, Protocol, Union, runtime_checkable +from typing import Callable, cast, get_origin, Optional, TYPE_CHECKING + +if TYPE_CHECKING: + from bigframes.session import Session + import bigframes.series import google.api_core.exceptions from google.cloud import bigquery import bigframes.formatting_helpers as bf_formatting from bigframes.functions import _function_session as bff_session -from bigframes.functions import function_typing, udf_def - -if TYPE_CHECKING: - from bigframes.session import Session +from bigframes.functions import _utils, function_typing, udf_def logger = logging.getLogger(__name__) @@ -63,9 +63,7 @@ def get_routine_reference( def remote_function(*args, **kwargs): - import bigframes - - function_session = bigframes.get_global_session()._function_session + function_session = bff_session.FunctionSession() return function_session.remote_function(*args, **kwargs) @@ -73,9 +71,7 @@ def remote_function(*args, **kwargs): def udf(*args, **kwargs): - import bigframes - - function_session = bigframes.get_global_session()._function_session + function_session = bff_session.FunctionSession() return function_session.udf(*args, **kwargs) @@ -83,33 +79,42 @@ def udf(*args, **kwargs): def _try_import_routine( - routine: bigquery.Routine, bq_client: bigquery.Client + 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 ) - return BigqueryCallableRoutine(udf_def, bq_client, is_managed=not is_remote) + 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, bq_client: bigquery.Client -) -> BigqueryCallableRoutine: - udf_def = _routine_as_udf_def(routine, is_row_processor=True) - + 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 ) - return BigqueryCallableRoutine(udf_def, bq_client, is_managed=not is_remote) + 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, is_row_processor: bool = False -) -> udf_def.BigqueryUdf: +def _routine_as_udf_def(routine: bigquery.Routine) -> udf_def.BigqueryUdf: try: - return udf_def.BigqueryUdf.from_routine( - routine, is_row_processor=is_row_processor - ) + 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." @@ -121,6 +126,31 @@ def _routine_as_udf_def( ) +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, *, @@ -148,23 +178,17 @@ def read_gbq_function( ValueError, f"Unknown function '{routine_ref}'." ) - # TODO(493293086): Deprecate is_row_processor. + if is_row_processor and len(routine.arguments) > 1: + raise bf_formatting.create_exception_with_feedback_link( + ValueError, + "A multi-input function cannot be a row processor. A row processor function " + "takes in a single input representing the row.", + ) + if is_row_processor: - return _try_import_row_routine(routine, bigquery_client) + return _try_import_row_routine(routine, session) else: - return _try_import_routine(routine, bigquery_client) - - -@runtime_checkable -class Udf(Protocol): - """ - Protocol for all BigFrames user-defined functions. - - Has @runtime_checkable so functions like df.apply() can dispatch UDFs with isinstance() checks. - """ - - @property - def udf_def(self) -> Union[udf_def.BigqueryUdf, udf_def.PythonUdf]: ... + return _try_import_routine(routine, session) class BigqueryCallableRoutine: @@ -177,14 +201,18 @@ class BigqueryCallableRoutine: def __init__( self, udf_def: udf_def.BigqueryUdf, - bq_client: bigquery.Client, + 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._bq_client = bq_client + self._session = session + self._post_routine = post_routine self._local_fun = local_func self._cloud_function = cloud_function_ref self._is_managed = is_managed @@ -193,17 +221,13 @@ 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 - from bigframes.core.compile.sqlglot import sql as sg_sql - args_string = ", ".join([sg_sql.to_sql(sg_sql.literal(v)) for v in args]) + args_string = ", ".join(map(bf_sql.simple_literal, args)) sql = f"SELECT `{str(self._udf_def.routine_ref)}`({args_string})" - row_iterator = bf_io_bigquery.start_query_job_optional( - self._bq_client, - sql=sql, - job_config=bigquery.QueryJobConfig(), - ) # type: ignore - return list(row_iterator.to_arrow().to_pydict().values())[0][0] + iter, job = bf_io_bigquery.start_query_with_client(self._session.bqclient, sql=sql, query_with_job=True, job_config=bigquery.QueryJobConfig()) # type: ignore + return list(iter.to_arrow().to_pydict().values())[0][0] @property def bigframes_bigquery_function(self) -> str: @@ -215,7 +239,7 @@ def bigframes_remote_function(self): @property def is_row_processor(self) -> bool: - return self.udf_def.signature.is_row_processor + return False @property def udf_def(self) -> udf_def.BigqueryUdf: @@ -227,27 +251,97 @@ def bigframes_cloud_function(self) -> Optional[str]: @property def input_dtypes(self): - return tuple(arg.bf_type for arg in self.udf_def.signature.inputs) + return self.udf_def.signature.bf_input_types @property def output_dtype(self): - return self.udf_def.signature.output.bf_type + return self.udf_def.signature.bf_output_type @property def bigframes_bigquery_function_output_dtype(self): - return self.udf_def.signature.output.emulating_type.bf_type + 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 -@dataclasses.dataclass(frozen=True) -class UdfRoutine: - func: Callable - # Try not to depend on this, bq managed function creation will be deferred later - # And this ref will be replaced with requirements rather to support lazy creation - _udf_def: Union[udf_def.BigqueryUdf, udf_def.PythonUdf] +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): - return self.func(*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()) # 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 udf_def(self) -> Union[udf_def.BigqueryUdf, udf_def.PythonUdf]: + 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 598de7c853d..5f04fcc8e25 100644 --- a/bigframes/functions/function_template.py +++ b/bigframes/functions/function_template.py @@ -19,12 +19,18 @@ import os import re import textwrap +from typing import Tuple -from bigframes.functions import udf_def +import cloudpickle logger = logging.getLogger(__name__) +# Protocol version 4 is available in python version 3.4 and above +# https://docs.python.org/3/library/pickle.html#data-stream-format +_pickle_protocol_version = 4 + + # Placeholder variables for testing. input_types = ("STRING",) output_type = "STRING" @@ -43,7 +49,7 @@ def convert_from_bq_json(type_, arg): import base64 import collections - converters = collections.defaultdict(lambda: lambda value: value) # type: ignore + converters = collections.defaultdict(lambda: (lambda value: value)) # type: ignore converters["BYTES"] = base64.b64decode converter = converters[type_] return converter(arg) if arg is not None else None @@ -53,7 +59,7 @@ def convert_to_bq_json(type_, arg): import base64 import collections - converters = collections.defaultdict(lambda: lambda value: value) # type: ignore + converters = collections.defaultdict(lambda: (lambda value: value)) # type: ignore converters["BYTES"] = lambda value: base64.b64encode(value).decode("utf-8") converter = converters[type_] return converter(arg) if arg is not None else None @@ -181,17 +187,15 @@ def udf_http_row_processor(request): import math import traceback - import pandas as pd from flask import jsonify + import pandas as pd try: request_json = request.get_json(silent=True) calls = request_json["calls"] replies = [] for call in calls: - reply = convert_to_bq_json( - output_type, udf(get_pd_series(call[0]), *call[1:]) - ) + reply = convert_to_bq_json(output_type, udf(get_pd_series(call[0]))) if type(reply) is list: # Since the BQ remote function does not support array yet, # return a json serialized version of the reply. @@ -222,39 +226,38 @@ def udf_http_row_processor(request): return jsonify({"errorMessage": traceback.format_exc()}), 400 -def generate_udf_code(code_def: udf_def.CodeDef, directory: str): +def generate_udf_code(def_, directory): """Generate serialized code using cloudpickle given a udf.""" udf_code_file_name = "udf.py" udf_pickle_file_name = "udf.cloudpickle" # original code, only for debugging purpose - if code_def.function_source: - udf_code_file_path = os.path.join(directory, udf_code_file_name) - with open(udf_code_file_path, "w") as f: - f.write(code_def.function_source) + udf_code = textwrap.dedent(inspect.getsource(def_)) + udf_code_file_path = os.path.join(directory, udf_code_file_name) + with open(udf_code_file_path, "w") as f: + f.write(udf_code) # serialized udf udf_pickle_file_path = os.path.join(directory, udf_pickle_file_name) # TODO(b/345433300): try io.BytesIO to avoid writing to the file system with open(udf_pickle_file_path, "wb") as f: - f.write(code_def.pickled_code) + cloudpickle.dump(def_, f, protocol=_pickle_protocol_version) return udf_code_file_name, udf_pickle_file_name def generate_cloud_function_main_code( - code_def: udf_def.CodeDef, - directory: str, + def_, + directory, *, - udf_signature: udf_def.UdfSignature, + input_types: Tuple[str], + output_type: str, + is_row_processor=False, ): """Get main.py code for the cloud function for the given user defined function.""" # Pickle the udf with all its dependencies - udf_code_file, udf_pickle_file = generate_udf_code(code_def, directory) - - input_types = tuple(arg.sql_type for arg in udf_signature.inputs) - output_type = udf_signature.output.sql_type + udf_code_file, udf_pickle_file = generate_udf_code(def_, directory) code_blocks = [ f"""\ @@ -273,7 +276,7 @@ def generate_cloud_function_main_code( # For converting scalar outputs to the correct type. code_blocks.append(inspect.getsource(convert_to_bq_json)) - if udf_signature.is_row_processor: + if is_row_processor: code_blocks.append(inspect.getsource(get_pd_series)) handler_func_name = "udf_http_row_processor" code_blocks.append(inspect.getsource(udf_http_row_processor)) @@ -292,95 +295,52 @@ def generate_cloud_function_main_code( def generate_managed_function_code( - code_def: udf_def.CodeDef, - signature: udf_def.UdfSignature, + def_, + udf_name: str, + is_row_processor: bool, capture_references: bool, ) -> str: """Generates the Python code block for managed Python UDF.""" - udf_name = "unpickled_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({code_def.pickled_code!r}) + {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. - assert code_def.function_source is not None - assert code_def.entry_point is not None - func_code = code_def.function_source - udf_name = code_def.entry_point + 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 signature.is_row_processor: + 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)) - """ + f"""def bigframes_handler(str_arg): + return {udf_name}({get_pd_series.__name__}(str_arg))""" ) - - params = list(arg.name for arg in signature.inputs) - 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(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(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 code_def.package_requirements: - # Include package requirements as comments to help force a new - # BigQuery UDF definition when only package requirements change. - packages_comment = "# Packages: " + ", ".join( - sorted(code_def.package_requirements) + f"""def bigframes_handler(*args): + return {udf_name}(*args)""" ) - udf_code_block.append(packages_comment) - if not capture_references and signature.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) + udf_code_block = textwrap.dedent( + f"{udf_code}\n{func_code}\n{bigframes_handler_code}" + ) - return textwrap.dedent("\n".join(udf_code_block)) + return udf_code_block diff --git a/bigframes/functions/function_typing.py b/bigframes/functions/function_typing.py index a64b3992b68..44ee071001f 100644 --- a/bigframes/functions/function_typing.py +++ b/bigframes/functions/function_typing.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import Any, Type, get_args, get_origin +from typing import Any, get_args, get_origin, Type from google.cloud import bigquery @@ -60,28 +60,14 @@ 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}) " + f"'{type_}' must be one of the supported types ({supported_types}) " "or a list of one of those types." ) def sdk_type_from_python_type( - t: type, allow_lists: bool = True + 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) diff --git a/bigframes/functions/udf_def.py b/bigframes/functions/udf_def.py index 70e0406a6f6..078e45f32d4 100644 --- a/bigframes/functions/udf_def.py +++ b/bigframes/functions/udf_def.py @@ -14,17 +14,10 @@ from __future__ import annotations import dataclasses -import functools import inspect -import io -import os -import textwrap +from typing import cast, Optional import warnings -from typing import Any, Optional, Sequence, Type, cast, get_args, get_origin -import cloudpickle -import google_crc32c -import pandas as pd from google.cloud import bigquery import bigframes.dtypes @@ -32,586 +25,149 @@ import bigframes.formatting_helpers as bf_formatting from bigframes.functions import function_typing -# Protocol version 4 is available in python version 3.4 and above -# https://docs.python.org/3/library/pickle.html#data-stream-format -_pickle_protocol_version = 4 - class ReturnTypeMissingError(ValueError): pass @dataclasses.dataclass(frozen=True) -class UdfArg: +class UdfField: name: str = dataclasses.field() - dtype: DirectScalarType | RowSeriesInputFieldV1 - - def __post_init__(self): - assert isinstance(self.name, str) - assert isinstance(self.dtype, (DirectScalarType, RowSeriesInputFieldV1)) + dtype: bigquery.StandardSqlDataType = dataclasses.field(hash=False, compare=False) @classmethod - def from_py_param(cls, param: inspect.Parameter) -> UdfArg: - if param.annotation == pd.Series: - return cls(param.name, RowSeriesInputFieldV1()) - return cls(param.name, DirectScalarType(param.annotation)) - - @classmethod - def from_sdk(cls, arg: bigquery.RoutineArgument) -> UdfArg: + def from_sdk(cls, arg: bigquery.RoutineArgument) -> UdfField: assert arg.name is not None - - if arg.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) - sdk_type = function_typing.DEFAULT_RF_TYPE - else: - sdk_type = arg.data_type - return cls(arg.name, DirectScalarType.from_sdk_type(sdk_type)) - - @property - def py_type(self) -> type: - return self.dtype.py_type - - @property - def bf_type(self) -> bigframes.dtypes.Dtype: - return self.dtype.bf_type - - @property - def sql_type(self) -> str: - return self.dtype.sql_type - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self.name.encode()) - hash_val.update(self.dtype.stable_hash()) - return hash_val.digest() + assert arg.data_type is not None + return cls(arg.name, arg.data_type) @dataclasses.dataclass(frozen=True) -class DirectScalarType: - """ - Represents a scalar value that is passed directly to the remote function. - - For these values, BigQuery handles the serialization and deserialization without any additional processing. - """ - - _py_type: type - - @property - def py_type(self) -> type: - return self._py_type +class UdfSignature: + input_types: tuple[UdfField, ...] = dataclasses.field() + output_bq_type: bigquery.StandardSqlDataType = dataclasses.field( + hash=False, compare=False + ) @property - def bf_type(self) -> bigframes.dtypes.Dtype: - return function_typing.sdk_type_to_bf_type( - function_typing.sdk_type_from_python_type(self._py_type) + 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 sql_type(self) -> str: - sdk_type = function_typing.sdk_type_from_python_type(self._py_type) - return function_typing.sdk_type_to_sql_string(sdk_type) - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self._py_type.__name__.encode()) - return hash_val.digest() - - @classmethod - def from_sdk_type(cls, sdk_type: bigquery.StandardSqlDataType) -> DirectScalarType: - return cls(function_typing.sdk_type_to_py_type(sdk_type)) - - @property - def emulating_type(self) -> DirectScalarType: - return self - - -@dataclasses.dataclass(frozen=True) -class VirtualListTypeV1: - """ - Represents a list of scalar values that is emulated as a JSON array string in the remote function. - - Only works as output paramter right now where array -> string in function runtime, and then string -> array in SQL post-processing (defined in out_expr()). - """ - - _PROTOCOL_ID = "virtual_list_v1" - - inner_dtype: DirectScalarType + def bf_output_type(self) -> bigframes.dtypes.Dtype: + return function_typing.sdk_type_to_bf_type(self.output_bq_type) @property - def py_type(self) -> Type[list[Any]]: - return list[self.inner_dtype.py_type] # type: ignore - - @property - def bf_type(self) -> bigframes.dtypes.Dtype: - return bigframes.dtypes.list_type(self.inner_dtype.bf_type) - - @property - def emulating_type(self) -> DirectScalarType: - # Regardless of list inner type, string is used to emulate the list in the remote function. - return DirectScalarType(str) - - def out_expr( - self, expr: bigframes.core.expression.Expression - ) -> bigframes.core.expression.Expression: - # essentially we are undoing json.dumps in sql - import bigframes.operations as ops - - as_str_list = ops.JSONValueArray(json_path="$").as_expr(expr) - if self.inner_dtype.py_type is str: - return as_str_list - elif self.inner_dtype.py_type is bool: - # hack so we don't need to make ArrayMap support general expressions yet - # with b/495513753 we can map the equality operator instead - return ops.ArrayMapOp(ops.IsInOp(values=("true",))).as_expr(as_str_list) - else: - return ops.ArrayMapOp(ops.AsTypeOp(self.inner_dtype.bf_type)).as_expr( - as_str_list - ) - - @property - def sql_type(self) -> str: - return f"ARRAY<{self.inner_dtype.sql_type}>" - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self._PROTOCOL_ID.encode()) - hash_val.update(self.inner_dtype.stable_hash()) - return hash_val.digest() - - -@dataclasses.dataclass(frozen=True) -class RowSeriesInputFieldV1: - """ - Used to handle functions that logically take a series as an input, but handled via a string protocol in the remote function. - - For these, the serialization is dependent on index metadata, which must be provided by the caller. - """ - - _PROTOCOL_ID = "row_series_input_v1" - - @property - def py_type(self) -> type: - return pd.Series - - @property - def bf_type(self) -> bigframes.dtypes.Dtype: - # Code paths shouldn't hit this. - raise ValueError("Series does not have a corresponding BigFrames type.") - - @property - def sql_type(self) -> str: - return "STRING" - - @property - def emulating_type(self) -> DirectScalarType: - # Regardless of list inner type, string is used to emulate the list in the remote function. - return DirectScalarType(str) - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self._PROTOCOL_ID.encode()) - return hash_val.digest() - - -@dataclasses.dataclass(frozen=True) -class UdfSignature: - """ - Represents the mapping of input types from bigframes to sql to python and back. - """ - - inputs: tuple[UdfArg, ...] = dataclasses.field() - output: DirectScalarType | VirtualListTypeV1 - - def __post_init__(self): - # Validate inputs and outputs are of the correct types. - assert all(isinstance(arg, UdfArg) for arg in self.inputs) - assert isinstance(self.output, (DirectScalarType, VirtualListTypeV1)) - - def to_sql_input_signature(self) -> str: - return ",".join( - f"{field.name} {field.sql_type}" - for field in self.with_devirtualize().inputs + 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 protocol_metadata(self) -> str | None: - import bigframes.functions._utils - - if isinstance(self.output, VirtualListTypeV1): - return bigframes.functions._utils.get_bigframes_metadata( - python_output_type=self.output.py_type - ) - return None + def py_output_type(self) -> type: + return function_typing.sdk_type_to_py_type(self.output_bq_type) @property - def is_virtual(self) -> bool: - dtypes = (self.output,) + tuple(arg.dtype for arg in self.inputs) - return not all(isinstance(dtype, DirectScalarType) for dtype in dtypes) + 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 is_row_processor(self) -> bool: - return any(isinstance(arg.dtype, RowSeriesInputFieldV1) for arg in self.inputs) + def sql_output_type(self) -> str: + return function_typing.sdk_type_to_sql_string(self.output_bq_type) - def with_devirtualize(self) -> UdfSignature: - return UdfSignature( - inputs=tuple( - UdfArg(arg.name, arg.dtype.emulating_type) for arg in self.inputs - ), - output=self.output.emulating_type, - ) - - # TODO(493293086): Deprecate is_row_processor. @classmethod - def from_routine( - cls, routine: bigquery.Routine, is_row_processor: bool = False - ) -> UdfSignature: - import bigframes.functions._utils - - ## Handle return type + def from_routine(cls, routine: bigquery.Routine) -> UdfSignature: if routine.return_type is None: - raise ReturnTypeMissingError( - f"Routine {routine} has no return type. Routine properties: {routine._properties}" - ) - + raise ReturnTypeMissingError bq_return_type = cast(bigquery.StandardSqlDataType, routine.return_type) - return_type: DirectScalarType | VirtualListTypeV1 = ( - DirectScalarType.from_sdk_type(bq_return_type) - ) if ( - python_output_type - := bigframes.functions._utils.get_python_output_type_from_bigframes_metadata( - routine.description - ) + bq_return_type.type_kind is None + or bq_return_type.type_kind + not in function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS ): - if 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: - inner_type = get_args(python_output_type)[0] - return_type = VirtualListTypeV1(DirectScalarType(inner_type)) - else: - raise bf_formatting.create_exception_with_feedback_link( - TypeError, - "Currently only list of a type is supported as python output type.", - ) + raise ValueError( + f"Remote function must have one of the following supported output types: {function_typing.RF_SUPPORTED_IO_BIGQUERY_TYPEKINDS}" + ) - ## Handle input types udf_fields = [] - - for i, argument in enumerate(routine.arguments): - if is_row_processor and i == 0: - if argument.data_type.type_kind == "STRING": - udf_fields.append(UdfArg(argument.name, RowSeriesInputFieldV1())) - else: - raise ValueError( - "Row processor functions must have STRING input type as first argument." - ) - udf_fields.append(UdfArg.from_sdk(argument)) + 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( - inputs=tuple(udf_fields), - output=return_type, + input_types=tuple(udf_fields), + output_bq_type=bq_return_type, ) @classmethod def from_py_signature(cls, signature: inspect.Signature): - import bigframes.series - - input_types: list[UdfArg] = [] + 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 udfs.", - ) - if parameter.annotation is bigframes.series.Series: - raise TypeError( - "Argument type hint must be Pandas Series, not BigFrames Series." + "Types are required to use @remote_function.", ) - - input_types.append(UdfArg.from_py_param(parameter)) + 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 " - "udfs.", + "@remote_function.", ) - - output_type = DirectScalarType(signature.return_annotation) - return cls(tuple(input_types), output_type) - - def to_remote_function_compatible(self) -> UdfSignature: - # need to virtualize list outputs - if isinstance(self.output, DirectScalarType): - if get_origin(self.output.py_type) is list: - inner_py_type = get_args(self.output.py_type)[0] - return UdfSignature( - inputs=self.inputs, - output=VirtualListTypeV1(DirectScalarType(inner_py_type)), - ) - return self - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - for input_type in self.inputs: - hash_val.update(input_type.stable_hash()) - hash_val.update(self.output.stable_hash()) - return hash_val.digest() - - -@dataclasses.dataclass(frozen=True) -class RuntimeRequirements: - container_cpu: Optional[float] = None - container_memory: Optional[str] = None - bq_connection_id: Optional[str] = None - max_batching_rows: Optional[int] = None - packages: tuple[str, ...] = () - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - if self.container_cpu is not None: - hash_val.update(str(self.container_cpu).encode()) - if self.container_memory is not None: - hash_val.update(str(self.container_memory).encode()) - if self.bq_connection_id is not None: - hash_val.update(str(self.bq_connection_id).encode()) - if self.max_batching_rows is not None: - hash_val.update(str(self.max_batching_rows).encode()) - if self.packages: - for p in sorted(self.packages): - hash_val.update(p.encode()) - return hash_val.digest() + 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: - """ - Represents the information needed to call a BigQuery remote function - not a full spec. - """ - routine_ref: bigquery.RoutineReference = dataclasses.field() signature: UdfSignature - - def with_devirtualize(self) -> BigqueryUdf: - if not self.signature.is_virtual: - return self - return BigqueryUdf( - routine_ref=self.routine_ref, - signature=self.signature.with_devirtualize(), - ) - - @classmethod - def from_routine( - cls, routine: bigquery.Routine, is_row_processor: bool = False - ) -> BigqueryUdf: - signature = UdfSignature.from_routine( - routine, is_row_processor=is_row_processor - ) - return cls(routine.reference, signature=signature) - - -@dataclasses.dataclass(frozen=True) -class PythonUdf: - """ - Represents user-requested Python UDF semantics, including the code and runtime requirements. - """ - - signature: UdfSignature - code: CodeDef - requirements: RuntimeRequirements = dataclasses.field( - default_factory=RuntimeRequirements + # 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 ) - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self.code.stable_hash()) - hash_val.update(self.signature.stable_hash()) - hash_val.update(self.requirements.stable_hash()) - return hash_val.digest() - - def to_managed_function_config(self) -> ManagedFunctionConfig: - return ManagedFunctionConfig( - code=self.code, - signature=self.signature, - max_batching_rows=self.requirements.max_batching_rows, - container_cpu=self.requirements.container_cpu, - container_memory=self.requirements.container_memory, - bq_connection_id=self.requirements.bq_connection_id, - capture_references=False, - ) - - -@dataclasses.dataclass(frozen=True) -class CodeDef: - # Produced by cloudpickle, not compatible across python versions - pickled_code: bytes - # This is just the function itself, and does not include referenced objects/functions/modules - function_source: Optional[str] - entry_point: Optional[str] - package_requirements: tuple[str, ...] - - @classmethod - def from_func(cls, func, package_requirements: Sequence[str] | None = None): - bytes_io = io.BytesIO() - cloudpickle.dump(func, bytes_io, protocol=_pickle_protocol_version) - source = None - entry_point = None - try: - # dedent is hacky, but works for some nested functions - source = textwrap.dedent(inspect.getsource(func)) - entry_point = func.__name__ - except OSError: - pass - return cls( - pickled_code=bytes_io.getvalue(), - function_source=source, - entry_point=entry_point, - package_requirements=tuple(package_requirements or []), - ) - - @functools.cache - def stable_hash(self) -> bytes: - # There is a known cell-id sensitivity of the cloudpickle serialization in - # notebooks https://github.com/cloudpipe/cloudpickle/issues/538. Because of - # this, if a cell contains a udf decorated with @remote_function, a unique - # cloudpickle code is generated every time the cell is run, creating new - # cloud artifacts every time. This is slow and wasteful. - # A workaround of the same can be achieved by replacing the filename in the - # code object to a static value - # https://github.com/cloudpipe/cloudpickle/issues/120#issuecomment-338510661. - # - # To respect the user code/environment let's make this modification on a - # copy of the udf, not on the original udf itself. - def_copy = cloudpickle.loads(self.pickled_code) - def_copy.__code__ = def_copy.__code__.replace( - co_filename="bigframes_place_holder_filename" - ) - - normalized_pickled_code = cloudpickle.dumps( - def_copy, protocol=_pickle_protocol_version + @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 ) - hash_val = google_crc32c.Checksum() - hash_val.update(normalized_pickled_code) - - if self.package_requirements: - for p in sorted(self.package_requirements): - hash_val.update(p.encode()) - - return hash_val.digest() - - def to_callable(self): - """ - Reconstructs the python callable from the pickled code. - - Assumption: package_requirements match local environment - """ - return cloudpickle.loads(self.pickled_code) - - -@dataclasses.dataclass(frozen=True) -class ManagedFunctionConfig: - code: CodeDef - signature: UdfSignature - max_batching_rows: Optional[int] - container_cpu: Optional[float] - container_memory: Optional[str] - bq_connection_id: Optional[str] - # capture_refernces=True -> deploy as cloudpickle - # capture_references=False -> deploy as source - capture_references: bool = False - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self.code.stable_hash()) - hash_val.update(self.signature.stable_hash()) - hash_val.update(str(self.max_batching_rows).encode()) - hash_val.update(str(self.container_cpu).encode()) - hash_val.update(str(self.container_memory).encode()) - hash_val.update(str(self.bq_connection_id).encode()) - hash_val.update(str(self.capture_references).encode()) - return hash_val.digest() - - -@dataclasses.dataclass(frozen=True) -class CloudRunFunctionConfig: - code: CodeDef - signature: UdfSignature - timeout_seconds: int | None - max_instance_count: int | None - vpc_connector: str | None - vpc_connector_egress_settings: str - memory_mib: int | None - cpus: float | None - ingress_settings: str - workers: int | None - threads: int | None - concurrency: int | None - kms_key_name: str | None - docker_repository: str | None - cloud_build_service_account: str | None - cloud_run_service_account: str | None - - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self.code.stable_hash()) - hash_val.update(self.signature.stable_hash()) - hash_val.update(str(self.timeout_seconds).encode()) - hash_val.update(str(self.max_instance_count).encode()) - hash_val.update(str(self.vpc_connector).encode()) - hash_val.update(str(self.vpc_connector_egress_settings).encode()) - hash_val.update(str(self.memory_mib).encode()) - hash_val.update(str(self.cpus).encode()) - hash_val.update(str(self.ingress_settings).encode()) - hash_val.update(str(self.workers).encode()) - hash_val.update(str(self.threads).encode()) - hash_val.update(str(self.concurrency).encode()) - hash_val.update(str(self.kms_key_name).encode()) - hash_val.update(str(self.docker_repository).encode()) - hash_val.update(str(self.cloud_build_service_account).encode()) - hash_val.update(str(self.cloud_run_service_account).encode()) - return hash_val.digest() - - -@dataclasses.dataclass(frozen=True) -class RemoteFunctionConfig: - """ - Represents the information needed to create a BigQuery remote function. - """ - - endpoint: str - signature: UdfSignature - connection_id: str - max_batching_rows: int - bq_metadata: str | None = None - @classmethod - def from_bq_routine(cls, routine: bigquery.Routine) -> RemoteFunctionConfig: - return cls( - endpoint=routine.remote_function_options.endpoint, - connection_id=os.path.basename(routine.remote_function_options.connection), - signature=UdfSignature.from_routine(routine), - max_batching_rows=routine.remote_function_options.max_batching_rows, - bq_metadata=routine.description, - ) + def from_routine(cls, routine: bigquery.Routine) -> BigqueryUdf: + signature = UdfSignature.from_routine(routine) - def stable_hash(self) -> bytes: - hash_val = google_crc32c.Checksum() - hash_val.update(self.endpoint.encode()) - hash_val.update(self.signature.stable_hash()) - hash_val.update(self.connection_id.encode()) - hash_val.update(str(self.max_batching_rows).encode()) - hash_val.update(str(self.bq_metadata).encode()) - return hash_val.digest() + 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 dc373216b65..f3558e4b34d 100644 --- a/bigframes/geopandas/geoseries.py +++ b/bigframes/geopandas/geoseries.py @@ -22,11 +22,11 @@ import bigframes.operations as ops import bigframes.series import bigframes.session -from bigframes._tools import docs -@docs.inherit_docs(vendored_geoseries.GeoSeries) -class GeoSeries(bigframes.series.Series): +class GeoSeries(vendored_geoseries.GeoSeries, bigframes.series.Series): + __doc__ = vendored_geoseries.GeoSeries.__doc__ + def __init__(self, data=None, index=None, **kwargs): super().__init__( data=data, index=index, dtype=geopandas.array.GeometryDtype(), **kwargs @@ -107,7 +107,7 @@ def buffer(self: GeoSeries, distance: float) -> bigframes.series.Series: # type @property def centroid(self: GeoSeries) -> bigframes.series.Series: # type: ignore - return self._apply_nary_op(ops.googlesql.ST_CENTROID, []) + return self._apply_unary_op(ops.geo_st_centroid_op) @property def convex_hull(self: GeoSeries) -> bigframes.series.Series: # type: ignore @@ -123,8 +123,3 @@ def distance(self: GeoSeries, other: GeoSeries) -> bigframes.series.Series: # t 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 368d272e7b4..b2c62ff9612 100644 --- a/bigframes/ml/__init__.py +++ b/bigframes/ml/__init__.py @@ -12,82 +12,19 @@ # 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. - -.. 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, -) +"""BigQuery DataFrames ML provides a SKLearn-like API on the BigQuery engine.""" __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 fbfaf6b537c..c36457d0b5e 100644 --- a/bigframes/ml/base.py +++ b/bigframes/ml/base.py @@ -15,30 +15,25 @@ """ 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 -import typing +from typing import cast, Optional, TypeVar, Union import warnings -from typing import Optional, TypeVar, Union 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 -from bigframes._tools import docs -from bigframes.ml import core -@docs.inherit_docs(bigframes_vendored.sklearn.base.BaseEstimator) -class BaseEstimator(abc.ABC): +class BaseEstimator(bigframes_vendored.sklearn.base.BaseEstimator, abc.ABC): """ A BigQuery DataFrames machine learning component follows sklearn API design Ref: https://bit.ly/3NyhKjN @@ -51,16 +46,12 @@ class 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. @@ -136,7 +127,7 @@ def register(self: _T, vertex_ai_model_id: Optional[str] = None) -> _T: self._bqml_model = self._create_bqml_model() # type: ignore except AttributeError: raise RuntimeError("A model must be trained before register.") - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) self._bqml_model.register(vertex_ai_model_id) return self @@ -251,13 +242,6 @@ 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): def _predict_and_retry( @@ -289,7 +273,7 @@ def _predict_and_retry( bpd.concat([df_result, df_succ]) if df_result is not None else df_succ ) - df_result = typing.cast( + df_result = cast( bpd.DataFrame, bpd.concat([df_result, df_fail]) if df_result is not None else df_fail, ) @@ -309,7 +293,7 @@ def _extract_output_names(self): output_names = [] for transform_col in self._bqml_model._model._properties["transformColumns"]: - transform_col_dict = typing.cast(dict, transform_col) + transform_col_dict = cast(dict, transform_col) # pass the columns that are not transformed if "transformSql" not in transform_col_dict: continue diff --git a/bigframes/ml/cluster.py b/bigframes/ml/cluster.py index f7a84a57e97..9ce4649c5e2 100644 --- a/bigframes/ml/cluster.py +++ b/bigframes/ml/cluster.py @@ -20,13 +20,13 @@ from typing import List, Literal, Optional, Union import bigframes_vendored.sklearn.cluster._kmeans -import pandas as pd from google.cloud import bigquery +import pandas as pd import bigframes -import bigframes.pandas as bpd -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.pandas as bpd _BQML_PARAMS_MAPPING = { "n_clusters": "numClusters", @@ -44,6 +44,7 @@ class KMeans( base.UnsupervisedTrainablePredictor, bigframes_vendored.sklearn.cluster._kmeans.KMeans, ): + __doc__ = bigframes_vendored.sklearn.cluster._kmeans.KMeans.__doc__ def __init__( diff --git a/bigframes/ml/compose.py b/bigframes/ml/compose.py index 0d6c58897ac..46d40d5fc8c 100644 --- a/bigframes/ml/compose.py +++ b/bigframes/ml/compose.py @@ -21,17 +21,16 @@ import re import types import typing -from typing import Iterable, List, Optional, Set, Tuple, Union +from typing import cast, Iterable, List, Optional, Set, Tuple, Union -import bigframes_vendored.sklearn.compose._column_transformer from bigframes_vendored import constants +import bigframes_vendored.sklearn.compose._column_transformer from google.cloud import bigquery -import bigframes.core.utils as core_utils -import bigframes.pandas as bpd -from bigframes.core.compile.sqlglot import sql as sg_sql -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter +import bigframes.core.compile.googlesql as sql_utils from bigframes.ml import base, core, globals, impute, preprocessing, utils +import bigframes.pandas as bpd _BQML_TRANSFROM_TYPE_MAPPING = types.MappingProxyType( { @@ -69,6 +68,7 @@ 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,17 +103,18 @@ 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(sg_sql.to_sql(sg_sql.identifier(column))) - current_target_column = sg_sql.to_sql( - sg_sql.identifier(self._target_column.format(column)) + current_sql = self._sql.format(sql_utils.identifier(column)) + current_target_column = sql_utils.identifier( + self._target_column.format(column) ) result.append(f"{current_sql} AS {current_target_column}") return result @@ -171,13 +172,7 @@ def _keys(self): @property def transformers_( self, - ) -> List[ - Tuple[ - str, - SingleColTransformer, - str, - ] - ]: + ) -> List[Tuple[str, SingleColTransformer, str,]]: """The collection of transformers as tuples of (name, transformer, column).""" result: List[ Tuple[ @@ -224,7 +219,7 @@ def camel_to_snake(name): output_names = [] for transform_col in bq_model._properties["transformColumns"]: - transform_col_dict = typing.cast(dict, transform_col) + transform_col_dict = cast(dict, transform_col) # pass the columns that are not transformed if "transformSql" not in transform_col_dict: continue @@ -288,7 +283,7 @@ def _merge( return self # SQLScalarColumnTransformer only work inside ColumnTransformer feature_columns_sorted = sorted( [ - typing.cast(str, feature_column.name) + cast(str, feature_column.name) for feature_column in bq_model.feature_columns ] ) diff --git a/bigframes/ml/core.py b/bigframes/ml/core.py index 5a096f305bd..73b8ba8dbcb 100644 --- a/bigframes/ml/core.py +++ b/bigframes/ml/core.py @@ -18,17 +18,16 @@ import dataclasses import datetime -import typing +from typing import Callable, cast, Iterable, Mapping, Optional, Union import uuid -from typing import Callable, Iterable, Mapping, Optional, Union from google.cloud import bigquery import bigframes.constants as constants import bigframes.formatting_helpers as formatting_helpers +from bigframes.ml import sql as ml_sql import bigframes.pandas as bpd import bigframes.session -from bigframes.ml import sql as ml_sql class BaseBqml: @@ -46,11 +45,7 @@ def ai_forecast( 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) + return self._session.read_gbq(result_sql) class BqmlModel(BaseBqml): @@ -100,17 +95,7 @@ def _apply_ml_tvf( ) result_sql = apply_sql_tvf(input_sql) - 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, - ) + df = self._session.read_gbq(result_sql, index_col=index_col_ids) if df._has_index: df.index.names = index_labels # Restore column labels @@ -174,10 +159,7 @@ def explain_predict( 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) + self._session.read_gbq(sql) .sort_values(by="attribution", ascending=False) .set_index("feature") ) @@ -252,49 +234,26 @@ def forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: sql = self._sql_generator.ml_forecast(struct_options=options) timestamp_col_name = "forecast_timestamp" index_cols = [timestamp_col_name] - # 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] + first_col_name = self._session.read_gbq(sql).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - # 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() + return self._session.read_gbq(sql, index_col=index_cols).reset_index() def explain_forecast(self, options: Mapping[str, int | float]) -> bpd.DataFrame: sql = self._sql_generator.ml_explain_forecast(struct_options=options) timestamp_col_name = "time_series_timestamp" index_cols = [timestamp_col_name] - # 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] + first_col_name = self._session.read_gbq(sql).columns.values[0] if timestamp_col_name != first_col_name: index_cols.append(first_col_name) - # 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() + return self._session.read_gbq(sql, index_col=index_cols).reset_index() def evaluate(self, input_data: Optional[bpd.DataFrame] = None): sql = self._sql_generator.ml_evaluate( input_data.sql if (input_data is not None) else None ) - # 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) + return self._session.read_gbq(sql) def llm_evaluate( self, @@ -303,37 +262,25 @@ def llm_evaluate( ): sql = self._sql_generator.ml_llm_evaluate(input_data.sql, task_type) - # 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) + return self._session.read_gbq(sql) def arima_evaluate(self, show_all_candidate_models: bool = False): sql = self._sql_generator.ml_arima_evaluate(show_all_candidate_models) - # 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) + return self._session.read_gbq(sql) def arima_coefficients(self) -> bpd.DataFrame: sql = self._sql_generator.ml_arima_coefficients() - # 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) + return self._session.read_gbq(sql) def centroids(self) -> bpd.DataFrame: assert self._model.model_type == "KMEANS" sql = self._sql_generator.ml_centroids() - # 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 + return self._session.read_gbq( + sql, index_col=["centroid_id", "feature"] ).reset_index() def principal_components(self) -> bpd.DataFrame: @@ -341,13 +288,8 @@ def principal_components(self) -> bpd.DataFrame: sql = self._sql_generator.ml_principal_components() - # 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, + return self._session.read_gbq( + sql, index_col=["principal_component_id", "feature"] ).reset_index() def principal_component_info(self) -> bpd.DataFrame: @@ -355,10 +297,7 @@ def principal_component_info(self) -> bpd.DataFrame: sql = self._sql_generator.ml_principal_component_info() - # 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) + return self._session.read_gbq(sql) def copy(self, new_model_name: str, replace: bool = False) -> BqmlModel: job_config = self._session._prepare_copy_job_config() @@ -377,7 +316,7 @@ def copy(self, new_model_name: str, replace: bool = False) -> BqmlModel: def register(self, vertex_ai_model_id: Optional[str] = None) -> BqmlModel: if vertex_ai_model_id is None: # vertex id needs to start with letters. https://cloud.google.com/vertex-ai/docs/general/resource-naming - vertex_ai_model_id = "bigframes_" + typing.cast(str, self._model.model_id) + vertex_ai_model_id = "bigframes_" + cast(str, self._model.model_id) # truncate as Vertex ID only accepts 63 characters, easily exceeding the limit for temp models. # The possibility of conflicts should be low. @@ -437,9 +376,8 @@ 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. - # This is a workaround for internal issue b/310266666. + # Cache dataframes to make sure base table is not a snapshot + # cached dataframe creates a full copy, never uses snapshot if y_train is None: input_data = X_train.reset_index(drop=True).cache() else: @@ -509,15 +447,15 @@ def create_time_series_model( transforms: Optional[Iterable[str]] = None, options: Mapping[str, Union[str, int, float, Iterable[str]]] = {}, ) -> BqmlModel: - assert X_train.columns.size == 1, ( - "Time series timestamp input must only contain 1 column." - ) - assert y_train.columns.size == 1, ( - "Time stamp data input must only contain 1 column." - ) - assert id_col is None or (id_col is not None and id_col.columns.size == 1), ( - "Time series id input is either None or must only contain 1 column." - ) + assert ( + X_train.columns.size == 1 + ), "Time series timestamp input must only contain 1 column." + assert ( + y_train.columns.size == 1 + ), "Time stamp data input must only contain 1 column." + assert id_col is None or ( + id_col is not None and id_col.columns.size == 1 + ), "Time series id input is either None or must only contain 1 column." options = dict(options) # Cache dataframes to make sure base table is not a snapshot diff --git a/bigframes/ml/decomposition.py b/bigframes/ml/decomposition.py index eedf6c09170..3ff32d24330 100644 --- a/bigframes/ml/decomposition.py +++ b/bigframes/ml/decomposition.py @@ -23,10 +23,10 @@ import bigframes_vendored.sklearn.decomposition._pca from google.cloud import bigquery +from bigframes.core import log_adapter +from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd import bigframes.session -from bigframes.core.logging import log_adapter -from bigframes.ml import base, core, globals, utils _BQML_PARAMS_MAPPING = { "svd_solver": "pcaSolver", @@ -226,6 +226,7 @@ def __init__( # 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`.") diff --git a/bigframes/ml/ensemble.py b/bigframes/ml/ensemble.py index 5d2b130d7ae..2633f134114 100644 --- a/bigframes/ml/ensemble.py +++ b/bigframes/ml/ensemble.py @@ -23,10 +23,10 @@ import bigframes_vendored.xgboost.sklearn from google.cloud import bigquery +from bigframes.core import log_adapter import bigframes.dataframe -import bigframes.session -from bigframes.core.logging import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.session _BQML_PARAMS_MAPPING = { "booster": "boosterType", @@ -213,6 +213,7 @@ class XGBClassifier( base.SupervisedTrainableWithEvaluationPredictor, bigframes_vendored.xgboost.sklearn.XGBClassifier, ): + __doc__ = bigframes_vendored.xgboost.sklearn.XGBClassifier.__doc__ def __init__( @@ -369,6 +370,7 @@ class RandomForestRegressor( base.SupervisedTrainableWithEvaluationPredictor, bigframes_vendored.sklearn.ensemble._forest.RandomForestRegressor, ): + __doc__ = bigframes_vendored.sklearn.ensemble._forest.RandomForestRegressor.__doc__ def __init__( @@ -534,6 +536,7 @@ class RandomForestClassifier( base.SupervisedTrainableWithEvaluationPredictor, bigframes_vendored.sklearn.ensemble._forest.RandomForestClassifier, ): + __doc__ = bigframes_vendored.sklearn.ensemble._forest.RandomForestClassifier.__doc__ def __init__( diff --git a/bigframes/ml/forecasting.py b/bigframes/ml/forecasting.py index bfdd736f855..d26abdfa712 100644 --- a/bigframes/ml/forecasting.py +++ b/bigframes/ml/forecasting.py @@ -20,10 +20,10 @@ from google.cloud import bigquery +from bigframes.core import log_adapter +from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd import bigframes.session -from bigframes.core.logging import log_adapter -from bigframes.ml import base, core, globals, utils _BQML_PARAMS_MAPPING = { "horizon": "horizon", diff --git a/bigframes/ml/imported.py b/bigframes/ml/imported.py index ca83b0ee568..a73ee352d03 100644 --- a/bigframes/ml/imported.py +++ b/bigframes/ml/imported.py @@ -16,15 +16,14 @@ from __future__ import annotations -import typing -from typing import Mapping, Optional +from typing import cast, Mapping, Optional from google.cloud import bigquery +from bigframes.core import log_adapter +from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd import bigframes.session -from bigframes.core.logging import log_adapter -from bigframes.ml import base, core, globals, utils @log_adapter.class_logger @@ -73,14 +72,13 @@ def predict(self, X: utils.ArrayType) -> bpd.DataFrame: Input DataFrame. Schema is defined by the model. Returns: - bigframes.dataframe.DataFrame: Output DataFrame. Schema is defined by the model. - """ + bigframes.dataframe.DataFrame: Output DataFrame. Schema is defined by the model.""" if not self._bqml_model: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) (X,) = utils.batch_convert_to_dataframe(X) @@ -101,7 +99,7 @@ def to_gbq(self, model_name: str, replace: bool = False) -> TensorFlowModel: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) new_model = self._bqml_model.copy(model_name, replace) return new_model.session.read_gbq_model(model_name) @@ -153,14 +151,13 @@ def predict(self, X: utils.ArrayType) -> bpd.DataFrame: Input DataFrame or Series. Schema is defined by the model. Returns: - bigframes.dataframe.DataFrame: Output DataFrame, schema is defined by the model. - """ + bigframes.dataframe.DataFrame: Output DataFrame, schema is defined by the model.""" if not self._bqml_model: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) (X,) = utils.batch_convert_to_dataframe(X, session=self._bqml_model.session) @@ -181,7 +178,7 @@ def to_gbq(self, model_name: str, replace: bool = False) -> ONNXModel: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) new_model = self._bqml_model.copy(model_name, replace) return new_model.session.read_gbq_model(model_name) @@ -273,14 +270,13 @@ def predict(self, X: utils.ArrayType) -> bpd.DataFrame: Input DataFrame or Series. Schema is defined by the model. Returns: - bigframes.dataframe.DataFrame: Output DataFrame. Schema is defined by the model. - """ + bigframes.dataframe.DataFrame: Output DataFrame. Schema is defined by the model.""" if not self._bqml_model: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) (X,) = utils.batch_convert_to_dataframe(X, session=self._bqml_model.session) @@ -301,7 +297,7 @@ def to_gbq(self, model_name: str, replace: bool = False) -> XGBoostModel: if self.model_path is None: raise ValueError("Model GCS path must be provided.") self._bqml_model = self._create_bqml_model() - self._bqml_model = typing.cast(core.BqmlModel, self._bqml_model) + self._bqml_model = cast(core.BqmlModel, self._bqml_model) new_model = self._bqml_model.copy(model_name, replace) return new_model.session.read_gbq_model(model_name) diff --git a/bigframes/ml/impute.py b/bigframes/ml/impute.py index 77314c360ad..f19c8e2cd36 100644 --- a/bigframes/ml/impute.py +++ b/bigframes/ml/impute.py @@ -22,10 +22,9 @@ import bigframes_vendored.sklearn.impute._base -import bigframes.core.utils as core_utils -import bigframes.pandas as bpd -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.pandas as bpd @log_adapter.class_logger @@ -33,6 +32,7 @@ class SimpleImputer( base.Transformer, bigframes_vendored.sklearn.impute._base.SimpleImputer, ): + __doc__ = bigframes_vendored.sklearn.impute._base.SimpleImputer.__doc__ def __init__( @@ -62,7 +62,6 @@ 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 d35a2d45ecb..3774a62c0cd 100644 --- a/bigframes/ml/linear_model.py +++ b/bigframes/ml/linear_model.py @@ -24,10 +24,10 @@ import bigframes_vendored.sklearn.linear_model._logistic from google.cloud import bigquery +from bigframes.core import log_adapter +from bigframes.ml import base, core, globals, utils import bigframes.pandas as bpd import bigframes.session -from bigframes.core.logging import log_adapter -from bigframes.ml import base, core, globals, utils _BQML_PARAMS_MAPPING = { "optimize_strategy": "optimizationStrategy", diff --git a/bigframes/ml/llm.py b/bigframes/ml/llm.py index 9e5ceb95d0e..eba15909b4c 100644 --- a/bigframes/ml/llm.py +++ b/bigframes/ml/llm.py @@ -16,20 +16,18 @@ from __future__ import annotations -import typing +from typing import cast, Iterable, Literal, Mapping, Optional, Union import warnings -from typing import Iterable, Literal, Mapping, Optional, Union import bigframes_vendored.constants as constants from google.cloud import bigquery +from bigframes import dtypes, exceptions import bigframes.bigquery as bbq +from bigframes.core import blocks, global_session, log_adapter import bigframes.dataframe -import bigframes.series -from bigframes import dtypes, exceptions -from bigframes.core import blocks, global_session -from bigframes.core.logging import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.series _BQML_PARAMS_MAPPING = { "max_iterations": "maxIterations", @@ -56,12 +54,6 @@ _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_3P1_FLASH_LITE_ENDPOINT = "gemini-3.1-flash-lite" -_GEMINI_3P5_FLASH_ENDPOINT = "gemini-3.5-flash" - _GEMINI_ENDPOINTS = ( _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, _GEMINI_1P5_PRO_FLASH_PREVIEW_ENDPOINT, @@ -72,11 +64,6 @@ _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_3P1_FLASH_LITE_ENDPOINT, - _GEMINI_3P5_FLASH_ENDPOINT, ) _GEMINI_PREVIEW_ENDPOINTS = ( _GEMINI_1P5_PRO_PREVIEW_ENDPOINT, @@ -97,11 +84,6 @@ _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_3P1_FLASH_LITE_ENDPOINT, - _GEMINI_3P5_FLASH_ENDPOINT, ) _CLAUDE_3_SONNET_ENDPOINT = "claude-3-sonnet" @@ -259,7 +241,7 @@ def predict( if len(X.columns) == 1: # BQML identified the column by name - col_label = typing.cast(blocks.Label, X.columns[0]) + col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "content"}) options: dict = {} @@ -292,7 +274,7 @@ class MultimodalEmbeddingGenerator(base.RetriableRemotePredictor): """Multimodal embedding generator LLM model. .. note:: - BigFrames ObjectRef is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + 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). @@ -380,7 +362,7 @@ def predict( 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. - The content column must be of string type or BigFrames `ObjectRef `_ of image or video. + The content column must be of string type or BigFrames Blob of image or video. max_retries (int, default 0): Max number of retries if the prediction for any rows failed. Each try needs to make progress (i.e. has successfully predicted rows) to continue the retry. @@ -398,12 +380,12 @@ def predict( if len(X.columns) == 1: # BQML identified the column by name - col_label = typing.cast(blocks.Label, X.columns[0]) + col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "content"}) # TODO(garrettwu): remove transform to ObjRefRuntime when BQML supports ObjRef as input if X["content"].dtype == dtypes.OBJ_REF_DTYPE: - X["content"] = bbq.obj.get_access_url(X["content"], mode="r") + X["content"] = X["content"].blob._get_runtime("R", with_metadata=True) options: dict = {} @@ -437,22 +419,20 @@ 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. + 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. Args: - model_name (str, Default to "gemini-2.5-flash"): + 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", "gemini-2.5-flash-lite", - "gemini-3.1-flash-lite" and "gemini-3.5-flash". - If no setting is provided, "gemini-2.5-flash" will be used by + "gemini-2.0-flash-lite-001", and "gemini-2.0-flash-001". + 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-1.5-X" is going to be deprecated. Please use gemini-2.0-X instead. For example, "gemini-2.0-flash-001". "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 @@ -482,11 +462,6 @@ def __init__( "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", - "gemini-3.1-flash-lite", - "gemini-3.5-flash", ] ] = None, session: Optional[bigframes.Session] = None, @@ -505,7 +480,7 @@ def __init__( warnings.warn(msg, category=exceptions.PreviewWarning) if model_name is None: - model_name = "gemini-2.5-flash" + model_name = "gemini-2.0-flash-001" msg = exceptions.format_message(_REMOVE_DEFAULT_MODEL_WARNING) warnings.warn(msg, category=FutureWarning, stacklevel=2) @@ -535,7 +510,7 @@ def _create_bqml_model(self): msg = exceptions.format_message( _MODEL_DEPRECATE_WARNING.format( model_name=self.model_name, - new_model_name="gemini-2.5-X", + new_model_name="gemini-2.0-X", link="https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.GeminiTextGenerator", ) ) @@ -614,10 +589,7 @@ def fit( options["prompt_col"] = X.columns.tolist()[0] self._bqml_model = self._bqml_model_factory.create_llm_remote_model( - X, - y, - options=options, - connection_name=typing.cast(str, self.connection_name), + X, y, options=options, connection_name=cast(str, self.connection_name) ) return self @@ -677,13 +649,13 @@ def predict( prompt (Iterable of str or bigframes.series.Series, or None, default None): .. note:: - BigFrames ObjectRef is subject to the "Pre-GA Offerings Terms" in the General Service Terms section of the + 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 `ObjectRef column(s) `_ of X, such as X["objectref_col"]. + 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 :. @@ -740,7 +712,7 @@ def predict( isinstance(item, bigframes.series.Series) and item.dtype == dtypes.OBJ_REF_DTYPE ): - item = bbq.obj.get_access_url(item, mode="r") + item = item.blob._get_runtime("R", with_metadata=True) df_prompt[label] = item df_prompt = df_prompt.drop(columns="bigframes_placeholder_col") @@ -748,7 +720,7 @@ def predict( if len(X.columns) == 1: # BQML identified the column by name - col_label = typing.cast(blocks.Label, X.columns[0]) + col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "prompt"}) options: dict = { @@ -833,8 +805,8 @@ def score( ) # BQML identified the column by name - X_col_label = typing.cast(blocks.Label, X.columns[0]) - y_col_label = typing.cast(blocks.Label, y.columns[0]) + 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"}) @@ -877,17 +849,13 @@ 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" (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-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-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" (deprecated) 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" (deprecated) is Anthropic's second-most powerful AI model, with strong performance on highly complex tasks. + "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 If no setting is provided, "claude-3-sonnet" will be used by default and a warning will be issued. @@ -1046,7 +1014,7 @@ def predict( if len(X.columns) == 1: # BQML identified the column by name - col_label = typing.cast(blocks.Label, X.columns[0]) + col_label = cast(blocks.Label, X.columns[0]) X = X.rename(columns={col_label: "prompt"}) options = { diff --git a/bigframes/ml/loader.py b/bigframes/ml/loader.py index 76975752457..a6366273fe6 100644 --- a/bigframes/ml/loader.py +++ b/bigframes/ml/loader.py @@ -20,7 +20,6 @@ import bigframes_vendored.constants as constants from google.cloud import bigquery -import bigframes.session from bigframes.ml import ( cluster, compose, @@ -36,6 +35,7 @@ preprocessing, utils, ) +import bigframes.session _BQML_MODEL_TYPE_MAPPING = MappingProxyType( { @@ -67,11 +67,6 @@ 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._GEMINI_3P1_FLASH_LITE_ENDPOINT: llm.GeminiTextGenerator, - llm._GEMINI_3P5_FLASH_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, diff --git a/bigframes/ml/metrics/_metrics.py b/bigframes/ml/metrics/_metrics.py index 1f69d60e317..c9639f4b16a 100644 --- a/bigframes/ml/metrics/_metrics.py +++ b/bigframes/ml/metrics/_metrics.py @@ -15,11 +15,9 @@ """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 Literal, Tuple, Union, overload +from typing import Tuple, Union import bigframes_vendored.constants as constants import bigframes_vendored.sklearn.metrics._classification as vendored_metrics_classification @@ -28,8 +26,8 @@ import numpy as np import pandas as pd -import bigframes.pandas as bpd from bigframes.ml import utils +import bigframes.pandas as bpd def r2_score( @@ -214,7 +212,7 @@ def confusion_matrix( y_true = row["y_true"] y_pred = row["y_pred"] count = row["dummy"] - confusion_matrix.at[y_true, y_pred] = count + confusion_matrix[y_pred][y_true] = count return confusion_matrix @@ -251,7 +249,7 @@ def recall_score( / is_accurate.groupby(y_true_series).count() ).to_pandas() - recall_score = pd.Series(0.0, index=index) + recall_score = pd.Series(0, index=index) for i in recall_score.index: recall_score.loc[i] = recall.loc[i] @@ -261,90 +259,43 @@ def recall_score( recall_score.__doc__ = inspect.getdoc(vendored_metrics_classification.recall_score) -@overload def precision_score( - y_true: bpd.DataFrame | bpd.Series, - y_pred: bpd.DataFrame | bpd.Series, - *, - 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, + y_true: Union[bpd.DataFrame, bpd.Series], + y_pred: Union[bpd.DataFrame, bpd.Series], *, - pos_label: int | float | bool | str = ..., - average: None = ..., -) -> pd.Series: ... - + average: typing.Optional[str] = "binary", +) -> 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) - 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 + is_accurate = y_true_series == y_pred_series unique_labels = ( - bpd.concat([y_true, y_pred], join="outer") + bpd.concat([y_true_series, y_pred_series], join="outer") .drop_duplicates() .sort_values(inplace=False) ) index = unique_labels.to_list() precision = ( - is_accurate.groupby(y_pred).sum() / is_accurate.groupby(y_pred).count() + is_accurate.groupby(y_pred_series).sum() + / is_accurate.groupby(y_pred_series).count() ).to_pandas() - precision_score = pd.Series(0.0, index=index) + precision_score = pd.Series(0, index=index) for i in precision.index: precision_score.loc[i] = precision.loc[i] return 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() +precision_score.__doc__ = inspect.getdoc( + vendored_metrics_classification.precision_score +) def f1_score( @@ -364,7 +315,7 @@ def f1_score( recall = recall_score(y_true_series, y_pred_series, average=None) precision = precision_score(y_true_series, y_pred_series, average=None) - f1_score = pd.Series(0.0, index=recall.index) + f1_score = pd.Series(0, index=recall.index) for index in recall.index: if precision[index] + recall[index] != 0: f1_score[index] = ( diff --git a/bigframes/ml/metrics/pairwise.py b/bigframes/ml/metrics/pairwise.py index 41785a8462d..658eef15aa5 100644 --- a/bigframes/ml/metrics/pairwise.py +++ b/bigframes/ml/metrics/pairwise.py @@ -17,9 +17,9 @@ import bigframes_vendored.sklearn.metrics.pairwise as vendored_metrics_pairwise +from bigframes.ml import utils import bigframes.operations as ops import bigframes.pandas as bpd -from bigframes.ml import utils def paired_cosine_distances( diff --git a/bigframes/ml/model_selection.py b/bigframes/ml/model_selection.py index 57e07d89301..6eba4f81c28 100644 --- a/bigframes/ml/model_selection.py +++ b/bigframes/ml/model_selection.py @@ -16,20 +16,19 @@ scikit-learn's model_selection module: https://scikit-learn.org/stable/modules/classes.html#module-sklearn.model_selection.""" + import inspect +from itertools import chain import time -import typing -from itertools import chain as _chain -from typing import Generator, List, Optional, Union +from typing import cast, Generator, List, Optional, Union import bigframes_vendored.sklearn.model_selection._split as vendored_model_selection_split import bigframes_vendored.sklearn.model_selection._validation as vendored_model_selection_validation import pandas as pd -import bigframes.pandas as bpd -from bigframes._tools import docs -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter from bigframes.ml import utils +import bigframes.pandas as bpd def train_test_split( @@ -40,6 +39,7 @@ def train_test_split( stratify: Union[bpd.Series, None] = None, shuffle: bool = True, ) -> List[Union[bpd.DataFrame, bpd.Series]]: + if test_size is None: if train_size is None: test_size = 0.25 @@ -71,7 +71,7 @@ def train_test_split( test_rows = total_rows - train_rows return list( - _chain.from_iterable( + chain.from_iterable( [ [bf_array.head(train_rows), bf_array.tail(test_rows)] for bf_array in bf_arrays @@ -99,10 +99,10 @@ def _stratify_split(df: bpd.DataFrame, stratify: bpd.Series) -> List[bpd.DataFra train_dfs.append(train) test_dfs.append(test) - train_df = typing.cast( + train_df = cast( bpd.DataFrame, bpd.concat(train_dfs).drop(columns="bigframes_stratify_col") ) - test_df = typing.cast( + test_df = cast( bpd.DataFrame, bpd.concat(test_dfs).drop(columns="bigframes_stratify_col") ) return [train_df, test_df] @@ -132,8 +132,9 @@ def _stratify_split(df: bpd.DataFrame, stratify: bpd.Series) -> List[bpd.DataFra @log_adapter.class_logger -@docs.inherit_docs(vendored_model_selection_split.KFold) -class KFold: +class KFold(vendored_model_selection_split.KFold): + __doc__ = inspect.getdoc(vendored_model_selection_split.KFold) + def __init__(self, n_splits: int = 5, *, random_state: Union[int, None] = None): if n_splits < 2: raise ValueError(f"n_splits must be at least 2. Got {n_splits}") diff --git a/bigframes/ml/pipeline.py b/bigframes/ml/pipeline.py index 59057fb2faf..dac51b19562 100644 --- a/bigframes/ml/pipeline.py +++ b/bigframes/ml/pipeline.py @@ -15,6 +15,7 @@ """For composing estimators together. This module is styled after scikit-learn's pipeline module: https://scikit-learn.org/stable/modules/pipeline.html.""" + from __future__ import annotations from typing import List, Optional, Tuple @@ -23,9 +24,8 @@ import bigframes_vendored.sklearn.pipeline from google.cloud import bigquery +from bigframes.core import log_adapter import bigframes.dataframe -import bigframes.session -from bigframes.core.logging import log_adapter from bigframes.ml import ( base, compose, @@ -35,6 +35,7 @@ preprocessing, utils, ) +import bigframes.session @log_adapter.class_logger diff --git a/bigframes/ml/preprocessing.py b/bigframes/ml/preprocessing.py index 28272fd6a02..2e8dc64a53b 100644 --- a/bigframes/ml/preprocessing.py +++ b/bigframes/ml/preprocessing.py @@ -18,7 +18,7 @@ from __future__ import annotations import typing -from typing import Iterable, List, Literal, Optional, Union +from typing import cast, Iterable, List, Literal, Optional, Union import bigframes_vendored.sklearn.preprocessing._data import bigframes_vendored.sklearn.preprocessing._discretization @@ -26,10 +26,9 @@ import bigframes_vendored.sklearn.preprocessing._label import bigframes_vendored.sklearn.preprocessing._polynomial -import bigframes.core.utils as core_utils -import bigframes.pandas as bpd -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.pandas as bpd @log_adapter.class_logger @@ -60,7 +59,6 @@ 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}" @@ -138,7 +136,6 @@ 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}" @@ -217,7 +214,6 @@ 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}" @@ -273,7 +269,9 @@ class KBinsDiscretizer( base.Transformer, bigframes_vendored.sklearn.preprocessing._discretization.KBinsDiscretizer, ): - __doc__ = bigframes_vendored.sklearn.preprocessing._discretization.KBinsDiscretizer.__doc__ + __doc__ = ( + bigframes_vendored.sklearn.preprocessing._discretization.KBinsDiscretizer.__doc__ + ) def __init__( self, @@ -306,7 +304,6 @@ 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: @@ -326,6 +323,7 @@ def _compile_to_sql( ] elif self.strategy == "quantile": + return [ self._base_sql_generator.ml_quantile_bucketize( column, self.n_bins, f"kbinsdiscretizer_{column}" @@ -435,7 +433,6 @@ 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 implementation always includes index 0, and top_k is on top of that. top_k = ( @@ -467,7 +464,7 @@ def _parse_from_sql(cls, sql: str) -> tuple[OneHotEncoder, str]: s = sql[sql.find("(") + 1 : sql.find(")")] col_label, drop_str, top_k, frequency_threshold = s.split(", ") drop = ( - typing.cast(Literal["most_frequent"], "most_frequent") + cast(Literal["most_frequent"], "most_frequent") if drop_str.lower() == "'most_frequent'" else None ) @@ -550,7 +547,6 @@ 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 = ( @@ -648,7 +644,6 @@ 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 f53ea645e92..b091c61f3f7 100644 --- a/bigframes/ml/remote.py +++ b/bigframes/ml/remote.py @@ -16,15 +16,14 @@ from __future__ import annotations -import warnings from typing import Mapping, Optional +import warnings +from bigframes.core import global_session, log_adapter import bigframes.dataframe import bigframes.exceptions as bfe -import bigframes.session -from bigframes.core import global_session -from bigframes.core.logging import log_adapter from bigframes.ml import base, core, globals, utils +import bigframes.session _REMOTE_MODEL_STATUS = "remote_model_status" diff --git a/bigframes/ml/sql.py b/bigframes/ml/sql.py index 894fc44b1b3..2937368c92c 100644 --- a/bigframes/ml/sql.py +++ b/bigframes/ml/sql.py @@ -21,7 +21,8 @@ import bigframes_vendored.constants as constants import google.cloud.bigquery -from bigframes.core.compile.sqlglot import sql as sg_sql +import bigframes.core.compile.googlesql as sql_utils +import bigframes.core.sql as sql_vals INDENT_STR = " " @@ -34,7 +35,7 @@ class BaseSqlGenerator: def encode_value(self, v: Union[str, int, float, Iterable[str]]) -> str: """Encode a parameter value for SQL""" if isinstance(v, (str, int, float)): - return sg_sql.to_sql(sg_sql.literal(v)) + return sql_vals.simple_literal(v) elif isinstance(v, Iterable): inner = ", ".join([self.encode_value(x) for x in v]) return f"[{inner}]" @@ -61,7 +62,7 @@ def build_structs(self, **kwargs: Union[int, float, str, Mapping]) -> str: v_trans = self.build_schema(**v) if isinstance(v, Mapping) else v param_strs.append( - f"{sg_sql.to_sql(sg_sql.literal(v_trans))} AS {sg_sql.to_sql(sg_sql.identifier(k))}" + f"{sql_vals.simple_literal(v_trans)} AS {sql_utils.identifier(k)}" ) return "\n" + INDENT_STR + f",\n{INDENT_STR}".join(param_strs) @@ -72,9 +73,7 @@ def build_expressions(self, *expr_sqls: str) -> str: def build_schema(self, **kwargs: str) -> str: """Encode a dict of values into a formatted schema type items for SQL""" - param_strs = [ - f"{sg_sql.to_sql(sg_sql.identifier(k))} {v}" for k, v in kwargs.items() - ] + 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) def options(self, **kwargs: Union[str, int, float, Iterable[str]]) -> str: @@ -87,9 +86,7 @@ def struct_options(self, **kwargs: Union[int, float, Mapping]) -> str: def struct_columns(self, columns: Iterable[str]) -> str: """Encode a BQ Table columns to a STRUCT.""" - columns_str = ", ".join( - map(lambda x: sg_sql.to_sql(sg_sql.identifier(x)), columns) - ) + columns_str = ", ".join(map(sql_utils.identifier, columns)) return f"STRUCT({columns_str})" def input(self, **kwargs: str) -> str: @@ -112,15 +109,15 @@ def transform(self, *expr_sqls: str) -> str: def ml_standard_scaler(self, numeric_expr_sql: str, name: str) -> str: """Encode ML.STANDARD_SCALER for BQML""" - return f"""ML.STANDARD_SCALER({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + return f"""ML.STANDARD_SCALER({sql_utils.identifier(numeric_expr_sql)}) OVER() AS {sql_utils.identifier(name)}""" def ml_max_abs_scaler(self, numeric_expr_sql: str, name: str) -> str: """Encode ML.MAX_ABS_SCALER for BQML""" - return f"""ML.MAX_ABS_SCALER({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + return f"""ML.MAX_ABS_SCALER({sql_utils.identifier(numeric_expr_sql)}) OVER() AS {sql_utils.identifier(name)}""" def ml_min_max_scaler(self, numeric_expr_sql: str, name: str) -> str: """Encode ML.MIN_MAX_SCALER for BQML""" - return f"""ML.MIN_MAX_SCALER({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + return f"""ML.MIN_MAX_SCALER({sql_utils.identifier(numeric_expr_sql)}) OVER() AS {sql_utils.identifier(name)}""" def ml_imputer( self, @@ -129,7 +126,7 @@ def ml_imputer( name: str, ) -> str: """Encode ML.IMPUTER for BQML""" - return f"""ML.IMPUTER({sg_sql.to_sql(sg_sql.identifier(col_name))}, '{strategy}') OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + return f"""ML.IMPUTER({sql_utils.identifier(col_name)}, '{strategy}') OVER() AS {sql_utils.identifier(name)}""" def ml_bucketize( self, @@ -143,7 +140,7 @@ def ml_bucketize( point.item() if hasattr(point, "item") else point for point in array_split_points ] - return f"""ML.BUCKETIZE({sg_sql.to_sql(sg_sql.identifier(input_id))}, {points}, FALSE) AS {sg_sql.to_sql(sg_sql.identifier(output_id))}""" + return f"""ML.BUCKETIZE({sql_utils.identifier(input_id)}, {points}, FALSE) AS {sql_utils.identifier(output_id)}""" def ml_quantile_bucketize( self, @@ -152,7 +149,7 @@ def ml_quantile_bucketize( name: str, ) -> str: """Encode ML.QUANTILE_BUCKETIZE for BQML""" - return f"""ML.QUANTILE_BUCKETIZE({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}, {num_bucket}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + return f"""ML.QUANTILE_BUCKETIZE({sql_utils.identifier(numeric_expr_sql)}, {num_bucket}) OVER() AS {sql_utils.identifier(name)}""" def ml_one_hot_encoder( self, @@ -163,9 +160,8 @@ def ml_one_hot_encoder( name: str, ) -> str: """Encode ML.ONE_HOT_ENCODER for BQML. - https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-one-hot-encoder for params. - """ - return f"""ML.ONE_HOT_ENCODER({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}, '{drop}', {top_k}, {frequency_threshold}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-one-hot-encoder for params.""" + return f"""ML.ONE_HOT_ENCODER({sql_utils.identifier(numeric_expr_sql)}, '{drop}', {top_k}, {frequency_threshold}) OVER() AS {sql_utils.identifier(name)}""" def ml_label_encoder( self, @@ -175,17 +171,15 @@ def ml_label_encoder( name: str, ) -> str: """Encode ML.LABEL_ENCODER for BQML. - https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-label-encoder for params. - """ - return f"""ML.LABEL_ENCODER({sg_sql.to_sql(sg_sql.identifier(numeric_expr_sql))}, {top_k}, {frequency_threshold}) OVER() AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-label-encoder for params.""" + return f"""ML.LABEL_ENCODER({sql_utils.identifier(numeric_expr_sql)}, {top_k}, {frequency_threshold}) OVER() AS {sql_utils.identifier(name)}""" def ml_polynomial_expand( self, columns: Iterable[str], degree: int, name: str ) -> str: """Encode ML.POLYNOMIAL_EXPAND. - https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-polynomial-expand - """ - return f"""ML.POLYNOMIAL_EXPAND({self.struct_columns(columns)}, {degree}) AS {sg_sql.to_sql(sg_sql.identifier(name))}""" + https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-polynomial-expand""" + return f"""ML.POLYNOMIAL_EXPAND({self.struct_columns(columns)}, {degree}) AS {sql_utils.identifier(name)}""" def ml_distance( self, @@ -196,9 +190,8 @@ def ml_distance( name: str, ) -> str: """Encode ML.DISTANCE for BQML. - https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-distance - """ - return f"""SELECT *, ML.DISTANCE({sg_sql.to_sql(sg_sql.identifier(col_x))}, {sg_sql.to_sql(sg_sql.identifier(col_y))}, '{type}') AS {sg_sql.to_sql(sg_sql.identifier(name))} FROM ({source_sql})""" + 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, @@ -206,8 +199,7 @@ def ai_forecast( 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 - """ + 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})""" @@ -220,7 +212,7 @@ def _model_id_sql( self, model_ref: google.cloud.bigquery.ModelReference, ): - return f"{sg_sql.to_sql(sg_sql.identifier(model_ref.project))}.{sg_sql.to_sql(sg_sql.identifier(model_ref.dataset_id))}.{sg_sql.to_sql(sg_sql.identifier(model_ref.model_id))}" + return f"{sql_utils.identifier(model_ref.project)}.{sql_utils.identifier(model_ref.dataset_id)}.{sql_utils.identifier(model_ref.model_id)}" # Model create and alter def create_model( @@ -311,7 +303,7 @@ def __init__(self, model_ref: google.cloud.bigquery.ModelReference): self._model_ref = model_ref def _model_ref_sql(self) -> str: - return f"{sg_sql.to_sql(sg_sql.identifier(self._model_ref.project))}.{sg_sql.to_sql(sg_sql.identifier(self._model_ref.dataset_id))}.{sg_sql.to_sql(sg_sql.identifier(self._model_ref.model_id))}" + return f"{sql_utils.identifier(self._model_ref.project)}.{sql_utils.identifier(self._model_ref.dataset_id)}.{sql_utils.identifier(self._model_ref.model_id)}" # Alter model def alter_model( diff --git a/bigframes/ml/utils.py b/bigframes/ml/utils.py index 134020b7167..80630c4f815 100644 --- a/bigframes/ml/utils.py +++ b/bigframes/ml/utils.py @@ -26,11 +26,11 @@ ) import bigframes_vendored.constants as constants -import pandas as pd from google.cloud import bigquery +import pandas as pd -import bigframes.pandas as bpd from bigframes.core import convert, guid +import bigframes.pandas as bpd from bigframes.session import Session # Internal type alias @@ -201,28 +201,10 @@ 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 - - # 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()} - ) + X = bpd.concat([X_train, X_eval]) + y = bpd.concat([y_train, y_eval]) # create options copy to not mutate the incoming one bqml_options = bqml_options.copy() diff --git a/bigframes/operations/__init__.py b/bigframes/operations/__init__.py index 6df8da69b11..e5888ace006 100644 --- a/bigframes/operations/__init__.py +++ b/bigframes/operations/__init__.py @@ -14,19 +14,8 @@ from __future__ import annotations -from bigframes.operations.ai_ops import ( - AIClassify, - AIEmbed, - AIGenerate, - AIGenerateBool, - AIGenerateDouble, - AIGenerateInt, - AIIf, - AIScore, - AISimilarity, -) from bigframes.operations.array_ops import ( - ArrayMapOp, + ArrayIndexOp, ArrayReduceOp, ArraySliceOp, ArrayToStringOp, @@ -41,10 +30,9 @@ UnaryOp, ) from bigframes.operations.blob_ops import ( - ObjGetAccessUrl, obj_fetch_metadata_op, - obj_make_ref_json_op, obj_make_ref_op, + ObjGetAccessUrl, ) from bigframes.operations.bool_ops import and_op, or_op, xor_op from bigframes.operations.comparison_ops import ( @@ -69,15 +57,15 @@ year_op, ) from bigframes.operations.datetime_ops import ( + date_op, StrftimeOp, + time_op, + timestamp_diff_op, ToDatetimeOp, ToTimestampOp, UnixMicros, UnixMillis, UnixSeconds, - date_op, - time_op, - timestamp_diff_op, ) from bigframes.operations.distance_ops import ( cosine_distance_op, @@ -91,35 +79,28 @@ ) from bigframes.operations.generic_ops import ( AsTypeOp, - CaseWhenOp, - CoerceToBoolOp, - DynamicGetItemOp, - GetItemOp, - IsInOp, - MapOp, - RowKey, - SqlScalarOp, case_when_op, + CaseWhenOp, clip_op, coalesce_op, - coerce_to_bool_op, fillna_op, hash_op, invert_op, + IsInOp, isnull_op, + MapOp, maximum_op, minimum_op, notnull_op, + RowKey, + SqlScalarOp, where_op, ) from bigframes.operations.geo_ops import ( - GeoStBufferOp, - GeoStDistanceOp, - GeoStLengthOp, - GeoStRegionStatsOp, - GeoStSimplifyOp, + 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, @@ -128,31 +109,26 @@ geo_st_isclosed_op, geo_x_op, geo_y_op, + GeoStBufferOp, + GeoStDistanceOp, + GeoStLengthOp, ) -from bigframes.operations.googlesql import GoogleSqlScalarOp from bigframes.operations.json_ops import ( - JSONDecode, JSONExtract, JSONExtractArray, JSONExtractStringArray, - JSONKeys, JSONQuery, JSONQueryArray, JSONSet, JSONValue, JSONValueArray, ParseJSON, - ToJSON, ToJSONString, ) from bigframes.operations.numeric_ops import ( - AddOp, - DivOp, - FloorDivOp, - MulOp, - SubOp, abs_op, add_op, + AddOp, arccos_op, arccosh_op, arcsin_op, @@ -164,15 +140,18 @@ cos_op, cosh_op, div_op, + DivOp, exp_op, expm1_op, floor_op, floordiv_op, + FloorDivOp, ln_op, log1p_op, log10_op, mod_op, mul_op, + MulOp, neg_op, pos_op, pow_op, @@ -181,24 +160,40 @@ sinh_op, sqrt_op, sub_op, + SubOp, tan_op, tanh_op, unsafe_pow_op, ) from bigframes.operations.numpy_op_maps import NUMPY_TO_BINOP, NUMPY_TO_OP from bigframes.operations.remote_function_ops import ( - PythonUdfOp, + BinaryRemoteFunctionOp, + NaryRemoteFunctionOp, RemoteFunctionOp, ) from bigframes.operations.string_ops import ( + capitalize_op, EndsWithOp, + isalnum_op, + isalpha_op, + isdecimal_op, + isdigit_op, + islower_op, + isnumeric_op, + isspace_op, + isupper_op, + len_op, + lower_op, RegexReplaceStrOp, ReplaceStrOp, + reverse_op, StartsWithOp, + strconcat_op, StrContainsOp, StrContainsRegexOp, StrExtractOp, StrFindOp, + StrGetOp, StringSplitOp, StrLstripOp, StrPadOp, @@ -206,33 +201,19 @@ StrRstripOp, StrSliceOp, StrStripOp, - ZfillOp, - capitalize_op, - isalnum_op, - isalpha_op, - isdecimal_op, - isdigit_op, - islower_op, - isnumeric_op, - isspace_op, - isupper_op, - len_op, - lower_op, - reverse_op, - strconcat_op, 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 ( - ToTimedeltaOp, date_add_op, date_sub_op, timedelta_floor_op, timestamp_add_op, timestamp_sub_op, + ToTimedeltaOp, ) -from bigframes.operations.to_op import func_to_expr __all__ = [ # Base ops @@ -249,8 +230,6 @@ "clip_op", "coalesce_op", "fillna_op", - "DynamicGetItemOp", - "GetItemOp", "hash_op", "invert_op", "IsInOp", @@ -259,8 +238,6 @@ "maximum_op", "minimum_op", "notnull_op", - "CoerceToBoolOp", - "coerce_to_bool_op", "RowKey", "SqlScalarOp", "where_op", @@ -286,6 +263,7 @@ "StrContainsRegexOp", "StrExtractOp", "StrFindOp", + "StrGetOp", "StrLstripOp", "StringSplitOp", "strip_op", @@ -369,36 +347,34 @@ "tanh_op", "unsafe_pow_op", # Array ops + "ArrayIndexOp", "ArraySliceOp", "ArrayToStringOp", # Blob ops "ObjGetAccessUrl", - "obj_make_ref_json_op", "obj_make_ref_op", "obj_fetch_metadata_op", # Struct ops "StructFieldOp", "StructOp", # Remote Functions ops + "BinaryRemoteFunctionOp", + "NaryRemoteFunctionOp", "RemoteFunctionOp", - "PythonUdfOp", # Frequency ops "DatetimeToIntegerLabelOp", "FloorDtOp", "IntegerLabelToDatetimeOp", # JSON ops - "JSONDecode", "JSONExtract", "JSONExtractArray", "JSONExtractStringArray", - "JSONKeys", "JSONQuery", "JSONQueryArray", "JSONSet", "JSONValue", "JSONValueArray", "ParseJSON", - "ToJSON", "ToJSONString", # Bool ops "and_op", @@ -417,7 +393,9 @@ "euclidean_distance_op", "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", @@ -425,31 +403,14 @@ "geo_st_geogpoint_op", "geo_st_intersection_op", "geo_st_isclosed_op", + "GeoStBufferOp", + "GeoStLengthOp", "geo_x_op", "geo_y_op", - "GeoStBufferOp", "GeoStDistanceOp", - "GeoStLengthOp", - "GeoStRegionStatsOp", - "GeoStSimplifyOp", - # AI ops - "AIClassify", - "AIGenerate", - "AIGenerateBool", - "AIGenerateDouble", - "AIGenerateInt", - "AIEmbed", - "AIIf", - "AIScore", - "AISimilarity", - # Helper functions - "func_to_expr", # Numpy ops mapping "NUMPY_TO_BINOP", "NUMPY_TO_OP", "ToArrayOp", "ArrayReduceOp", - "ArrayMapOp", - # GoogleSql - "GoogleSqlScalarOp", ] diff --git a/bigframes/operations/_matplotlib/__init__.py b/bigframes/operations/_matplotlib/__init__.py index caacadf5fed..5f99d3b50a4 100644 --- a/bigframes/operations/_matplotlib/__init__.py +++ b/bigframes/operations/_matplotlib/__init__.py @@ -22,8 +22,6 @@ 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 06fb5235d78..a5f53b9f647 100644 --- a/bigframes/operations/_matplotlib/core.py +++ b/bigframes/operations/_matplotlib/core.py @@ -55,12 +55,7 @@ def _kind(self): @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." - ) + return None def __init__(self, data, **kwargs) -> None: self.kwargs = kwargs @@ -97,10 +92,6 @@ 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" @@ -111,17 +102,14 @@ class BarPlot(SamplingPlot): def _kind(self) -> typing.Literal["bar"]: return "bar" - -class BarhPlot(SamplingPlot): - @property - def _kind(self) -> typing.Literal["barh"]: - return "barh" - - -class PiePlot(SamplingPlot): @property - def _kind(self) -> typing.Literal["pie"]: - return "pie" + 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." + ) class LinePlot(SamplingPlot): @@ -135,10 +123,6 @@ 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/_op_converters.py b/bigframes/operations/_op_converters.py index 14417a24f6e..3ebf22bcb6a 100644 --- a/bigframes/operations/_op_converters.py +++ b/bigframes/operations/_op_converters.py @@ -15,10 +15,10 @@ import bigframes.operations as ops -def convert_index(key: int) -> ops.GetItemOp: +def convert_index(key: int) -> ops.ArrayIndexOp: if key < 0: raise NotImplementedError("Negative indexing is not supported.") - return ops.GetItemOp(key=key) + return ops.ArrayIndexOp(index=key) def convert_slice(key: slice) -> ops.ArraySliceOp: diff --git a/bigframes/operations/aggregations.py b/bigframes/operations/aggregations.py index f7b89b949a8..6889997a103 100644 --- a/bigframes/operations/aggregations.py +++ b/bigframes/operations/aggregations.py @@ -17,18 +17,13 @@ import abc import dataclasses import typing -from typing import TYPE_CHECKING, Callable, ClassVar, Iterable, Optional +from typing import ClassVar, Iterable, Optional -import numpy as np import pandas as pd import pyarrow as pa import bigframes.dtypes as dtypes import bigframes.operations.type as signatures -from bigframes.core import agg_expressions - -if TYPE_CHECKING: - from bigframes.core import expression @dataclasses.dataclass(frozen=True) @@ -65,14 +60,8 @@ def order_independent(self): return False @abc.abstractmethod - 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 + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + ... @dataclasses.dataclass(frozen=True) @@ -98,11 +87,13 @@ class AggregateOp(WindowOp): @property @abc.abstractmethod - def name(self) -> str: ... + def name(self) -> str: + ... @property @abc.abstractmethod - def arguments(self) -> int: ... + def arguments(self) -> int: + ... @property def order_independent(self): @@ -119,14 +110,6 @@ 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): @@ -134,23 +117,6 @@ 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): @@ -158,21 +124,6 @@ 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): @@ -187,10 +138,6 @@ 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 @@ -204,7 +151,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT return dtypes.TIMEDELTA_DTYPE if dtypes.is_numeric(input_types[0]): - if pd.api.types.is_bool_dtype(input_types[0]): # type: ignore + if pd.api.types.is_bool_dtype(input_types[0]): return dtypes.INT_DTYPE return input_types[0] @@ -223,7 +170,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # These will change if median is changed to exact implementation. 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]): # type: ignore + if pd.api.types.is_bool_dtype(input_types[0]): return dtypes.INT_DTYPE else: return input_types[0] @@ -259,11 +206,12 @@ 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") - return input_types[0] - - @property - def can_be_windowized(self): - return False + 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] @dataclasses.dataclass(frozen=True) @@ -282,10 +230,6 @@ 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): @@ -391,26 +335,9 @@ def skips_nulls(self): return True def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - 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 + return pd.ArrowDtype( + pa.list_(dtypes.bigframes_dtype_to_arrow_dtype(input_types[0])) + ) @dataclasses.dataclass(frozen=True) @@ -535,8 +462,6 @@ def implicitly_inherits_order(self): @dataclasses.dataclass(frozen=True) class DenseRankOp(UnaryWindowOp): - name: ClassVar[str] = "dense_rank" - @property def skips_nulls(self): return False @@ -692,7 +617,7 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT # TODO: Alternative names and lookup from numpy function objects -_STRING_TO_AGG_OP: typing.Dict[ +_AGGREGATIONS_LOOKUP: typing.Dict[ str, typing.Union[UnaryAggregateOp, NullaryAggregateOp] ] = { op.name: op @@ -719,38 +644,17 @@ 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, -) -> 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__) +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] 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 00000000000..8c7628059a4 --- /dev/null +++ b/bigframes/operations/ai.py @@ -0,0 +1,848 @@ +# 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.display.progress_bar = None + >>> 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.display.progress_bar = None + >>> 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.display.progress_bar = None + >>> 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.display.progress_bar = None + >>> 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.display.progress_bar = None + >>> 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 + >>> bpd.options.display.progress_bar = None + + >>> 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, + ) + .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.display.progress_bar = None + >>> 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 deleted file mode 100644 index ad2b9850577..00000000000 --- a/bigframes/operations/ai_ops.py +++ /dev/null @@ -1,201 +0,0 @@ -# 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, 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 = None - endpoint: str | None = None - request_type: str | None = None - model_params: str | None = None - output_schema: str | None = 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 = None - endpoint: str | None = None - request_type: str | None = None - model_params: str | None = 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 = None - endpoint: str | None = None - request_type: str | None = None - model_params: str | None = 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 = None - endpoint: str | None = None - request_type: str | None = None - model_params: str | None = 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 AIEmbed(base_ops.UnaryOp): - name: ClassVar[str] = "ai_embed" - - endpoint: str | None = None - model: str | None = None - task_type: str | None = None - title: str | None = None - model_params: str | None = None - connection_id: str | None = None - - def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - return pd.ArrowDtype( - pa.struct( - ( - pa.field("result", pa.list_(pa.float64())), - 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 | None = None - endpoint: str | None = None - optimization_mode: str | None = None - max_error_ratio: float | None = None - - 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, ...] - examples: ( - tuple[tuple[str, str], ...] | tuple[tuple[str, tuple[str, ...]], ...] | None - ) = None - connection_id: str | None = None - endpoint: str | None = None - output_mode: str | None = None - optimization_mode: str | None = None - max_error_ratio: float | None = None - - def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - if self.output_mode is not None: - return dtypes.list_type(dtypes.STRING_DTYPE) - 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 | None = None - endpoint: str | None = None - max_error_ratio: float | None = None - - def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - return dtypes.FLOAT_DTYPE - - -@dataclasses.dataclass(frozen=True) -class AISimilarity(base_ops.BinaryOp): - name: ClassVar[str] = "ai_similarity" - - endpoint: str | None = None - model: str | None = None - model_params: str | None = None - connection_id: str | None = None - - 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 e6f5743989b..61ada59cc7b 100644 --- a/bigframes/operations/array_ops.py +++ b/bigframes/operations/array_ops.py @@ -32,6 +32,23 @@ def output_type(self, *input_types): return dtypes.STRING_DTYPE +@dataclasses.dataclass(frozen=True) +class ArrayIndexOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "array_index" + index: int + + def output_type(self, *input_types): + input_type = input_types[0] + if dtypes.is_string_like(input_type): + return dtypes.STRING_DTYPE + elif dtypes.is_array_like(input_type): + return dtypes.arrow_dtype_to_bigframes_dtype( + input_type.pyarrow_dtype.value_type + ) + else: + raise TypeError("Input type must be an array or string-like type.") + + @dataclasses.dataclass(frozen=True) class ArraySliceOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "array_slice" @@ -71,17 +88,3 @@ def output_type(self, *input_types): assert dtypes.is_array_like(input_type) inner_type = dtypes.get_array_inner_type(input_type) return self.aggregation.output_type(inner_type) - - -@dataclasses.dataclass(frozen=True) -class ArrayMapOp(base_ops.UnaryOp): - name: typing.ClassVar[str] = "array_map" - # TODO(b/495513753): Generalize to chained expressions - map_op: base_ops.UnaryOp - - 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) - out_inner_type = self.map_op.output_type(inner_type) - return dtypes.list_type(out_inner_type) diff --git a/bigframes/operations/base.py b/bigframes/operations/base.py new file mode 100644 index 00000000000..f2bbcb33201 --- /dev/null +++ b/bigframes/operations/base.py @@ -0,0 +1,306 @@ +# 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.core.utils as bf_utils +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): + block = data + elif isinstance(data, SeriesMethods): + block = data._get_block() + # special case where data is local scalar, but index is bigframes index (maybe very big) + elif ( + not bf_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 = pd.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 + + @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", 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.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.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 a3a0187d2d1..c0145a6711e 100644 --- a/bigframes/operations/base_ops.py +++ b/bigframes/operations/base_ops.py @@ -17,8 +17,8 @@ import dataclasses import typing -import bigframes.operations.type as op_typing from bigframes import dtypes +import bigframes.operations.type as op_typing if typing.TYPE_CHECKING: # Avoids circular dependency @@ -27,11 +27,11 @@ class RowOp(typing.Protocol): @property - def name(self) -> str: ... + def name(self) -> str: + ... - def output_type( - self, *input_types: dtypes.ExpressionType - ) -> dtypes.ExpressionType: ... + def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: + ... @property def is_monotonic(self) -> bool: @@ -168,7 +168,7 @@ def as_expr( def _convert_expr_input( - input: typing.Union[str, bigframes.core.expression.Expression], + input: typing.Union[str, bigframes.core.expression.Expression] ) -> bigframes.core.expression.Expression: """Allows creating column references with just a string""" import bigframes.core.expression diff --git a/bigframes/operations/blob.py b/bigframes/operations/blob.py index 3666ee66602..63875ded99a 100644 --- a/bigframes/operations/blob.py +++ b/bigframes/operations/blob.py @@ -14,36 +14,832 @@ from __future__ import annotations +import os +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.core import log_adapter import bigframes.dataframe +import bigframes.exceptions as bfe +from bigframes.operations import base import bigframes.operations as ops import bigframes.series -from bigframes.core.logging import log_adapter FILE_FOLDER_REGEX = r"^.*\/(.*)$" FILE_EXT_REGEX = r"(\.[0-9a-zA-Z]+$)" @log_adapter.class_logger -class _BlobAccessor: +class BlobAccessor(base.SeriesMethods): """ - Internal blob functions for Series and Index. + 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 __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def uri(self) -> bigframes.series.Series: + """URIs of the Blob. + + Returns: + bigframes.series.Series: URIs as string.""" + s = bigframes.series.Series(self._block) + + return s.struct.field("uri") + + def authorizer(self) -> bigframes.series.Series: + """Authorizers of the Blob. + + Returns: + bigframes.series.Series: Autorithers(connection) as string.""" + s = bigframes.series.Series(self._block) + + return s.struct.field("authorizer") + + def version(self) -> bigframes.series.Series: + """Versions of the Blob. + + Returns: + 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") + + def metadata(self) -> bigframes.series.Series: + """Retrieve the metadata of the Blob. + + Returns: + bigframes.series.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" + ) + import bigframes.bigquery as bbq + + return bbq.json_extract(details_json, "$.gcs_metadata").rename("metadata") + + def content_type(self) -> bigframes.series.Series: + """Retrieve the content type of the Blob. + + Returns: + bigframes.series.Series: string of the content type.""" + return ( + self.metadata() + ._apply_unary_op(ops.JSONValue(json_path="$.content_type")) + .rename("content_type") + ) + + def md5_hash(self) -> bigframes.series.Series: + """Retrieve the md5 hash of the Blob. + + Returns: + bigframes.series.Series: string of the md5 hash.""" + return ( + self.metadata() + ._apply_unary_op(ops.JSONValue(json_path="$.md5_hash")) + .rename("md5_hash") + ) + + def size(self) -> bigframes.series.Series: + """Retrieve the file size of the Blob. + + Returns: + bigframes.series.Series: file size in bytes.""" + return ( + self.metadata() + ._apply_unary_op(ops.JSONValue(json_path="$.size")) + .rename("size") + .astype("Int64") + ) + + def updated(self) -> bigframes.series.Series: + """Retrieve the updated time of the Blob. + + Returns: + bigframes.series.Series: updated time as UTC datetime.""" + import bigframes.pandas as bpd + + updated = ( + self.metadata() + ._apply_unary_op(ops.JSONValue(json_path="$.updated")) + .rename("updated") + .astype("Int64") + ) + + return bpd.to_datetime(updated, unit="us", utc=True) def _get_runtime( self, mode: str, with_metadata: bool = False ) -> bigframes.series.Series: - s = ( - self._data._apply_unary_op(ops.obj_fetch_metadata_op) - if with_metadata - else self._data - ) + """Retrieve the ObjectRefRuntime as JSON. + + Args: + mode (str): mode for the URLs, "R" for read, "RW" for read & write. + metadata (bool, default False): whether to fetch the metadata in the ObjectRefRuntime. + + Returns: + bigframes.series.Series: ObjectRefRuntime JSON. + """ + s = self._apply_unary_op(ops.obj_fetch_metadata_op) if with_metadata else self return s._apply_unary_op(ops.ObjGetAccessUrl(mode=mode)) - def _read_url(self) -> bigframes.series.Series: + 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 read_url(self) -> bigframes.series.Series: + """Retrieve the read URL of the Blob. + + Returns: + bigframes.series.Series: Read only URLs.""" return self._get_runtime(mode="R")._apply_unary_op( ops.JSONValue(json_path="$.access_urls.read_url") ) + + def write_url(self) -> bigframes.series.Series: + """Retrieve the write URL of the Blob. + + Returns: + 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 = "", + width: Optional[int] = None, + height: Optional[int] = None, + ): + """Display the blob content in the IPython Notebook environment. Only works for image type now. + + 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").to_frame() + + df["read_url"] = df["blob_col"].blob.read_url() + + if content_type: + df["content_type"] = content_type + else: + df["content_type"] = df["blob_col"].blob.content_type() + + pandas_df, _, query_job = df._block.retrieve_repr_request_results(n) + df._set_internal_query_job(query_job) + + def display_single_url( + read_url: str, content_type: Union[str, pd._libs.missing.NAType] + ): + if content_type is pd.NA: # 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, 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, width=width, height=height) + ) + else: # display as raw data + response = requests.get(read_url) + ipy_display.display(response.content) + + for _, row in pandas_df.iterrows(): + display_single_url(row["read_url"], row["content_type"]) + + @property + def session(self): + return self._block.session + + def _resolve_connection(self, connection: Optional[str] = None) -> str: + """Resovle the BigQuery connection. + + Args: + 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. + + Returns: + str: the resolved BigQuery connection string in the format: + "project.location.connection_id". + + Raises: + ValueError: If the connection cannot be resolved to a valid string. + """ + connection = connection or self._block.session._bq_connection + return clients.get_canonical_bq_connection_id( + connection, + default_project=self._block.session._project, + default_location=self._block.session._location, + ) + + def get_runtime_json_str( + self, mode: str = "R", *, with_metadata: bool = False + ) -> bigframes.series.Series: + """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 JSON string. Default to False. + + Returns: + str: the runtime object in the JSON string. + """ + 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", + ) -> 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. + + Returns: + bigframes.series.Series: JSON series of key-value pairs. + """ + 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 + + connection = self._resolve_connection(connection) + df = self.get_runtime_json_str(mode="R").to_frame() + + exif_udf = blob_func.TransformFunction( + blob_func.exif_func_def, + session=self._block.session, + connection=connection, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, + ).udf() + + res = self._df_apply_udf(df, exif_udf) + res = bbq.parse_json(res) + + return res + + 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 = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + ) -> bigframes.series.Series: + """Blurs images. + + Args: + ksize (tuple(int, int)): Kernel size. + 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 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. + + Returns: + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + """ + if engine is None or engine.casefold() != "opencv": + raise ValueError("Must specify the engine, supported value is 'opencv'.") + + import bigframes.blob._functions as blob_func + + connection = self._resolve_connection(connection) + 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, + connection=connection, + max_batching_rows=max_batching_rows, + container_cpu=container_cpu, + container_memory=container_memory, + ).udf() + + df["ksize_x"], df["ksize_y"] = ksize + df["ext"] = ext # type: ignore + res = self._df_apply_udf(df, image_blur_udf) + + return res + + if isinstance(dst, str): + dst = os.path.join(dst, "") + # Replace src folder with dst folder, keep the file names. + 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, + 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") + + df = df.join(dst_rt, how="outer") + df["ksize_x"], df["ksize_y"] = ksize + df["ext"] = ext # type: ignore + + res = self._df_apply_udf(df, image_blur_udf) + res.cache() # to execute the udf + + 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 = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + ): + """Resize images. + + 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): 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 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. + + Returns: + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + """ + 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: + raise ValueError( + "Only one of dsize or (fx, fy) parameters must be set. And the set values must be positive. " + ) + + import bigframes.blob._functions as blob_func + + connection = self._resolve_connection(connection) + 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, + 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["fx"], df["fy"] = fx, fy + df["ext"] = ext # type: ignore + res = self._df_apply_udf(df, image_resize_udf) + + return res + + if isinstance(dst, str): + dst = os.path.join(dst, "") + # Replace src folder with dst folder, keep the file names. + 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, + 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") + + df = df.join(dst_rt, how="outer") + df["dsize_x"], df["dsizye_y"] = dsize + df["fx"], df["fy"] = fx, fy + df["ext"] = ext # type: ignore + + res = self._df_apply_udf(df, image_resize_udf) + res.cache() # to execute the udf + + 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 = 8192, + container_cpu: Union[float, int] = 0.33, + container_memory: str = "512Mi", + ) -> bigframes.series.Series: + """Normalize images. + + 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): 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 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. + + Returns: + bigframes.series.Series: blob Series if destination is GCS. Or bytes Series if destination is BQ. + """ + if engine is None or engine.casefold() != "opencv": + raise ValueError("Must specify the engine, supported value is 'opencv'.") + + import bigframes.blob._functions as blob_func + + connection = self._resolve_connection(connection) + 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, + 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 + df["ext"] = ext # type: ignore + res = self._df_apply_udf(df, image_normalize_udf) + + return res + + if isinstance(dst, str): + dst = os.path.join(dst, "") + # Replace src folder with dst folder, keep the file names. + 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, + 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") + + df = df.join(dst_rt, how="outer") + df["alpha"] = alpha + df["beta"] = beta + df["norm_type"] = norm_type + df["ext"] = ext # type: ignore + + res = self._df_apply_udf(df, image_normalize_udf) + res.cache() # to execute the udf + + return dst + + def pdf_extract( + self, + *, + engine: Literal[None, "pypdf"] = None, + connection: Optional[str] = None, + max_batching_rows: int = 1, + container_cpu: Union[float, int] = 2, + container_memory: str = "1Gi", + verbose: bool = False, + ) -> bigframes.series.Series: + """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 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: 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. + """ + 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, + 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) + + content_series = res._apply_unary_op(ops.JSONValue(json_path="$.content")) + + if verbose: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + res_df = bpd.DataFrame({"status": status_series, "content": content_series}) + struct_series = bbq.struct(res_df) + return struct_series + else: + return content_series + + def pdf_chunk( + self, + *, + engine: Literal[None, "pypdf"] = None, + connection: Optional[str] = None, + chunk_size: int = 2000, + overlap_size: int = 200, + 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. + + 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 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 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: 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. + """ + 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) + + if chunk_size <= 0: + raise ValueError("chunk_size must be a positive integer.") + if overlap_size < 0: + raise ValueError("overlap_size must be a non-negative integer.") + if overlap_size >= chunk_size: + raise ValueError("overlap_size must be smaller than chunk_size.") + + pdf_chunk_udf = blob_func.TransformFunction( + blob_func.pdf_chunk_def, + session=self._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["chunk_size"] = chunk_size + df["overlap_size"] = overlap_size + + res = self._df_apply_udf(df, pdf_chunk_udf) + + content_series = bbq.json_extract_string_array(res, "$.content") + if verbose: + status_series = res._apply_unary_op(ops.JSONValue(json_path="$.status")) + res_df = bpd.DataFrame({"status": status_series, "content": content_series}) + struct_series = bbq.struct(res_df) + return struct_series + else: + return content_series + + 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. + """ + if engine.casefold() != "bigquery": + raise ValueError("Must specify the engine, supported value is 'bigquery'.") + + import bigframes.bigquery as bbq + import bigframes.ml.llm as llm + import bigframes.pandas as bpd + + # col name doesn't matter here. Rename to avoid column name conflicts + audio_series = bigframes.series.Series(self._block) + + 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." + + llm_model = llm.GeminiTextGenerator( + model_name=model_name, + session=self._block.session, + connection_name=connection, + ) + + # transcribe audio using ML.GENERATE_TEXT + transcribed_results = llm_model.predict( + X=audio_series, + prompt=[prompt_text, audio_series], + temperature=0.0, + ) + + transcribed_content_series = cast( + bpd.Series, transcribed_results["ml_generate_text_llm_result"] + ).rename("transcribed_content") + + if verbose: + transcribed_status_series = cast( + bpd.Series, transcribed_results["ml_generate_text_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 diff --git a/bigframes/operations/blob_ops.py b/bigframes/operations/blob_ops.py index 21d645a2fee..29f23a2f705 100644 --- a/bigframes/operations/blob_ops.py +++ b/bigframes/operations/blob_ops.py @@ -15,9 +15,9 @@ import dataclasses import typing -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing ObjFetchMetadataOp = base_ops.create_unary_op( name="obj_fetch_metadata", type_signature=op_typing.BLOB_TRANSFORM @@ -29,7 +29,6 @@ class ObjGetAccessUrl(base_ops.UnaryOp): name: typing.ClassVar[str] = "obj_get_access_url" mode: str # access mode, e.g. R read, W write, RW read & write - duration: typing.Optional[int] = None # duration in microseconds def output_type(self, *input_types): return dtypes.JSON_DTYPE @@ -47,14 +46,3 @@ def output_type(self, *input_types): obj_make_ref_op = ObjMakeRef() - - -@dataclasses.dataclass(frozen=True) -class ObjMakeRefJson(base_ops.UnaryOp): - name: typing.ClassVar[str] = "obj_make_ref_json" - - def output_type(self, *input_types): - return dtypes.OBJ_REF_DTYPE - - -obj_make_ref_json_op = ObjMakeRefJson() diff --git a/bigframes/operations/bool_ops.py b/bigframes/operations/bool_ops.py index ce4406d8f70..003318f8226 100644 --- a/bigframes/operations/bool_ops.py +++ b/bigframes/operations/bool_ops.py @@ -13,8 +13,8 @@ # limitations under the License. -import bigframes.operations.type as op_typing from bigframes.operations import base_ops +import bigframes.operations.type as op_typing AndOp = base_ops.create_binary_op(name="and", type_signature=op_typing.LOGICAL) and_op = AndOp() diff --git a/bigframes/operations/comparison_ops.py b/bigframes/operations/comparison_ops.py index f3c01a3536b..4c2911808db 100644 --- a/bigframes/operations/comparison_ops.py +++ b/bigframes/operations/comparison_ops.py @@ -13,8 +13,8 @@ # limitations under the License. -import bigframes.operations.type as op_typing from bigframes.operations import base_ops +import bigframes.operations.type as op_typing EqOp = base_ops.create_binary_op(name="eq", type_signature=op_typing.COMPARISON) eq_op = EqOp() diff --git a/bigframes/operations/date_ops.py b/bigframes/operations/date_ops.py index 1dbb244afbc..352bc9f93e9 100644 --- a/bigframes/operations/date_ops.py +++ b/bigframes/operations/date_ops.py @@ -15,9 +15,9 @@ import dataclasses import typing -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing DayOp = base_ops.create_unary_op( name="day", diff --git a/bigframes/operations/datetime_ops.py b/bigframes/operations/datetime_ops.py index 702466c4f35..9988e8ed7b9 100644 --- a/bigframes/operations/datetime_ops.py +++ b/bigframes/operations/datetime_ops.py @@ -18,9 +18,9 @@ import pandas as pd import pyarrow as pa -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing DateOp = base_ops.create_unary_op( name="date", @@ -73,8 +73,6 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT dtypes.INT_DTYPE, dtypes.STRING_DTYPE, dtypes.DATE_DTYPE, - dtypes.TIMESTAMP_DTYPE, - dtypes.DATETIME_DTYPE, ): raise TypeError("expected string or numeric input") return pd.ArrowDtype(pa.timestamp("us", tz=None)) @@ -88,14 +86,11 @@ class ToTimestampOp(base_ops.UnaryOp): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: # Must be numeric or string - if input_types[0] == dtypes.TIMESTAMP_DTYPE: - raise TypeError("Already tz-aware.") if input_types[0] not in ( dtypes.FLOAT_DTYPE, dtypes.INT_DTYPE, dtypes.STRING_DTYPE, dtypes.DATE_DTYPE, - dtypes.DATETIME_DTYPE, ): raise TypeError("expected string or numeric input") return pd.ArrowDtype(pa.timestamp("us", tz="UTC")) diff --git a/bigframes/operations/datetimes.py b/bigframes/operations/datetimes.py index b16c596120a..14bf10f4631 100644 --- a/bigframes/operations/datetimes.py +++ b/bigframes/operations/datetimes.py @@ -15,159 +15,127 @@ from __future__ import annotations import datetime as dt -from typing import Generic, Literal, Optional, TypeVar +from typing import Optional import bigframes_vendored.pandas.core.arrays.datetimelike as vendored_pandas_datetimelike import bigframes_vendored.pandas.core.indexes.accessor as vendordt import pandas -import bigframes.core.col -import bigframes.core.indexes.base as indices -import bigframes.operations as ops from bigframes import dataframe, dtypes, series -from bigframes._tools import docs -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter +from bigframes.core.reshape import concat +import bigframes.operations as ops +import bigframes.operations.base -_ONE_DAY = pandas.Timedelta("1D") +_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") - -T = TypeVar("T", series.Series, indices.Index, bigframes.core.col.Expression) - -# Simpler base class for datetime properties, excludes isocalendar, unit, tz -class DatetimeSimpleMethods(Generic[T]): - def __init__(self, data: T): - self._data: T = data +@log_adapter.class_logger +class DatetimeMethods( + bigframes.operations.base.SeriesMethods, + vendordt.DatetimeProperties, + vendored_pandas_datetimelike.DatelikeOps, +): + __doc__ = vendordt.DatetimeProperties.__doc__ # Date accessors @property - def day(self) -> T: - return self._data._apply_unary_op(ops.day_op) - - @property - def dayofweek(self) -> T: - return self._data._apply_unary_op(ops.dayofweek_op) + def day(self) -> series.Series: + return self._apply_unary_op(ops.day_op) @property - def day_of_week(self) -> T: - return self.dayofweek + def dayofweek(self) -> series.Series: + return self._apply_unary_op(ops.dayofweek_op) @property - def weekday(self) -> T: + def day_of_week(self) -> series.Series: return self.dayofweek @property - def dayofyear(self) -> T: - return self._data._apply_unary_op(ops.dayofyear_op) + def dayofyear(self) -> series.Series: + return self._apply_unary_op(ops.dayofyear_op) @property - def day_of_year(self) -> T: + def day_of_year(self) -> series.Series: return self.dayofyear @property - def date(self) -> T: - return self._data._apply_unary_op(ops.date_op) + def date(self) -> series.Series: + return self._apply_unary_op(ops.date_op) @property - def quarter(self) -> T: - return self._data._apply_unary_op(ops.quarter_op) + def quarter(self) -> series.Series: + return self._apply_unary_op(ops.quarter_op) @property - def year(self) -> T: - return self._data._apply_unary_op(ops.year_op) + def year(self) -> series.Series: + return self._apply_unary_op(ops.year_op) @property - def month(self) -> T: - return self._data._apply_unary_op(ops.month_op) + def month(self) -> series.Series: + return self._apply_unary_op(ops.month_op) + + def isocalendar(self) -> dataframe.DataFrame: + years = self._apply_unary_op(ops.iso_year_op) + weeks = self._apply_unary_op(ops.iso_week_op) + days = self._apply_unary_op(ops.iso_day_op) + + result = concat.concat([years, weeks, days], axis=1) + result.columns = pandas.Index(["year", "week", "day"]) + return result # Time accessors @property - def hour(self) -> T: - return self._data._apply_unary_op(ops.hour_op) + def hour(self) -> series.Series: + return self._apply_unary_op(ops.hour_op) @property - def minute(self) -> T: - return self._data._apply_unary_op(ops.minute_op) + def minute(self) -> series.Series: + return self._apply_unary_op(ops.minute_op) @property - def second(self) -> T: - return self._data._apply_unary_op(ops.second_op) + def second(self) -> series.Series: + return self._apply_unary_op(ops.second_op) @property - def time(self) -> T: - return self._data._apply_unary_op(ops.time_op) + def time(self) -> series.Series: + return self._apply_unary_op(ops.time_op) # Timedelta accessors @property - def days(self) -> T: + def days(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._data._apply_binary_op(_ONE_DAY, ops.floordiv_op) + return self._apply_binary_op(_ONE_DAY, ops.floordiv_op) @property - def seconds(self) -> T: + 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 + return self._apply_binary_op(_ONE_DAY, ops.mod_op) // _ONE_SECOND # type: ignore @property - def microseconds(self) -> T: + 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 + return self._apply_binary_op(_ONE_SECOND, ops.mod_op) // _ONE_MICRO # type: ignore - def total_seconds(self) -> T: + def total_seconds(self) -> series.Series: self._check_dtype(dtypes.TIMEDELTA_DTYPE) - return self._data._apply_binary_op(_ONE_SECOND, ops.div_op) + return self._apply_binary_op(_ONE_SECOND, ops.div_op) def _check_dtype(self, target_dtype: dtypes.Dtype): - if isinstance(self._data, (indices.Index, series.Series)): - if self._data.dtype != target_dtype: - raise TypeError( - f"Expect dtype: {target_dtype}, but got {self._data.dtype}" - ) - return - - def tz_localize(self, tz: Literal["UTC"] | None) -> T: - if tz == "UTC": - return self._data._apply_unary_op(ops.ToTimestampOp()) - - if tz is None: - return self._data._apply_unary_op(ops.ToDatetimeOp()) - - raise ValueError(f"Unsupported timezone {tz}") - - def day_name(self) -> T: - return self.strftime("%A") - - def strftime(self, date_format: str) -> T: - return self._data._apply_unary_op(ops.StrftimeOp(date_format=date_format)) - - def normalize(self) -> T: - return self._data._apply_unary_op(ops.normalize_op) - - def floor(self, freq: str) -> T: - 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 - - -# this is the version used by series.dt, and the one that shows up in reference docs -@log_adapter.class_logger -@docs.inherit_docs(vendordt.DatetimeProperties) -@docs.inherit_docs(vendored_pandas_datetimelike.DatelikeOps) -class DatetimeMethods(DatetimeSimpleMethods[bigframes.series.Series]): - def __init__(self, data: series.Series): - super().__init__(data) + if self._dtype == target_dtype: + return + raise TypeError(f"Expect dtype: {target_dtype}, but got {self._dtype}") @property def tz(self) -> Optional[dt.timezone]: # Assumption: pyarrow dtype - tz_string = self._data._dtype.pyarrow_dtype.tz + tz_string = self._dtype.pyarrow_dtype.tz if tz_string == "UTC": return dt.timezone.utc elif tz_string is None: @@ -178,12 +146,13 @@ def tz(self) -> Optional[dt.timezone]: @property def unit(self) -> str: # Assumption: pyarrow dtype - return self._data._dtype.pyarrow_dtype.unit + return self._dtype.pyarrow_dtype.unit - 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) + def strftime(self, date_format: str) -> series.Series: + return self._apply_unary_op(ops.StrftimeOp(date_format=date_format)) + + def normalize(self) -> series.Series: + return self._apply_unary_op(ops.normalize_op) + + def floor(self, freq: str) -> series.Series: + return self._apply_unary_op(ops.FloorDtOp(freq=freq)) diff --git a/bigframes/operations/distance_ops.py b/bigframes/operations/distance_ops.py index 435308f9c40..ac0863b9e6b 100644 --- a/bigframes/operations/distance_ops.py +++ b/bigframes/operations/distance_ops.py @@ -13,8 +13,8 @@ # limitations under the License. -import bigframes.operations.type as op_typing from bigframes.operations import base_ops +import bigframes.operations.type as op_typing CosineDistanceOp = base_ops.create_binary_op( name="ml_cosine_distance", type_signature=op_typing.VECTOR_METRIC diff --git a/bigframes/operations/frequency_ops.py b/bigframes/operations/frequency_ops.py index b94afa72710..2d5a854c32b 100644 --- a/bigframes/operations/frequency_ops.py +++ b/bigframes/operations/frequency_ops.py @@ -27,22 +27,9 @@ @dataclasses.dataclass(frozen=True) class FloorDtOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "floor_dt" - freq: typing.Literal[ - "Y", - "Q", - "M", - "W", - "D", - "h", - "min", - "s", - "ms", - "us", - ] + freq: str 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 9b226ad28d8..d6155a770c1 100644 --- a/bigframes/operations/generic_ops.py +++ b/bigframes/operations/generic_ops.py @@ -16,9 +16,9 @@ import functools import typing -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing InvertOp = base_ops.create_unary_op( name="invert", @@ -45,21 +45,6 @@ ) notnull_op = NotNullOp() - -# Semantics match Python's truth value testing (truthy and falsey objects). -# See https://docs.python.org/3/library/stdtypes.html#truth-value-testing -CoerceToBoolOp = base_ops.create_unary_op( - name="coerce_to_bool", - type_signature=op_typing.FixedOutputType( - dtypes.is_bool_coercable, dtypes.BOOL_DTYPE, description="coercable to bool" - ), -) -CoerceToBoolOp.__doc__ = ( - "Coerce a value to a boolean, matching Python's truth value testing semantics " - "(truthy/falsey). See https://docs.python.org/3/library/stdtypes.html#truth-value-testing" -) -coerce_to_bool_op = CoerceToBoolOp() - HashOp = base_ops.create_unary_op( name="hash", type_signature=op_typing.FixedOutputType( @@ -108,6 +93,10 @@ dtypes.STRING_DTYPE, dtypes.INT_DTYPE, ), + ( + dtypes.JSON_DTYPE, + dtypes.INT_DTYPE, + ), # Float casts ( dtypes.BOOL_DTYPE, @@ -129,6 +118,10 @@ dtypes.STRING_DTYPE, dtypes.FLOAT_DTYPE, ), + ( + dtypes.JSON_DTYPE, + dtypes.FLOAT_DTYPE, + ), # Bool casts ( dtypes.INT_DTYPE, @@ -138,6 +131,10 @@ dtypes.FLOAT_DTYPE, dtypes.BOOL_DTYPE, ), + ( + dtypes.JSON_DTYPE, + dtypes.BOOL_DTYPE, + ), # String casts ( dtypes.BYTES_DTYPE, @@ -171,6 +168,10 @@ dtypes.DATE_DTYPE, dtypes.STRING_DTYPE, ), + ( + dtypes.JSON_DTYPE, + dtypes.STRING_DTYPE, + ), # bytes casts ( dtypes.STRING_DTYPE, @@ -275,6 +276,23 @@ 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, + ), ) ) @@ -425,15 +443,10 @@ class SqlScalarOp(base_ops.NaryOp): name: typing.ClassVar[str] = "sql_scalar" _output_type: dtypes.ExpressionType sql_template: str - is_deterministic: bool = True def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: return self._output_type - @property - def deterministic(self) -> bool: - return self.is_deterministic - @dataclasses.dataclass(frozen=True) class PyUdfOp(base_ops.NaryOp): @@ -445,66 +458,3 @@ class PyUdfOp(base_ops.NaryOp): def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: return self._output_type - - -@dataclasses.dataclass(frozen=True) -class GetItemOp(base_ops.UnaryOp): - """Represents subscripting with a statically-known key (e.g. `obj[1]` or `obj["field"]`). - - We must keep this static UnaryOp separate from DynamicGetItemOp (a BinaryOp) - primarily to support Struct field subscripting. Because the return type of a Struct - field lookup depends on the specific field being accessed, and type resolution - (output_type) only has access to input types rather than input values, we must store - the static key inside the operation instance to infer the correct output type. - """ - - name: typing.ClassVar[str] = "getitem" - key: typing.Union[str, int] - - def output_type(self, *input_types): - input_type = input_types[0] - if dtypes.is_struct_like(input_type): - pa_type = input_type.pyarrow_dtype - pa_result_type = pa_type[self.key].type - return dtypes.arrow_dtype_to_bigframes_dtype(pa_result_type) - elif dtypes.is_array_like(input_type): - if not isinstance(self.key, int): - raise TypeError("Array index must be an integer") - return dtypes.arrow_dtype_to_bigframes_dtype( - input_type.pyarrow_dtype.value_type - ) - elif dtypes.is_string_like(input_type): - if not isinstance(self.key, int): - raise TypeError("String index must be an integer") - return dtypes.STRING_DTYPE - else: - raise TypeError(f"Cannot subscript input of type {input_type}") - - -@dataclasses.dataclass(frozen=True) -class DynamicGetItemOp(base_ops.BinaryOp): - """Represents subscripting with a dynamic key expression (e.g. `obj[expr]`). - - Unlike GetItemOp, this operates on 2 dynamic inputs (the container and the key). - Because SQL/BigQuery does not support dynamic struct field access (struct paths must - be statically declared), this operation is only supported for array and string - subscripting, where output type inference does not require knowing the runtime - index value. - """ - - name: typing.ClassVar[str] = "dynamic_getitem" - - def output_type(self, *input_types): - left_type = input_types[0] - right_type = input_types[1] - if not dtypes.is_numeric(right_type): - raise TypeError(f"Subscript index must be numeric type, got {right_type}") - - if dtypes.is_array_like(left_type): - return dtypes.arrow_dtype_to_bigframes_dtype( - left_type.pyarrow_dtype.value_type - ) - elif dtypes.is_string_like(left_type): - return dtypes.STRING_DTYPE - else: - raise TypeError(f"Cannot dynamically subscript input of type {left_type}") diff --git a/bigframes/operations/geo_ops.py b/bigframes/operations/geo_ops.py index a965ddca2b9..3b7754a47ad 100644 --- a/bigframes/operations/geo_ops.py +++ b/bigframes/operations/geo_ops.py @@ -13,11 +13,18 @@ # limitations under the License. import dataclasses -from typing import Optional -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing + +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() GeoStAstextOp = base_ops.create_unary_op( name="geo_st_astext", @@ -126,35 +133,3 @@ class GeoStLengthOp(base_ops.UnaryOp): 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/googlesql/__init__.py b/bigframes/operations/googlesql/__init__.py deleted file mode 100644 index edec5b84f8e..00000000000 --- a/bigframes/operations/googlesql/__init__.py +++ /dev/null @@ -1,106 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 bigframes.operations as ops -from bigframes import dtypes - - -@dataclasses.dataclass(frozen=True) -class ArgSpec: - arg_name: str | None = None - optional: bool = False - is_vararg: bool = False - const_only: bool = False - - -@dataclasses.dataclass(frozen=True) -class OpSignature: - # Detailed specs for each parameter. This is particularly relevant for ren - arg_specs: typing.Sequence[ArgSpec] - resolve_return_type: typing.Any - has_varargs: bool = False - - -# Eventually we should migrate every op over to this that can be directly emitted 1:1 as a sql op -# This will allow us to fully lower to pure SQL dialect expressions and emitting sql text is trivial. -@dataclasses.dataclass(frozen=True) -class GoogleSqlScalarOp(ops.NaryOp): - name: typing.ClassVar[str] = "googlesql_scalar" - - # syntax - sql_name: str - args: tuple[ArgSpec, ...] - # typing - signature: typing.Callable[..., dtypes.ExpressionType] - - # semantics - is_deterministic: bool = True - - @property - def deterministic(self) -> bool: - return self.is_deterministic - - def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionType: - return self.signature(*input_types) - - -RAND = GoogleSqlScalarOp( - "RAND", args=(), is_deterministic=False, signature=lambda: dtypes.FLOAT_DTYPE -) - - -def _check_geo_input( - t: dtypes.ExpressionType, out: dtypes.ExpressionType -) -> dtypes.ExpressionType: - if t is not None and not dtypes.is_geo_like(t): - raise TypeError(f"Type {t} is not supported. Type must be geo-like") - return out - - -def _check_simplify_inputs( - geo: dtypes.ExpressionType, tol: dtypes.ExpressionType -) -> dtypes.ExpressionType: - if geo is not None and not dtypes.is_geo_like(geo): - raise TypeError(f"Type {geo} is not supported. Type must be geo-like") - if tol is not None and not dtypes.is_numeric(tol): - raise TypeError(f"Type {tol} is not supported. Type must be numeric") - return dtypes.GEO_DTYPE - - -ST_AREA = GoogleSqlScalarOp( - "ST_AREA", - args=(ArgSpec(),), - is_deterministic=True, - signature=lambda geo: _check_geo_input(geo, dtypes.FLOAT_DTYPE), -) - -ST_CENTROID = GoogleSqlScalarOp( - "ST_CENTROID", - args=(ArgSpec(),), - is_deterministic=True, - signature=lambda geo: _check_geo_input(geo, dtypes.GEO_DTYPE), -) - -ST_SIMPLIFY = GoogleSqlScalarOp( - "ST_SIMPLIFY", - args=(ArgSpec(), ArgSpec()), - is_deterministic=True, - signature=_check_simplify_inputs, -) diff --git a/bigframes/operations/googlesql/aead.py b/bigframes/operations/googlesql/aead.py deleted file mode 100644 index f719d7d6989..00000000000 --- a/bigframes/operations/googlesql/aead.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/aead.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -from typing import Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - -_DECRYPT_BYTES_OP = googlesql.GoogleSqlScalarOp( - "AEAD.DECRYPT_BYTES", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BYTES_DTYPE, -) -_DECRYPT_STRING_OP = googlesql.GoogleSqlScalarOp( - "AEAD.DECRYPT_STRING", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.STRING_DTYPE, -) -_ENCRYPT_OP = googlesql.GoogleSqlScalarOp( - "AEAD.ENCRYPT", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BYTES_DTYPE, -) - - -def decrypt_bytes( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - ciphertext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Uses the matching key from keyset to decrypt ciphertext and verifies the integrity of the data using additional_data. Returns an error if decryption or verification fails.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DECRYPT_BYTES_OP, - keyset, - ciphertext, - additional_data, - ) - - -def decrypt_string( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - ciphertext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Like AEAD.DECRYPT_BYTES, but where additional_data is of type STRING.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DECRYPT_STRING_OP, - keyset, - ciphertext, - additional_data, - ) - - -def encrypt( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - plaintext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Encrypts plaintext using the primary cryptographic key in keyset. The algorithm of the primary key must be AEAD_AES_GCM_256. Binds the ciphertext to the context defined by additional_data. Returns NULL if any input is NULL.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ENCRYPT_OP, - keyset, - plaintext, - additional_data, - ) diff --git a/bigframes/operations/googlesql/global_namespace/__init__.py b/bigframes/operations/googlesql/global_namespace/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/bigframes/operations/googlesql/global_namespace/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/operations/googlesql/global_namespace/aead_encryption.py b/bigframes/operations/googlesql/global_namespace/aead_encryption.py deleted file mode 100644 index 4613ddd7e6d..00000000000 --- a/bigframes/operations/googlesql/global_namespace/aead_encryption.py +++ /dev/null @@ -1,122 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/aead_encryption.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -from typing import Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - -_DETERMINISTIC_DECRYPT_BYTES_OP = googlesql.GoogleSqlScalarOp( - "DETERMINISTIC_DECRYPT_BYTES", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BYTES_DTYPE, -) -_DETERMINISTIC_DECRYPT_STRING_OP = googlesql.GoogleSqlScalarOp( - "DETERMINISTIC_DECRYPT_STRING", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.STRING_DTYPE, -) -_DETERMINISTIC_ENCRYPT_OP = googlesql.GoogleSqlScalarOp( - "DETERMINISTIC_ENCRYPT", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BYTES_DTYPE, -) - - -def deterministic_decrypt_bytes( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - ciphertext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Uses the matching key from `keyset` to decrypt `ciphertext` and verifies the integrity of the data using `additional_data`. Returns an error if decryption fails.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DETERMINISTIC_DECRYPT_BYTES_OP, - keyset, - ciphertext, - additional_data, - ) - - -def deterministic_decrypt_string( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - ciphertext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Like `DETERMINISTIC_DECRYPT_BYTES`, but where plaintext is of type STRING.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DETERMINISTIC_DECRYPT_STRING_OP, - keyset, - ciphertext, - additional_data, - ) - - -def deterministic_encrypt( - keyset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, dict], - ], - plaintext: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - additional_data: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Encrypts `plaintext` using the primary cryptographic key in `keyset` using deterministic AEAD. The algorithm of the primary key must be `DETERMINISTIC_AEAD_AES_SIV_CMAC_256`. Binds the ciphertext to the context defined by `additional_data`. Returns `NULL` if any input is `NULL`.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DETERMINISTIC_ENCRYPT_OP, - keyset, - plaintext, - additional_data, - ) diff --git a/bigframes/operations/googlesql/global_namespace/array.py b/bigframes/operations/googlesql/global_namespace/array.py deleted file mode 100644 index 94adbad1839..00000000000 --- a/bigframes/operations/googlesql/global_namespace/array.py +++ /dev/null @@ -1,892 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/array.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -import decimal -from typing import Any, Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - - -def _ARRAY_CONCAT_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (2 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok and args[1] is not None: - if not dtypes.is_array_like(args[1]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[1]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok: - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - - raise TypeError( - f"Could not find matching signature for array_concat with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_CONCAT_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_CONCAT", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=_ARRAY_CONCAT_SIG, -) - - -def _ARRAY_FIRST_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (1 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok: - return any1_val - - raise TypeError( - f"Could not find matching signature for array_first with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_FIRST_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_FIRST", - args=(googlesql.ArgSpec(),), - signature=_ARRAY_FIRST_SIG, -) - - -def _ARRAY_FIRST_N_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (2 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok and args[1] is not None: - try: - if dtypes.coerce_to_common(args[1], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok: - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - - raise TypeError( - f"Could not find matching signature for array_first_n with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_FIRST_N_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_FIRST_N", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=_ARRAY_FIRST_N_SIG, -) -_ARRAY_INCLUDES_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_INCLUDES", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BOOL_DTYPE, -) -_ARRAY_INCLUDES_ALL_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_INCLUDES_ALL", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BOOL_DTYPE, -) -_ARRAY_INCLUDES_ANY_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_INCLUDES_ANY", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.BOOL_DTYPE, -) -_ARRAY_IS_DISTINCT_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_IS_DISTINCT", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.BOOL_DTYPE, -) - - -def _ARRAY_LAST_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (1 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok: - return any1_val - - raise TypeError( - f"Could not find matching signature for array_last with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_LAST_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_LAST", - args=(googlesql.ArgSpec(),), - signature=_ARRAY_LAST_SIG, -) -_ARRAY_LENGTH_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_LENGTH", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.INT_DTYPE, -) - - -def _ARRAY_REVERSE_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (1 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok: - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - - raise TypeError( - f"Could not find matching signature for array_reverse with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_REVERSE_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_REVERSE", - args=(googlesql.ArgSpec(),), - signature=_ARRAY_REVERSE_SIG, -) - - -def _ARRAY_SLICE_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (3 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok and args[1] is not None: - try: - if dtypes.coerce_to_common(args[1], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if dtypes.coerce_to_common(args[2], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok: - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - - raise TypeError( - f"Could not find matching signature for array_slice with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_SLICE_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_SLICE", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=_ARRAY_SLICE_SIG, -) - - -def _ARRAY_TO_STRING_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (3 - len(args)) - # Try matching impl 0 - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - try: - if ( - dtypes.coerce_to_common(inner, dtypes.STRING_DTYPE) - != dtypes.STRING_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[1] is not None: - try: - if ( - dtypes.coerce_to_common(args[1], dtypes.STRING_DTYPE) - != dtypes.STRING_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if ( - dtypes.coerce_to_common(args[2], dtypes.STRING_DTYPE) - != dtypes.STRING_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok: - return dtypes.STRING_DTYPE - - # Try matching impl 1 - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - try: - if ( - dtypes.coerce_to_common(inner, dtypes.BYTES_DTYPE) - != dtypes.BYTES_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[1] is not None: - try: - if ( - dtypes.coerce_to_common(args[1], dtypes.BYTES_DTYPE) - != dtypes.BYTES_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if ( - dtypes.coerce_to_common(args[2], dtypes.BYTES_DTYPE) - != dtypes.BYTES_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok: - return dtypes.BYTES_DTYPE - - raise TypeError( - f"Could not find matching signature for array_to_string with argument types: {[str(t) for t in args]}" - ) - - -_ARRAY_TO_STRING_OP = googlesql.GoogleSqlScalarOp( - "ARRAY_TO_STRING", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec(optional=True)), - signature=_ARRAY_TO_STRING_SIG, -) - - -def _FLATTEN_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (2 - len(args)) - # Try matching impl 0 - any1_val = None - match_ok = True - if match_ok and args[0] is not None: - if not dtypes.is_array_like(args[0]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[0]) - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - if match_ok and args[1] is not None: - try: - if dtypes.coerce_to_common(args[1], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok: - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - - raise TypeError( - f"Could not find matching signature for flatten with argument types: {[str(t) for t in args]}" - ) - - -_FLATTEN_OP = googlesql.GoogleSqlScalarOp( - "FLATTEN", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(arg_name="depth", optional=True)), - signature=_FLATTEN_SIG, -) - - -def _GENERATE_ARRAY_SIG(*args): - # Pad args with None to match max expected args - args = args + (None,) * (3 - len(args)) - # Try matching impl 0 - match_ok = True - if match_ok and args[0] is not None: - try: - if dtypes.coerce_to_common(args[0], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[1] is not None: - try: - if dtypes.coerce_to_common(args[1], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if dtypes.coerce_to_common(args[2], dtypes.INT_DTYPE) != dtypes.INT_DTYPE: - match_ok = False - except TypeError: - match_ok = False - if match_ok: - return dtypes.list_type(dtypes.INT_DTYPE) - - # Try matching impl 1 - match_ok = True - if match_ok and args[0] is not None: - try: - if ( - dtypes.coerce_to_common(args[0], dtypes.NUMERIC_DTYPE) - != dtypes.NUMERIC_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[1] is not None: - try: - if ( - dtypes.coerce_to_common(args[1], dtypes.NUMERIC_DTYPE) - != dtypes.NUMERIC_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if ( - dtypes.coerce_to_common(args[2], dtypes.NUMERIC_DTYPE) - != dtypes.NUMERIC_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok: - return dtypes.list_type(dtypes.NUMERIC_DTYPE) - - # Try matching impl 2 - match_ok = True - if match_ok and args[0] is not None: - try: - if ( - dtypes.coerce_to_common(args[0], dtypes.FLOAT_DTYPE) - != dtypes.FLOAT_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[1] is not None: - try: - if ( - dtypes.coerce_to_common(args[1], dtypes.FLOAT_DTYPE) - != dtypes.FLOAT_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok and args[2] is not None: - try: - if ( - dtypes.coerce_to_common(args[2], dtypes.FLOAT_DTYPE) - != dtypes.FLOAT_DTYPE - ): - match_ok = False - except TypeError: - match_ok = False - if match_ok: - return dtypes.list_type(dtypes.FLOAT_DTYPE) - - raise TypeError( - f"Could not find matching signature for generate_array with argument types: {[str(t) for t in args]}" - ) - - -_GENERATE_ARRAY_OP = googlesql.GoogleSqlScalarOp( - "GENERATE_ARRAY", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec(optional=True)), - signature=_GENERATE_ARRAY_SIG, -) - - -def array_concat( - array_expression_1: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - array_expression_2: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Concatenates one or more arrays with the same element type into a single array.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_CONCAT_OP, - array_expression_1, - array_expression_2, - ) - - -def array_first( - array_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array and returns the first element in the array.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_FIRST_OP, - array_expression, - ) - - -def array_first_n( - input_array: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - n: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns a prefix of `input_array` consisting of the first `n` elements.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_FIRST_N_OP, - input_array, - n, - ) - - -def array_includes( - array_to_search: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - search_value: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array and returns `TRUE` if there is an element in the array that is equal to the search_value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_INCLUDES_OP, - array_to_search, - search_value, - ) - - -def array_includes_all( - array_to_search: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - search_values: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array to search and an array of search values. Returns `TRUE` if all search values are in the array to search, otherwise returns `FALSE`.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_INCLUDES_ALL_OP, - array_to_search, - search_values, - ) - - -def array_includes_any( - array_to_search: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - search_values: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array to search and an array of search values. Returns `TRUE` if any search values are in the array to search, otherwise returns `FALSE`.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_INCLUDES_ANY_OP, - array_to_search, - search_values, - ) - - -def array_is_distinct( - array_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns `TRUE` if the array contains no repeated elements, using the same equality comparison logic as `SELECT DISTINCT`.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_IS_DISTINCT_OP, - array_expression, - ) - - -def array_last( - array_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array and returns the last element in the array.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_LAST_OP, - array_expression, - ) - - -def array_length( - series: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Compute the length of each array element in the Series. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> bbq.array_length(s) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> ps.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also apply this function directly to Series using `apply`. - - >>> s.apply(bbq.array_length, by_row=False) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - Args: - series (bigframes.series.Series): A Series with array columns. - - Returns: - bigframes.series.Series: A Series of integer values indicating - the length of each element in the Series. - """ - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_LENGTH_OP, - series, - ) - - -def array_reverse( - value: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns the input `ARRAY` with elements in reverse order.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_REVERSE_OP, - value, - ) - - -def array_slice( - array_to_slice: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - start_offset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - end_offset: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns an array containing zero or more consecutive elements from the input array.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_SLICE_OP, - array_to_slice, - start_offset, - end_offset, - ) - - -def array_to_string( - series: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - delimiter: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ], - null_text: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts array elements within a Series into delimited strings. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([["H", "i", "!"], ["Hello", "World"], np.nan, [], ["Hi"]]) - >>> bbq.array_to_string(s, delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([["H", "i", "!"], ["Hello", "World"], None, [], ["Hi"]]) - >>> ps.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - Args: - series (bigframes.series.Series): A Series containing arrays. - delimiter (str): The string used to separate array elements. - null_text (str, optional): The string to replace any NULL values in the array with. - - Returns: - bigframes.series.Series: A Series containing delimited strings. - """ - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _ARRAY_TO_STRING_OP, - series, - delimiter, - null_text, - ) - - -def flatten( - array_to_flatten: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - depth: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Takes an array of nested data and flattens a specific part of it into a single, flat array with the [array elements field access operator][array-el-field-operator]. Returns `NULL` if the input value is `NULL`.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _FLATTEN_OP, - array_to_flatten, - depth, - ) - - -def generate_array( - start_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], decimal.Decimal, float, int - ], - ], - end_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], decimal.Decimal, float, int - ], - ], - step_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], decimal.Decimal, float, int - ], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns an array of values. The `start_expression` and `end_expression` parameters determine the inclusive start and end of the array.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _GENERATE_ARRAY_OP, - start_expression, - end_expression, - step_expression, - ) diff --git a/bigframes/operations/googlesql/global_namespace/bit.py b/bigframes/operations/googlesql/global_namespace/bit.py deleted file mode 100644 index e0c22dfc299..00000000000 --- a/bigframes/operations/googlesql/global_namespace/bit.py +++ /dev/null @@ -1,48 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/bit.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -from typing import Any, Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - -_BIT_COUNT_OP = googlesql.GoogleSqlScalarOp( - "BIT_COUNT", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.INT_DTYPE, -) - - -def bit_count( - expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], bytes, int], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """The input, `expression`, must be an integer or `BYTES`. Returns the number of bits that are set in the input expression. For signed integers, this is the number of bits in two's complement form.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _BIT_COUNT_OP, - expression, - ) diff --git a/bigframes/operations/googlesql/global_namespace/conversion.py b/bigframes/operations/googlesql/global_namespace/conversion.py deleted file mode 100644 index cea4e45d836..00000000000 --- a/bigframes/operations/googlesql/global_namespace/conversion.py +++ /dev/null @@ -1,193 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/conversion.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -import datetime -from typing import Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - -_BOOL_OP = googlesql.GoogleSqlScalarOp( - "BOOL", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.BOOL_DTYPE, -) -_DOUBLE_OP = googlesql.GoogleSqlScalarOp( - "DOUBLE", - args=( - googlesql.ArgSpec(), - googlesql.ArgSpec(arg_name="wide_number_mode", optional=True), - ), - signature=lambda *args: dtypes.FLOAT_DTYPE, -) -_FLOAT64_OP = googlesql.GoogleSqlScalarOp( - "FLOAT64", - args=( - googlesql.ArgSpec(), - googlesql.ArgSpec(arg_name="wide_number_mode", optional=True), - ), - signature=lambda *args: dtypes.FLOAT_DTYPE, -) -_INT64_OP = googlesql.GoogleSqlScalarOp( - "INT64", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.INT_DTYPE, -) -_PARSE_BIGNUMERIC_OP = googlesql.GoogleSqlScalarOp( - "PARSE_BIGNUMERIC", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.BIGNUMERIC_DTYPE, -) -_PARSE_NUMERIC_OP = googlesql.GoogleSqlScalarOp( - "PARSE_NUMERIC", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.NUMERIC_DTYPE, -) -_STRING_OP = googlesql.GoogleSqlScalarOp( - "STRING", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(optional=True)), - signature=lambda *args: dtypes.STRING_DTYPE, -) - - -def bool_( - json_string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a JSON boolean to a SQL BOOL value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _BOOL_OP, - json_string_expression, - ) - - -def double( - json_string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - wide_number_mode: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a JSON number to a SQL FLOAT64 value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DOUBLE_OP, - json_string_expression, - wide_number_mode, - ) - - -def float64( - json_string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - wide_number_mode: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a JSON number to a SQL FLOAT64 value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _FLOAT64_OP, - json_string_expression, - wide_number_mode, - ) - - -def int64( - json_string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a JSON number to a SQL INT64 value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _INT64_OP, - json_string_expression, - ) - - -def parse_bignumeric( - string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a STRING to a BIGNUMERIC value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _PARSE_BIGNUMERIC_OP, - string_expression, - ) - - -def parse_numeric( - string_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a STRING to a NUMERIC value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _PARSE_NUMERIC_OP, - string_expression, - ) - - -def string( - expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], - datetime.date, - datetime.datetime, - datetime.time, - str, - ], - ], - timezone: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a value to a STRING value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _STRING_OP, - expression, - timezone, - ) diff --git a/bigframes/operations/googlesql/global_namespace/date.py b/bigframes/operations/googlesql/global_namespace/date.py deleted file mode 100644 index b6cfc9722b5..00000000000 --- a/bigframes/operations/googlesql/global_namespace/date.py +++ /dev/null @@ -1,412 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/date.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -from __future__ import annotations - -import datetime -from typing import Any, Literal, Union - -import bigframes.core.col -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -import bigframes.series as series -from bigframes import dtypes -from bigframes.operations import googlesql - -_CURRENT_DATE_OP = googlesql.GoogleSqlScalarOp( - "CURRENT_DATE", - args=(googlesql.ArgSpec(optional=True),), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_DATE_OP = googlesql.GoogleSqlScalarOp( - "DATE", - args=( - googlesql.ArgSpec(optional=True), - googlesql.ArgSpec(optional=True), - googlesql.ArgSpec(optional=True), - googlesql.ArgSpec(optional=True), - googlesql.ArgSpec(optional=True), - ), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_DATE_ADD_OP = googlesql.GoogleSqlScalarOp( - "DATE_ADD", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_DATE_DIFF_OP = googlesql.GoogleSqlScalarOp( - "DATE_DIFF", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.INT_DTYPE, -) -_DATE_FROM_UNIX_DATE_OP = googlesql.GoogleSqlScalarOp( - "DATE_FROM_UNIX_DATE", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_DATE_SUB_OP = googlesql.GoogleSqlScalarOp( - "DATE_SUB", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_DATE_TRUNC_OP = googlesql.GoogleSqlScalarOp( - "DATE_TRUNC", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_EXTRACT_OP = googlesql.GoogleSqlScalarOp( - "EXTRACT", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(), googlesql.ArgSpec(optional=True)), - signature=lambda *args: dtypes.INT_DTYPE, -) -_FORMAT_DATE_OP = googlesql.GoogleSqlScalarOp( - "FORMAT_DATE", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.STRING_DTYPE, -) -_GENERATE_DATE_ARRAY_OP = googlesql.GoogleSqlScalarOp( - "GENERATE_DATE_ARRAY", - args=( - googlesql.ArgSpec(), - googlesql.ArgSpec(), - googlesql.ArgSpec(optional=True), - googlesql.ArgSpec(optional=True), - ), - signature=lambda *args: dtypes.list_type(dtypes.DATE_DTYPE), -) -_LAST_DAY_OP = googlesql.GoogleSqlScalarOp( - "LAST_DAY", - args=(googlesql.ArgSpec(), googlesql.ArgSpec(optional=True)), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_PARSE_DATE_OP = googlesql.GoogleSqlScalarOp( - "PARSE_DATE", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: dtypes.DATE_DTYPE, -) -_UNIX_DATE_OP = googlesql.GoogleSqlScalarOp( - "UNIX_DATE", - args=(googlesql.ArgSpec(),), - signature=lambda *args: dtypes.INT_DTYPE, -) - - -def current_date( - time_zone_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns the current date as a DATE object. Parentheses are optional when called with no arguments.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _CURRENT_DATE_OP, - time_zone_expression, - ) - - -def date( - expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], - datetime.date, - datetime.datetime, - str, - ], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - time_zone_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - year: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - month: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - day: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Constructs or extracts a date.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_OP, - expression, - time_zone_expression, - year, - month, - day, - ) - - -def date_add( - date_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - int64_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - date_part: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Adds a specified time interval to a DATE.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_ADD_OP, - date_expression, - int64_expression, - date_part, - ) - - -def date_diff( - end_date: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - start_date: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - granularity: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Gets the number of unit boundaries between two DATE values (end_date - start_date) at a particular time granularity.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_DIFF_OP, - end_date, - start_date, - granularity, - ) - - -def date_from_unix_date( - int64_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Interprets an INT64 expression as the number of days since 1970-01-01.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_FROM_UNIX_DATE_OP, - int64_expression, - ) - - -def date_sub( - date_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - int64_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ], - date_part: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Subtracts a specified time interval from a DATE.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_SUB_OP, - date_expression, - int64_expression, - date_part, - ) - - -def date_trunc( - date_value: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - granularity: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Truncates a DATE, DATETIME, or TIMESTAMP value at a particular granularity.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _DATE_TRUNC_OP, - date_value, - granularity, - ) - - -def extract( - date_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[ - Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], - datetime.date, - datetime.datetime, - datetime.time, - ], - ], - part: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ], - time_zone: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns the value corresponding to the specified date part.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _EXTRACT_OP, - date_expression, - part, - time_zone, - ) - - -def format_date( - format_string: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - date_expr: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Formats a DATE value according to a specified format string.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _FORMAT_DATE_OP, - format_string, - date_expr, - ) - - -def generate_date_array( - start_date: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - end_date: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - int64_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], int], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, - date_part: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Generates an array of dates in a range.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _GENERATE_DATE_ARRAY_OP, - start_date, - end_date, - int64_expression, - date_part, - ) - - -def last_day( - date_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], - date_part: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Any, Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]], - ] = sentinels.Sentinel.ARGUMENT_DEFAULT, -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns the last day from a date expression. This is commonly used to return the last day of the month.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _LAST_DAY_OP, - date_expression, - date_part, - ) - - -def parse_date( - format_string: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], - date_string: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], str], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Converts a STRING value to a DATE value.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _PARSE_DATE_OP, - format_string, - date_string, - ) - - -def unix_date( - date_expression: Union[ - series.Series, - bigframes.core.col.Expression, - Union[Literal[sentinels.Sentinel.ARGUMENT_DEFAULT], datetime.date], - ], -) -> Union[series.Series, bigframes.core.col.Expression]: - """Returns the number of days since 1970-01-01.""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - _UNIX_DATE_OP, - date_expression, - ) diff --git a/bigframes/operations/json_ops.py b/bigframes/operations/json_ops.py index c9b5849f9ed..b1f4f2f6891 100644 --- a/bigframes/operations/json_ops.py +++ b/bigframes/operations/json_ops.py @@ -102,31 +102,16 @@ def output_type(self, *input_types): return dtypes.JSON_DTYPE -@dataclasses.dataclass(frozen=True) -class ToJSON(base_ops.UnaryOp): - name: typing.ClassVar[str] = "to_json" - safe: bool = True - - def output_type(self, *input_types): - input_type = input_types[0] - if not dtypes.is_json_encoding_type(input_type, strict=True): - 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_encoding_type(input_type): + if not dtypes.is_json_like(input_type): raise TypeError( - "The value to be assigned must be a type that can be encoded as JSON." - + f"Received type: {input_type}" + "Input type must be a valid JSON object or JSON-formatted string type." + + f" Received type: {input_type}" ) return dtypes.STRING_DTYPE @@ -200,28 +185,10 @@ def output_type(self, *input_types): 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 - safe: bool = True def output_type(self, *input_types): input_type = input_types[0] @@ -230,11 +197,4 @@ def output_type(self, *input_types): "Input type must be a valid JSON object or JSON-formatted string type." + f" Received type: {input_type}" ) - if self.to_type not in ( - dtypes.INT_DTYPE, - dtypes.FLOAT_DTYPE, - dtypes.BOOL_DTYPE, - dtypes.STRING_DTYPE, - ): - raise TypeError(f"Cannot cast from {dtypes.JSON_DTYPE} to {self.to_type}") return self.to_type diff --git a/bigframes/operations/lists.py b/bigframes/operations/lists.py index c0ff8d51650..16c22dfb2a9 100644 --- a/bigframes/operations/lists.py +++ b/bigframes/operations/lists.py @@ -19,27 +19,27 @@ import bigframes_vendored.pandas.core.arrays.arrow.accessors as vendoracessors +from bigframes.core import log_adapter import bigframes.operations as ops -import bigframes.series as series -from bigframes._tools import docs -from bigframes.core.logging import log_adapter from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.operations.base +import bigframes.series as series @log_adapter.class_logger -@docs.inherit_docs(vendoracessors.ListAccessor) -class ListAccessor: - def __init__(self, data: series.Series): - self._data = data +class ListAccessor( + bigframes.operations.base.SeriesMethods, vendoracessors.ListAccessor +): + __doc__ = vendoracessors.ListAccessor.__doc__ def len(self): - return self._data._apply_unary_op(ops.len_op) + return self._apply_unary_op(ops.len_op) def __getitem__(self, key: Union[int, slice]) -> series.Series: if isinstance(key, int): - return self._data._apply_unary_op(convert_index(key)) + return self._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._data._apply_unary_op(convert_slice(key)) + return self._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 af1eef74527..afdc924c0bf 100644 --- a/bigframes/operations/numeric_ops.py +++ b/bigframes/operations/numeric_ops.py @@ -15,9 +15,9 @@ import dataclasses import typing -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing SinOp = base_ops.create_unary_op( name="sin", type_signature=op_typing.UNARY_REAL_NUMERIC @@ -348,19 +348,3 @@ def output_type(self, *input_types: dtypes.ExpressionType) -> dtypes.ExpressionT 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 791e2eb8901..7f3decdfa06 100644 --- a/bigframes/operations/numpy_op_maps.py +++ b/bigframes/operations/numpy_op_maps.py @@ -40,8 +40,6 @@ 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 deleted file mode 100644 index ff9c9883dc0..00000000000 --- a/bigframes/operations/output_schemas.py +++ /dev/null @@ -1,90 +0,0 @@ -# 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 ecaa28e9747..a741ed5dd9a 100644 --- a/bigframes/operations/plotting.py +++ b/bigframes/operations/plotting.py @@ -17,16 +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 -from bigframes._tools import docs -from bigframes.core.logging import log_adapter @log_adapter.class_logger -@docs.inherit_docs(vendordt.PlotAccessor) -class PlotAccessor: - _common_kinds = ("line", "area", "hist", "bar", "barh", "pie") - _dataframe_kinds = ("scatter", "hexbin,") +class PlotAccessor(vendordt.PlotAccessor): + __doc__ = vendordt.PlotAccessor.__doc__ + + _common_kinds = ("line", "area", "hist", "bar") + _dataframe_kinds = ("scatter",) _all_kinds = _common_kinds + _dataframe_kinds def __call__(self, **kwargs): @@ -82,21 +82,6 @@ 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 deleted file mode 100644 index b4c58e14c7b..00000000000 --- a/bigframes/operations/python_op_maps.py +++ /dev/null @@ -1,126 +0,0 @@ -# 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, - generic_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, - operator.invert: generic_ops.invert_op, - operator.not_: generic_ops.invert_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, - str.isalnum: string_ops.isalnum_op, - str.isalpha: string_ops.isalpha_op, - str.isdecimal: string_ops.isdecimal_op, - str.isdigit: string_ops.isdigit_op, - str.isnumeric: string_ops.isnumeric_op, - str.isspace: string_ops.isspace_op, - str.islower: string_ops.islower_op, - str.isupper: string_ops.isupper_op, - str.capitalize: string_ops.capitalize_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), # type: ignore - any: array_ops.ArrayReduceOp(aggregations.any_op), # type: ignore - sum: array_ops.ArrayReduceOp(aggregations.sum_op), # type: ignore - min: array_ops.ArrayReduceOp(aggregations.min_op), # type: ignore - max: array_ops.ArrayReduceOp(aggregations.max_op), # type: ignore -} - - -def python_callable_to_op(obj) -> Optional[bigframes.operations.RowOp]: - if obj in PYTHON_TO_BIGFRAMES: - return PYTHON_TO_BIGFRAMES[obj] - return None - - -SERIES_METHOD_TO_OP = { - "abs": numeric_ops.abs_op, - "sqrt": numeric_ops.sqrt_op, - "sin": numeric_ops.sin_op, - "cos": numeric_ops.cos_op, - "tan": numeric_ops.tan_op, - "log": numeric_ops.ln_op, - "log10": numeric_ops.log10_op, - "exp": numeric_ops.exp_op, - "floor": numeric_ops.floor_op, - "ceil": numeric_ops.ceil_op, - "isnull": generic_ops.isnull_op, - "isna": generic_ops.isnull_op, - "notnull": generic_ops.notnull_op, - "notna": generic_ops.notnull_op, - "upper": string_ops.upper_op, - "lower": string_ops.lower_op, - "isalnum": string_ops.isalnum_op, - "isalpha": string_ops.isalpha_op, - "isdecimal": string_ops.isdecimal_op, - "isdigit": string_ops.isdigit_op, - "isnumeric": string_ops.isnumeric_op, - "isspace": string_ops.isspace_op, - "islower": string_ops.islower_op, - "isupper": string_ops.isupper_op, - "capitalize": string_ops.capitalize_op, -} diff --git a/bigframes/operations/remote_function_ops.py b/bigframes/operations/remote_function_ops.py index 3ce77d51c61..e610ce61d6e 100644 --- a/bigframes/operations/remote_function_ops.py +++ b/bigframes/operations/remote_function_ops.py @@ -19,22 +19,37 @@ from bigframes.operations import base_ops +# TODO: Enforce input type constraints from function def @dataclasses.dataclass(frozen=True) -class PythonUdfOp(base_ops.NaryOp): - name: typing.ClassVar[str] = "python_udf" - function_def: udf_def.PythonUdf +class RemoteFunctionOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "remote_function" + function_def: udf_def.BigqueryUdf + apply_on_null: bool @property def expensive(self) -> bool: return True def output_type(self, *input_types): - return self.function_def.signature.output.bf_type + return self.function_def.bigframes_output_type @dataclasses.dataclass(frozen=True) -class RemoteFunctionOp(base_ops.NaryOp): - name: typing.ClassVar[str] = "remote_function" +class BinaryRemoteFunctionOp(base_ops.BinaryOp): + name: typing.ClassVar[str] = "binary_remote_function" + function_def: udf_def.BigqueryUdf + + @property + def expensive(self) -> bool: + return True + + def output_type(self, *input_types): + return self.function_def.bigframes_output_type + + +@dataclasses.dataclass(frozen=True) +class NaryRemoteFunctionOp(base_ops.NaryOp): + name: typing.ClassVar[str] = "nary_remote_function" function_def: udf_def.BigqueryUdf @property @@ -42,4 +57,4 @@ def expensive(self) -> bool: return True def output_type(self, *input_types): - return self.function_def.signature.output.bf_type + return self.function_def.bigframes_output_type diff --git a/bigframes/operations/semantics.py b/bigframes/operations/semantics.py new file mode 100644 index 00000000000..9fa54507487 --- /dev/null +++ b/bigframes/operations/semantics.py @@ -0,0 +1,1176 @@ +# 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 re +import typing +from typing import List, Optional +import warnings + +import numpy as np + +from bigframes import dtypes, exceptions +from bigframes.core import guid, log_adapter + + +@log_adapter.class_logger +class Semantics: + def __init__(self, df) -> None: + import bigframes # Import in the function body to avoid circular imports. + import bigframes.dataframe + + if not bigframes.options.experiments.semantic_operators: + raise NotImplementedError() + + self._df: bigframes.dataframe.DataFrame = df + + def agg( + self, + instruction: str, + model, + cluster_column: typing.Optional[str] = None, + max_agg_rows: int = 10, + ground_with_google_search: bool = False, + ): + """ + Performs an aggregation over all rows of the table. + + This method recursively aggregates the input data to produce partial answers + in parallel, until a single answer remains. + + **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-2.0-flash-001") # doctest: +SKIP + + >>> df = bpd.DataFrame( + ... { + ... "Movies": [ + ... "Titanic", + ... "The Wolf of Wall Street", + ... "Inception", + ... ], + ... "Year": [1997, 2013, 2010], + ... }) + >>> df.semantics.agg( # doctest: +SKIP + ... "Find the first name shared by all actors in {Movies}. One word answer.", + ... model=model, + ... ) + 0 Leonardo + + Name: Movies, dtype: string + + Args: + instruction (str): + An instruction on how to map the data. This value must contain + column references by name enclosed in braces. + For example, to reference a column named "movies", use "{movies}" in the + instruction, like: "Find actor names shared by all {movies}." + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by the Bigframes ML package. + + cluster_column (Optional[str], default None): + If set, aggregates each cluster before performing aggregations across + clusters. Clustering based on semantic similarity can improve accuracy + of the sementic aggregations. + + max_agg_rows (int, default 10): + The maxinum number of rows to be aggregated at a time. + + 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.dataframe.DataFrame: A new DataFrame with the aggregated answers. + + Raises: + NotImplementedError: when the semantic operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when + more than one columns are referred to. + """ + import bigframes.bigquery as bbq + import bigframes.dataframe + import bigframes.series + + self._validate_model(model) + columns = self._parse_columns(instruction) + + if max_agg_rows <= 1: + raise ValueError( + f"Invalid value for `max_agg_rows`: {max_agg_rows}." + "It must be greater than 1." + ) + + work_estimate = len(self._df) * int(max_agg_rows / (max_agg_rows - 1)) + self._confirm_operation(work_estimate) + + df: bigframes.dataframe.DataFrame = self._df.copy() + for column in columns: + if column not in self._df.columns: + raise ValueError(f"Column {column} not found.") + + if df[column].dtype != dtypes.STRING_DTYPE: + df[column] = df[column].astype(dtypes.STRING_DTYPE) + + if len(columns) > 1: + raise NotImplementedError( + "Semantic aggregations are limited to a single column." + ) + column = columns[0] + + 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) + + user_instruction = self._format_instruction(instruction, columns) + + num_cluster = 1 + if cluster_column is not None: + if cluster_column not in df.columns: + raise ValueError(f"Cluster column `{cluster_column}` not found.") + + if df[cluster_column].dtype != dtypes.INT_DTYPE: + raise TypeError( + "Cluster column must be an integer type, not " + f"{type(df[cluster_column])}" + ) + + num_cluster = df[cluster_column].unique().shape[0] + df = df.sort_values(cluster_column) + else: + cluster_column = guid.generate_guid("pid") + df[cluster_column] = 0 + + aggregation_group_id = guid.generate_guid("agg") + group_row_index = guid.generate_guid("gid") + llm_prompt = guid.generate_guid("prompt") + df = ( + df.reset_index(drop=True) + .reset_index() + .rename(columns={"index": aggregation_group_id}) + ) + + output_instruction = ( + "Answer user instructions using the provided context from various sources. " + "Combine all relevant information into a single, concise, well-structured response. " + f"Instruction: {user_instruction}.\n\n" + ) + + while len(df) > 1: + df[group_row_index] = (df[aggregation_group_id] % max_agg_rows + 1).astype( + dtypes.STRING_DTYPE + ) + df[aggregation_group_id] = (df[aggregation_group_id] / max_agg_rows).astype( + dtypes.INT_DTYPE + ) + df[llm_prompt] = "\t\nSource #" + df[group_row_index] + ": " + df[column] + + if len(df) > num_cluster: + # Aggregate within each partition + agg_df = bbq.array_agg( + df.groupby(by=[cluster_column, aggregation_group_id]) + ) + else: + # Aggregate cross partitions + agg_df = bbq.array_agg(df.groupby(by=[aggregation_group_id])) + agg_df[cluster_column] = agg_df[cluster_column].list[0] + + # Skip if the aggregated group only has a single item + single_row_df: bigframes.series.Series = bbq.array_to_string( + agg_df[agg_df[group_row_index].list.len() <= 1][column], + delimiter="", + ) + prompt_s: bigframes.series.Series = bbq.array_to_string( + agg_df[agg_df[group_row_index].list.len() > 1][llm_prompt], + delimiter="", + ) + prompt_s = output_instruction + prompt_s # type:ignore + + # Run model + predict_df = typing.cast( + bigframes.dataframe.DataFrame, + model.predict( + prompt_s, + temperature=0.0, + ground_with_google_search=ground_with_google_search, + ), + ) + agg_df[column] = predict_df["ml_generate_text_llm_result"].combine_first( + single_row_df + ) + + agg_df = agg_df.reset_index() + df = agg_df[[aggregation_group_id, cluster_column, column]] + + return df[column] + + def cluster_by( + self, + column: str, + output_column: str, + model, + n_clusters: int = 5, + ): + """ + Clusters data based on the semantic similarity of text within a specified column. + + This method leverages a language model to generate text embeddings for each value in + the given column. These embeddings capture the semantic meaning of the text. + The data is then grouped into `n` clusters using the k-means clustering algorithm, + which groups data points based on the similarity of their embeddings. + + **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") + + >>> df = bpd.DataFrame({ + ... "Product": ["Smartphone", "Laptop", "T-shirt", "Jeans"], + ... }) + >>> df.semantics.cluster_by("Product", "Cluster ID", model, n_clusters=2) # doctest: +SKIP + Product Cluster ID + 0 Smartphone 2 + 1 Laptop 2 + 2 T-shirt 1 + 3 Jeans 1 + + [4 rows x 2 columns] + + Args: + column (str): + An column name to perform the similarity clustering. + + output_column (str): + An output column to store the clustering ID. + + model (bigframes.ml.llm.TextEmbeddingGenerator): + A TextEmbeddingGenerator provided by Bigframes ML package. + + n_clusters (int, default 5): + Default 5. Number of clusters to be detected. + + Returns: + bigframes.dataframe.DataFrame: A new DataFrame with the clustering output column. + + Raises: + NotImplementedError: when the semantic operator experiment is off. + ValueError: when the column refers to a non-existing column. + """ + + import bigframes.dataframe + import bigframes.ml.cluster as cluster + 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 column not in self._df.columns: + raise ValueError(f"Column {column} not found.") + + if n_clusters <= 1: + raise ValueError( + f"Invalid value for `n_clusters`: {n_clusters}." + "It must be greater than 1." + ) + + self._confirm_operation(len(self._df)) + + df: bigframes.dataframe.DataFrame = self._df.copy() + embeddings_df = model.predict(df[column]) + + cluster_model = cluster.KMeans(n_clusters=n_clusters) + cluster_model.fit(embeddings_df[["ml_generate_embedding_result"]]) + clustered_result = cluster_model.predict(embeddings_df) + df[output_column] = clustered_result["CENTROID_ID"] + return df + + 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.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-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) # doctest: +SKIP + 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 semantic operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when no + columns are referred to. + """ + 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 context, reply to the following claim by only True or False:" + + 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") + ] + + def map( + self, + instruction: str, + output_column: str, + model, + ground_with_google_search: bool = False, + ): + """ + Maps the DataFrame with the semantics of the user instruction. + + **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-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) # doctest: +SKIP + ingredient_1 ingredient_2 food + 0 Burger Bun Beef Patty Burger + + 1 Soy Bean Bittern Tofu + + + [2 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}." + + output_column (str): + The column name of the mapping result. + + 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 with attached mapping results. + + Raises: + NotImplementedError: when the semantic operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when no + columns are referred to. + """ + 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 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 + + return concat([self._df, results.rename(output_column)], axis=1) + + 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.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-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) # doctest: +SKIP + 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. + + max_rows (int, default 1000): + The maximum number of rows allowed to be sent to the model per call. If the result is too large, the method + call will end early with an error. + + 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. + """ + 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 + >>> 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") # doctest: +SKIP + + >>> df = bpd.DataFrame({"creatures": ["salmon", "sea urchin", "frog", "chimpanzee"]}) + >>> df.semantics.search("creatures", "monkey", top_k=1, model=model, score_column='distance') # doctest: +SKIP + 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 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, + ) + .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 top_k( + self, + instruction: str, + model, + k: int = 10, + ground_with_google_search: bool = False, + ): + """ + Ranks each tuple and returns the k best according to the instruction. + + This method employs a quick select algorithm to efficiently compare the pivot + with all other items. By leveraging an LLM (Large Language Model), it then + identifies the top 'k' best answers from these comparisons. + + **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-2.0-flash-001") # doctest: +SKIP + + >>> 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 2 columns] + + Args: + instruction (str): + An instruction on how to map the data. This value must contain + column references by name enclosed in braces. + For example, to reference a column named "Animals", use "{Animals}" in the + instruction, like: "{Animals} are more popular as pets" + + model (bigframes.ml.llm.GeminiTextGenerator): + A GeminiTextGenerator provided by the Bigframes ML package. + + k (int, default 10): + The number of rows to return. + + 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.dataframe.DataFrame: A new DataFrame with the top k rows. + + Raises: + NotImplementedError: when the semantic operator experiment is off. + ValueError: when the instruction refers to a non-existing column, or when no + columns are referred to. + """ + 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 len(columns) > 1: + raise NotImplementedError("Semantic top K are limited to a single column.") + + 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 = int(len(self._df) * (len(self._df) - 1) / 2) + self._confirm_operation(work_estimate) + + df: bigframes.dataframe.DataFrame = self._df[columns].copy() + column = columns[0] + if df[column].dtype != dtypes.STRING_DTYPE: + df[column] = df[column].astype(dtypes.STRING_DTYPE) + + # `index` is reserved for the `reset_index` below. + if column == "index": + raise ValueError( + "Column name 'index' is reserved. Please choose a different name." + ) + + if k < 1: + raise ValueError("k must be an integer greater than or equal to 1.") + + user_instruction = self._format_instruction(instruction, columns) + + n = df.shape[0] + if k >= n: + return df + + # Create a unique index and duplicate it as the "index" column. This workaround + # is needed for the select search algorithm due to unimplemented bigFrame methods. + df = df.reset_index().rename(columns={"index": "old_index"}).reset_index() + + # Initialize a status column to track the selection status of each item. + # - None: Unknown/not yet processed + # - 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, session=df._session + ) + + num_selected = 0 + while num_selected < k: + df, num_new_selected = self._topk_partition( + df, + column, + status_column, + user_instruction, + model, + k - num_selected, + ground_with_google_search, + ) + num_selected += num_new_selected + + result_df: bigframes.dataframe.DataFrame = self._df.copy() + return result_df[df.set_index("old_index")[status_column] > 0.0] + + @staticmethod + def _topk_partition( + df, + column: str, + status_column: str, + user_instruction: str, + model, + k: int, + ground_with_google_search: bool, + ): + output_instruction = ( + "Given a question and two documents, choose the document that best answers " + "the question. Respond with 'Document 1' or 'Document 2'. You must choose " + "one, even if neither is ideal. " + ) + + # Random pivot selection for improved average quickselect performance. + pending_df = df[df[status_column].isna()] + pivot_iloc = np.random.randint(0, pending_df.shape[0]) + pivot_index = pending_df.iloc[pivot_iloc]["index"] + pivot_df = pending_df[pending_df["index"] == pivot_index] + + # Build a prompt to compare the pivot item's relevance to other pending items. + prompt_s = pending_df[pending_df["index"] != pivot_index][column] + prompt_s = ( + f"{output_instruction}\n\nQuestion: {user_instruction}\n" + + f"\nDocument 1: {column} " + + pivot_df.iloc[0][column] + + f"\nDocument 2: {column} " + + prompt_s # type:ignore + ) + + import bigframes.dataframe + + predict_df = typing.cast( + bigframes.dataframe.DataFrame, + model.predict( + prompt_s, + temperature=0.0, + ground_with_google_search=ground_with_google_search, + ), + ) + + marks = predict_df["ml_generate_text_llm_result"].str.contains("2") + more_relavant: bigframes.dataframe.DataFrame = df[marks] + less_relavent: bigframes.dataframe.DataFrame = df[~marks] + + num_more_relavant = more_relavant.shape[0] + if k < num_more_relavant: + less_relavent[status_column] = -1.0 + pivot_df[status_column] = -1.0 + df = df.combine_first(less_relavent).combine_first(pivot_df) + return df, 0 + else: # k >= num_more_relavant + more_relavant[status_column] = 1.0 + df = df.combine_first(more_relavant) + if k >= num_more_relavant + 1: + pivot_df[status_column] = 1.0 + df = df.combine_first(pivot_df) + return df, num_more_relavant + 1 + else: + return df, num_more_relavant + + 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.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") # 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) # doctest: +SKIP + 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 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 + + @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.semantic_ops_confirmation_threshold + + if threshold is None or row_count <= threshold: + return + + if bigframes.options.compute.semantic_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.semantic_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/string_ops.py b/bigframes/operations/string_ops.py index 21d23416314..f937ed23b6b 100644 --- a/bigframes/operations/string_ops.py +++ b/bigframes/operations/string_ops.py @@ -18,9 +18,9 @@ import pandas as pd import pyarrow as pa -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing LenOp = base_ops.create_unary_op( name="len", @@ -30,23 +30,6 @@ ) len_op = LenOp() -## 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 ) @@ -126,6 +109,15 @@ def output_type(self, *input_types): return op_typing.STRING_PREDICATE.output_type(input_types[0]) +@dataclasses.dataclass(frozen=True) +class StrGetOp(base_ops.UnaryOp): + name: typing.ClassVar[str] = "str_get" + i: int + + def output_type(self, *input_types): + return op_typing.STRING_TRANSFORM.output_type(input_types[0]) + + @dataclasses.dataclass(frozen=True) class StrPadOp(base_ops.UnaryOp): name: typing.ClassVar[str] = "str_pad" diff --git a/bigframes/operations/strings.py b/bigframes/operations/strings.py index ad9bfd6da40..9022a1665ed 100644 --- a/bigframes/operations/strings.py +++ b/bigframes/operations/strings.py @@ -15,20 +15,17 @@ from __future__ import annotations import re -from typing import Generic, Hashable, Literal, Optional, TypeVar, Union +from typing import Literal, Optional, Union import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.strings.accessor as vendorstr -import bigframes.core.col -import bigframes.core.indexes.base as indices +from bigframes.core import log_adapter import bigframes.dataframe as df import bigframes.operations as ops -import bigframes.operations.aggregations as agg_ops -import bigframes.series as series -from bigframes._tools import docs -from bigframes.core.logging import log_adapter from bigframes.operations._op_converters import convert_index, convert_slice +import bigframes.operations.base +import bigframes.series as series # Maps from python to re2 REGEXP_FLAGS = { @@ -37,20 +34,16 @@ re.DOTALL: "s", } -T = TypeVar("T", series.Series, indices.Index, bigframes.core.col.Expression) - @log_adapter.class_logger -@docs.inherit_docs(vendorstr.StringMethods) -class StringMethods(Generic[T]): - def __init__(self, data: T): - self._data: T = data +class StringMethods(bigframes.operations.base.SeriesMethods, vendorstr.StringMethods): + __doc__ = vendorstr.StringMethods.__doc__ - def __getitem__(self, key: Union[int, slice]) -> T: + def __getitem__(self, key: Union[int, slice]) -> series.Series: if isinstance(key, int): - return self._data._apply_unary_op(convert_index(key)) + return self._apply_unary_op(convert_index(key)) elif isinstance(key, slice): - return self._data._apply_unary_op(convert_slice(key)) + return self._apply_unary_op(convert_slice(key)) else: raise ValueError(f"key must be an int or slice, got {type(key).__name__}") @@ -59,24 +52,24 @@ def find( sub: str, start: Optional[int] = None, end: Optional[int] = None, - ) -> T: - return self._data._apply_unary_op( - ops.StrFindOp(substr=sub, start=start, end=end) - ) + ) -> series.Series: + return self._apply_unary_op(ops.StrFindOp(substr=sub, start=start, end=end)) - def len(self) -> T: - return self._data._apply_unary_op(ops.len_op) + def len(self) -> series.Series: + return self._apply_unary_op(ops.len_op) - def lower(self) -> T: - return self._data._apply_unary_op(ops.lower_op) + def lower(self) -> series.Series: + return self._apply_unary_op(ops.lower_op) - def reverse(self) -> T: + def reverse(self) -> series.Series: """Reverse strings in the Series. **Examples:** >>> import bigframes.pandas as bpd - >>> s = bpd.Series(["apple", "banana", "", pd.NA]) + >>> bpd.options.display.progress_bar = None + + >>> s = bpd.Series(["apple", "banana", "", bpd.NA]) >>> s.str.reverse() 0 elppa 1 ananab @@ -89,118 +82,118 @@ def reverse(self) -> T: pattern matches the start of each string element. """ # reverse method is in ibis, not pandas. - return self._data._apply_unary_op(ops.reverse_op) + return self._apply_unary_op(ops.reverse_op) def slice( self, start: Optional[int] = None, stop: Optional[int] = None, - ) -> T: - return self._data._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) + ) -> series.Series: + return self._apply_unary_op(ops.StrSliceOp(start=start, end=stop)) - def strip(self, to_strip: Optional[str] = None) -> T: - return self._data._apply_unary_op( + def strip(self, to_strip: Optional[str] = None) -> series.Series: + return self._apply_unary_op( ops.StrStripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def upper(self) -> T: - return self._data._apply_unary_op(ops.upper_op) + def upper(self) -> series.Series: + return self._apply_unary_op(ops.upper_op) - def isnumeric(self) -> T: - return self._data._apply_unary_op(ops.isnumeric_op) + def isnumeric(self) -> series.Series: + return self._apply_unary_op(ops.isnumeric_op) def isalpha( self, - ) -> T: - return self._data._apply_unary_op(ops.isalpha_op) + ) -> series.Series: + return self._apply_unary_op(ops.isalpha_op) def isdigit( self, - ) -> T: - return self._data._apply_unary_op(ops.isdigit_op) + ) -> series.Series: + return self._apply_unary_op(ops.isdigit_op) def isdecimal( self, - ) -> T: - return self._data._apply_unary_op(ops.isdecimal_op) + ) -> series.Series: + return self._apply_unary_op(ops.isdecimal_op) def isalnum( self, - ) -> T: - return self._data._apply_unary_op(ops.isalnum_op) + ) -> series.Series: + return self._apply_unary_op(ops.isalnum_op) def isspace( self, - ) -> T: - return self._data._apply_unary_op(ops.isspace_op) + ) -> series.Series: + return self._apply_unary_op(ops.isspace_op) def islower( self, - ) -> T: - return self._data._apply_unary_op(ops.islower_op) + ) -> series.Series: + return self._apply_unary_op(ops.islower_op) def isupper( self, - ) -> T: - return self._data._apply_unary_op(ops.isupper_op) + ) -> series.Series: + return self._apply_unary_op(ops.isupper_op) - def rstrip(self, to_strip: Optional[str] = None) -> T: - return self._data._apply_unary_op( + def rstrip(self, to_strip: Optional[str] = None) -> series.Series: + return self._apply_unary_op( ops.StrRstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def lstrip(self, to_strip: Optional[str] = None) -> T: - return self._data._apply_unary_op( + def lstrip(self, to_strip: Optional[str] = None) -> series.Series: + return self._apply_unary_op( ops.StrLstripOp(to_strip=" \n\t" if to_strip is None else to_strip) ) - def repeat(self, repeats: int) -> T: - return self._data._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) + def repeat(self, repeats: int) -> series.Series: + return self._apply_unary_op(ops.StrRepeatOp(repeats=repeats)) - def capitalize(self) -> T: - return self._data._apply_unary_op(ops.capitalize_op) + def capitalize(self) -> series.Series: + return self._apply_unary_op(ops.capitalize_op) - def match(self, pat, case=True, flags=0) -> T: + def match(self, pat, case=True, flags=0) -> series.Series: # \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) -> T: + def fullmatch(self, pat, case=True, flags=0) -> series.Series: # \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) -> T: - return self._data._apply_unary_op(ops.GetItemOp(key=i)) + def get(self, i: int) -> series.Series: + return self._apply_unary_op(ops.StrGetOp(i=i)) - def pad(self, width, side="left", fillchar=" ") -> T: - return self._data._apply_unary_op( + def pad(self, width, side="left", fillchar=" ") -> series.Series: + return self._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side=side) ) - def ljust(self, width, fillchar=" ") -> T: - return self._data._apply_unary_op( + def ljust(self, width, fillchar=" ") -> series.Series: + return self._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="right") ) - def rjust(self, width, fillchar=" ") -> T: - return self._data._apply_unary_op( + def rjust(self, width, fillchar=" ") -> series.Series: + return self._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="left") ) def contains( self, pat, case: bool = True, flags: int = 0, *, regex: bool = True - ) -> T: + ) -> series.Series: 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._data._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) + return self._apply_unary_op(ops.StrContainsRegexOp(pat=pat)) else: - return self._data._apply_unary_op(ops.StrContainsOp(pat=pat)) + return self._apply_unary_op(ops.StrContainsOp(pat=pat)) def extract(self, pat: str, flags: int = 0) -> df.DataFrame: re2flags = _parse_flags(flags) @@ -210,19 +203,23 @@ def extract(self, pat: str, flags: int = 0) -> df.DataFrame: if compiled.groups == 0: raise ValueError("No capture groups in 'pat'") - results: dict[Hashable, series.Series] = {} + results: list[str] = [] + block = self._block 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 i - result = self._data._apply_unary_op( + label = labels[0] if labels else str(i) + block, id = block.apply_unary_op( + self._value_column, ops.StrExtractOp(pat=pat, n=i + 1), + result_label=label, ) - results[label] = series.Series(result) - return df.DataFrame(results) + results.append(id) + block = block.select_columns(results) + return df.DataFrame(block) def replace( self, @@ -232,7 +229,7 @@ def replace( case: Optional[bool] = None, flags: int = 0, regex: bool = False, - ) -> T: + ) -> series.Series: if isinstance(pat, re.Pattern): assert isinstance(pat.pattern, str) pat_str = pat.pattern @@ -246,64 +243,81 @@ def replace( re2flags = _parse_flags(flags) if re2flags: pat_str = re2flags + pat_str - return self._data._apply_unary_op( - ops.RegexReplaceStrOp(pat=pat_str, repl=repl) - ) + return self._apply_unary_op(ops.RegexReplaceStrOp(pat=pat_str, repl=repl)) else: if isinstance(pat, re.Pattern): raise ValueError( "Must set 'regex'=True if using compiled regex pattern." ) - return self._data._apply_unary_op(ops.ReplaceStrOp(pat=pat_str, repl=repl)) + return self._apply_unary_op(ops.ReplaceStrOp(pat=pat_str, repl=repl)) def startswith( self, pat: Union[str, tuple[str, ...]], - ) -> T: + ) -> series.Series: if not isinstance(pat, tuple): pat = (pat,) - return self._data._apply_unary_op(ops.StartsWithOp(pat=pat)) + return self._apply_unary_op(ops.StartsWithOp(pat=pat)) def endswith( self, pat: Union[str, tuple[str, ...]], - ) -> T: + ) -> series.Series: if not isinstance(pat, tuple): pat = (pat,) - return self._data._apply_unary_op(ops.EndsWithOp(pat=pat)) + return self._apply_unary_op(ops.EndsWithOp(pat=pat)) def split( self, pat: str = " ", regex: Union[bool, None] = None, - ) -> T: + ) -> series.Series: 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._data._apply_unary_op(ops.StringSplitOp(pat=pat)) + return self._apply_unary_op(ops.StringSplitOp(pat=pat)) - def zfill(self, width: int) -> T: - return self._data._apply_unary_op(ops.ZfillOp(width=width)) + def zfill(self, width: int) -> series.Series: + return self._apply_unary_op(ops.ZfillOp(width=width)) - def center(self, width: int, fillchar: str = " ") -> T: - return self._data._apply_unary_op( + def center(self, width: int, fillchar: str = " ") -> series.Series: + return self._apply_unary_op( ops.StrPadOp(length=width, fillchar=fillchar, side="both") ) def cat( self, - others: Union[str, indices.Index, series.Series], + others: Union[str, series.Series], *, join: Literal["outer", "left"] = "left", - ) -> T: - return self._data._apply_binary_op(others, ops.strconcat_op, alignment=join) + ) -> series.Series: + return self._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: + """Create a BigFrames Blob series from a series of URIs. + + .. 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). + + + Args: + connection (str or None, default 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. + + Returns: + bigframes.series.Series: Blob Series. + + """ + session = self._block.session + connection = session._create_bq_connection(connection=connection) + return self._apply_binary_op(connection, ops.obj_make_ref_op) def _parse_flags(flags: int) -> Optional[str]: diff --git a/bigframes/operations/struct_ops.py b/bigframes/operations/struct_ops.py index de51efd8a48..0926142b17f 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 c5446510a6d..051023c2994 100644 --- a/bigframes/operations/structs.py +++ b/bigframes/operations/structs.py @@ -17,60 +17,50 @@ import bigframes_vendored.pandas.core.arrays.arrow.accessors as vendoracessors import pandas as pd +from bigframes.core import log_adapter import bigframes.dataframe +import bigframes.dtypes import bigframes.operations +import bigframes.operations.base import bigframes.series -from bigframes._tools import docs -from bigframes.core import backports -from bigframes.core.logging import log_adapter @log_adapter.class_logger -@docs.inherit_docs(vendoracessors.StructAccessor) -class StructAccessor: - def __init__(self, data: bigframes.series.Series): - self._data = data +class StructAccessor( + bigframes.operations.base.SeriesMethods, vendoracessors.StructAccessor +): + __doc__ = vendoracessors.StructAccessor.__doc__ def field(self, name_or_index: str | int) -> bigframes.series.Series: - series = self._data._apply_unary_op( - bigframes.operations.StructFieldOp(name_or_index) - ) + series = self._apply_unary_op(bigframes.operations.StructFieldOp(name_or_index)) if isinstance(name_or_index, str): name = name_or_index else: - struct_field = self._data._dtype.pyarrow_dtype[name_or_index] + struct_field = self._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._data._dtype.pyarrow_dtype + pa_type = self._dtype.pyarrow_dtype return bigframes.pandas.concat( - [ - self.field(field.name) - for field in backports.pyarrow_struct_type_fields(pa_type) - ], - axis="columns", + [self.field(i) for i in range(pa_type.num_fields)], axis="columns" ) - @property def dtypes(self) -> pd.Series: - pa_type = self._data._dtype.pyarrow_dtype + pa_type = self._dtype.pyarrow_dtype return pd.Series( data=[ - 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) + bigframes.dtypes.arrow_dtype_to_bigframes_dtype(pa_type.field(i).type) + for i in range(pa_type.num_fields) ], + index=[pa_type.field(i).name for i in range(pa_type.num_fields)], ) @log_adapter.class_logger -@docs.inherit_docs(vendoracessors.StructFrameAccessor) -class StructFrameAccessor: +class StructFrameAccessor(vendoracessors.StructFrameAccessor): __doc__ = vendoracessors.StructAccessor.__doc__ def __init__(self, data: bigframes.dataframe.DataFrame) -> None: diff --git a/bigframes/operations/time_ops.py b/bigframes/operations/time_ops.py index 3b6845053a7..bf6fa3e7d1e 100644 --- a/bigframes/operations/time_ops.py +++ b/bigframes/operations/time_ops.py @@ -12,9 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import bigframes.operations.type as op_typing from bigframes import dtypes from bigframes.operations import base_ops +import bigframes.operations.type as op_typing HourOp = base_ops.create_unary_op( name="hour", diff --git a/bigframes/operations/to_op.py b/bigframes/operations/to_op.py deleted file mode 100644 index 4f97a61e3c0..00000000000 --- a/bigframes/operations/to_op.py +++ /dev/null @@ -1,201 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 -import typing - -import bigframes.core.expression as ex -from bigframes._config import options -from bigframes.exceptions import TranspilationError -from bigframes.functions import Udf -from bigframes.functions.udf_def import BigqueryUdf, PythonUdf -from bigframes.operations import base_ops, remote_function_ops - -ArgKind = typing.Literal[ - "positional_only", - "positional_or_keyword", - "keyword_only", - "var_positional", - "var_keyword", -] - -_ARGKIND_MAP: dict[inspect._ParameterKind, ArgKind] = { - inspect.Parameter.POSITIONAL_ONLY: "positional_only", - inspect.Parameter.POSITIONAL_OR_KEYWORD: "positional_or_keyword", - inspect.Parameter.VAR_POSITIONAL: "var_positional", - inspect.Parameter.KEYWORD_ONLY: "keyword_only", - inspect.Parameter.VAR_KEYWORD: "var_keyword", -} - - -@dataclasses.dataclass(frozen=True) -class ArgumentSpec: - """ - Information about a single argument to a function - """ - - name: str - default_value: typing.Any - argkind: ArgKind - - @property - def is_positional(self) -> bool: - return self.argkind in ["positional_only", "positional_or_keyword"] - - @property - def is_keyword(self) -> bool: - return self.argkind in ["keyword_only", "positional_or_keyword"] - - @property - def is_var_positional(self) -> bool: - return self.argkind == "var_positional" - - @property - def is_var_keyword(self) -> bool: - return self.argkind == "var_keyword" - - @property - def is_varargs(self) -> bool: - return self.is_var_positional - - -@dataclasses.dataclass(frozen=True) -class CallableExpression: - """ - Encodes a calling convention and an expression to bind arguments to. - """ - - expr: ex.Expression - arg_specs: typing.Sequence[ArgumentSpec] - - @classmethod - def from_callable(cls, func: typing.Callable) -> CallableExpression: - sig = inspect.signature(func) - arg_specs = [] - for name, param in sig.parameters.items(): - arg_specs.append( - ArgumentSpec( - name=name, - default_value=param.default, - argkind=_ARGKIND_MAP[param.kind], - ) - ) - - from bigframes.core.bytecode import py_to_expression - - try: - expr = py_to_expression(func) - except Exception as ex: - raise TranspilationError(f"Failed to transpile function {func}") from ex - return cls(expr=expr, arg_specs=arg_specs) - - def apply(self, *args, **kwargs) -> ex.Expression: - """ - Apply the arguments to the expression. - - All args are expected to be column references, or scalars. - """ - return self.bind_partial(*args, _offset=0, **kwargs).expr - - def bind_partial( - self, - *args, - _offset: int = 0, - **kwargs, - ) -> CallableExpression: - """ - Bind a subset of arguments and return a new CallableExpression with the remaining unbound arguments. - """ - bindings: dict[typing.Hashable, ex.Expression] = {} - pos_idx = 0 - allowed_params = self.arg_specs[_offset:] - allowed_names = {spec.name for spec in allowed_params} - - # Validate unexpected keyword arguments - for key in kwargs: - if key not in allowed_names: - raise TypeError(f"got an unexpected keyword argument '{key}'") - - def to_expr(val): - if isinstance(val, ex.Expression): - return val - return ex.const(val) - - for spec in allowed_params: - if spec.is_varargs: - raise NotImplementedError( - "varargs in compiled python functions is not supported" - ) - - if pos_idx < len(args): - if spec.name in kwargs: - raise TypeError( - f"got multiple values for keyword argument '{spec.name}'" - ) - bindings[spec.name] = to_expr(args[pos_idx]) - pos_idx += 1 - elif spec.name in kwargs: - bindings[spec.name] = to_expr(kwargs[spec.name]) - elif spec.default_value is not inspect.Parameter.empty: - bindings[spec.name] = to_expr(spec.default_value) - else: - raise TypeError(f"missing required argument: '{spec.name}'") - - if pos_idx < len(args): - raise TypeError( - f"too many positional arguments: expected {len(allowed_params)}, got {len(args)}" - ) - - new_expr = self.expr.bind_variables(bindings, allow_partial_bindings=True) - remaining_specs = list(self.arg_specs[:_offset]) - return CallableExpression(expr=new_expr, arg_specs=remaining_specs) - - -def func_to_expr(op) -> CallableExpression: - """ - Convert various bigframes, python functions into bigframes CallableExpression. - """ - if isinstance(op, Udf): - bq_op: base_ops.NaryOp - if isinstance(op.udf_def, BigqueryUdf): - bq_op = remote_function_ops.RemoteFunctionOp(function_def=op.udf_def) - elif isinstance(op.udf_def, PythonUdf): - bq_op = remote_function_ops.PythonUdfOp(function_def=op.udf_def) - else: - raise TypeError(f"Unsupported UDF definition: {op.udf_def}") - - inputs_expr = tuple( - ex.free_var(arg.name) for arg in op.udf_def.signature.inputs - ) - expr = ex.OpExpression(bq_op, inputs_expr) - - arg_specs = [ - ArgumentSpec( - name=arg.name, - default_value=inspect.Parameter.empty, - # Udf specs don't have concept of positional only or keyword only yet, - # so default to positional_or_keyword. - argkind="positional_or_keyword", - ) - for arg in op.udf_def.signature.inputs - ] - return CallableExpression(expr=expr, arg_specs=arg_specs) - - elif options.experiments.enable_python_transpiler and callable(op): - return CallableExpression.from_callable(op) - - else: - raise TypeError(f"Unsupported function type: {op}") diff --git a/bigframes/operations/type.py b/bigframes/operations/type.py index 0ddf3a113fc..b4029d74c77 100644 --- a/bigframes/operations/type.py +++ b/bigframes/operations/type.py @@ -34,13 +34,11 @@ def as_method(self): """Convert the signature into an object method. Convenience function for constructing ops that use the signature.""" ... - def __call__(self, *args, **kwargs): - return self.as_method(*args, **kwargs) - class UnaryTypeSignature(TypeSignature): @abc.abstractmethod - def output_type(self, input_type: ExpressionType) -> ExpressionType: ... + def output_type(self, input_type: ExpressionType) -> ExpressionType: + ... @property def as_method(self): @@ -55,7 +53,8 @@ class BinaryTypeSignature(TypeSignature): @abc.abstractmethod def output_type( self, left_type: ExpressionType, right_type: ExpressionType - ) -> ExpressionType: ... + ) -> ExpressionType: + ... @property def as_method(self): @@ -175,7 +174,15 @@ class CoerceCommon(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - return bigframes.dtypes.coerce_to_common(left_type, right_type) + 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.") @dataclasses.dataclass @@ -185,7 +192,8 @@ class Comparison(BinaryTypeSignature): def output_type( self, left_type: ExpressionType, right_type: ExpressionType ) -> ExpressionType: - if not bigframes.dtypes.can_compare(left_type, right_type): + common_type = CoerceCommon().output_type(left_type, right_type) + if not bigframes.dtypes.is_comparable(common_type): raise TypeError(f"Types {left_type} and {right_type} are not comparable") return bigframes.dtypes.BOOL_DTYPE @@ -205,7 +213,7 @@ def output_type( raise TypeError(f"Type {right_type} is not binary") if left_type != right_type: raise TypeError( - f"Bitwise operands {left_type} and {right_type} do not match" + "Bitwise operands {left_type} and {right_type} do not match" ) return left_type @@ -223,7 +231,7 @@ def output_type( raise TypeError(f"Type {right_type} is not array-like") if left_type != right_type: raise TypeError( - f"Vector op operands {left_type} and {right_type} do not match" + "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 b88816ab5ab..6ffed5b53fd 100644 --- a/bigframes/pandas/__init__.py +++ b/bigframes/pandas/__init__.py @@ -12,69 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -""" -The primary entry point for the BigQuery DataFrames (BigFrames) pandas-compatible API. - -**BigQuery DataFrames** provides a Pythonic DataFrame and machine learning (ML) API -powered by the BigQuery engine. The ``bigframes.pandas`` module implements a large -subset of the pandas API, allowing you to perform large-scale data analysis -using familiar pandas syntax while the computations are executed in the cloud. - -**Key Features:** - -* **Petabyte-Scale Scalability:** Handle datasets that exceed local memory by - offloading computation to the BigQuery distributed engine. -* **Pandas Compatibility:** Use common pandas methods like - :func:`~bigframes.pandas.DataFrame.groupby`, - :func:`~bigframes.pandas.DataFrame.merge`, - :func:`~bigframes.pandas.DataFrame.pivot_table`, and more on BigQuery-backed - :class:`~bigframes.pandas.DataFrame` objects. -* **Direct BigQuery Integration:** Read from and write to BigQuery tables and - queries with :func:`bigframes.pandas.read_gbq` and - :func:`bigframes.pandas.DataFrame.to_gbq`. -* **User-defined Functions (UDFs):** Effortlessly deploy Python functions - functions using the :func:`bigframes.pandas.remote_function` and - :func:`bigframes.pandas.udf` decorators. -* **Data Ingestion:** Support for various formats including CSV, Parquet, JSON, - and Arrow via :func:`bigframes.pandas.read_csv`, - :func:`bigframes.pandas.read_parquet`, etc., which are automatically uploaded - to BigQuery for processing. Convert any pandas DataFrame into a BigQuery - DataFrame using :func:`bigframes.pandas.read_pandas`. - -**Example usage:** - - >>> import bigframes.pandas as bpd - -Initialize session and set options. - - >>> bpd.options.bigquery.project = "your-project-id" # doctest: +SKIP - -Load data from a BigQuery public dataset. - - >>> df = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") # doctest: +SKIP - -Perform familiar pandas operations that execute in the cloud. - - >>> top_names = ( - ... df.groupby("name") - ... .agg({"number": "sum"}) - ... .sort_values("number", ascending=False) - ... .head(10) - ... ) # doctest: +SKIP - -Bring the final, aggregated results back to local memory if needed. - - >>> local_df = top_names.to_pandas() # doctest: +SKIP - -BigQuery DataFrames is designed for data scientists and analysts who need the -power of BigQuery with the ease of use of pandas. It eliminates the "data -movement bottleneck" by keeping your data in BigQuery for processing. -""" +"""BigQuery DataFrames provides a DataFrame API backed by the BigQuery engine.""" from __future__ import annotations -import collections -import datetime +from collections import namedtuple +from datetime import datetime import inspect import sys import typing @@ -84,36 +27,37 @@ import pandas import bigframes._config as config +from bigframes.core import log_adapter +import bigframes.core.blocks 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 import bigframes.dataframe +import bigframes.enums import bigframes.functions._utils as bff_utils -import bigframes.series -import bigframes.session -import bigframes.session._io.bigquery -import bigframes.version -from bigframes.core.col import col -from bigframes.core.logging import log_adapter -from bigframes.core.reshape.api import concat, crosstab, cut, get_dummies, merge, qcut -from bigframes.pandas import api from bigframes.pandas.core.api import to_timedelta from bigframes.pandas.io.api import ( - _from_glob_path, _read_gbq_colab, + from_glob_path, read_arrow, - read_avro, read_csv, read_gbq, read_gbq_function, read_gbq_model, + read_gbq_object_table, read_gbq_query, read_gbq_table, read_json, - read_orc, read_pandas, read_parquet, read_pickle, ) +import bigframes.series +import bigframes.session +import bigframes.session._io.bigquery +import bigframes.session.clients +import bigframes.version try: import resource @@ -143,11 +87,7 @@ def remote_function( 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] = None, - cloud_function_cpus: Optional[float] = None, + cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" ] = "internal-only", @@ -169,9 +109,7 @@ 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_cpus=cloud_function_cpus, cloud_function_ingress_settings=cloud_function_ingress_settings, cloud_build_service_account=cloud_build_service_account, ) @@ -200,9 +138,9 @@ def udf( *, input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, - dataset: Optional[str] = None, + dataset: str, bigquery_connection: Optional[str] = None, - name: Optional[str] = None, + name: str, packages: Optional[Sequence[str]] = None, max_batching_rows: Optional[int] = None, container_cpu: Optional[float] = None, @@ -250,22 +188,24 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> bigframes.series.Series: ... +) -> bigframes.series.Series: + ... @typing.overload def to_datetime( - arg: Union[int, float, str, datetime.datetime, datetime.date], + arg: Union[int, float, str, datetime], *, utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime.datetime]: ... +) -> Union[pandas.Timestamp, datetime]: + ... def to_datetime( arg: Union[ - Union[int, float, str, datetime.datetime, datetime.date], + Union[int, float, str, datetime], vendored_pandas_datetimes.local_iterables, bigframes.series.Series, bigframes.dataframe.DataFrame, @@ -274,9 +214,8 @@ def to_datetime( utc: bool = False, format: Optional[str] = None, unit: Optional[str] = None, -) -> Union[pandas.Timestamp, datetime.datetime, bigframes.series.Series]: - return global_session.with_default_session( - bigframes.session.Session.to_datetime, +) -> Union[pandas.Timestamp, datetime, bigframes.series.Series]: + return bigframes.core.tools.to_datetime( arg, utc=utc, format=format, @@ -348,7 +287,6 @@ def clean_up_by_session_id( session.bqclient, location=location, project=project, - publisher=session._publisher, ) bigframes.session._io.bigquery.delete_tables_matching_session_id( @@ -362,22 +300,11 @@ def clean_up_by_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 @@ -390,7 +317,7 @@ def clean_up_by_session_id( __version__ = bigframes.version.__version__ # Other public pandas attributes -NamedAgg = collections.namedtuple("NamedAgg", ["column", "aggfunc"]) +NamedAgg = namedtuple("NamedAgg", ["column", "aggfunc"]) options = config.options """Global :class:`~bigframes._config.Options` to configure BigQuery DataFrames.""" @@ -437,10 +364,8 @@ def reset_session(): pass _functions = [ - _from_glob_path, clean_up_by_session_id, concat, - crosstab, cut, deploy_remote_function, deploy_udf, @@ -448,60 +373,27 @@ def reset_session(): get_dummies, merge, qcut, - read_arrow, - read_avro, 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_orc, 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__ = [ - # Function names - "clean_up_by_session_id", - "concat", - "crosstab", - "col", - "cut", - "deploy_remote_function", - "deploy_udf", - "get_default_session_id", - "get_dummies", - "merge", - "qcut", - "read_arrow", - "read_avro", - "read_csv", - "read_gbq", - "_read_gbq_colab", - "read_gbq_function", - "read_gbq_model", - "read_gbq_query", - "read_gbq_table", - "read_json", - "read_orc", - "read_pandas", - "read_parquet", - "read_pickle", - "remote_function", - "to_datetime", - "to_timedelta", - # Other names - "api", +_function_names = [_function.__name__ for _function in _functions] +_other_names = [ # pandas dtype attributes "NA", "BooleanDtype", @@ -527,6 +419,9 @@ def reset_session(): "udf", ] +# Use __all__ to let type checkers know what is part of the public API. +__all__ = _function_names + _other_names + _module = sys.modules[__name__] for _function in _functions: diff --git a/bigframes/pandas/api/__init__.py b/bigframes/pandas/api/__init__.py deleted file mode 100644 index 6d181f92c12..00000000000 --- a/bigframes/pandas/api/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -# 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 APIs.""" - -from bigframes.pandas.api import typing - -__all__ = [ - "typing", -] diff --git a/bigframes/pandas/api/typing.py b/bigframes/pandas/api/typing.py deleted file mode 100644 index 8d8d65eddec..00000000000 --- a/bigframes/pandas/api/typing.py +++ /dev/null @@ -1,37 +0,0 @@ -# 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.plotting import PlotAccessor -from bigframes.operations.strings import StringMethods -from bigframes.operations.structs import StructAccessor, StructFrameAccessor - -__all__ = [ - "DataFrameGroupBy", - "DatetimeMethods", - "PlotAccessor", - "SeriesGroupBy", - "StringMethods", - "StructAccessor", - "StructFrameAccessor", - "Window", -] diff --git a/bigframes/pandas/core/methods/describe.py b/bigframes/pandas/core/methods/describe.py index 34c116ba27d..18d23183798 100644 --- a/bigframes/pandas/core/methods/describe.py +++ b/bigframes/pandas/core/methods/describe.py @@ -16,15 +16,8 @@ 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 -) +from bigframes.core.reshape import api as rs def describe( @@ -37,91 +30,100 @@ def describe( 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)) - if not stack_cols.empty: - describe_block = describe_block.stack(override_labels=stack_cols) - return dataframe.DataFrame(describe_block).droplevel(level=0) - return dataframe.DataFrame(describe_block) - - -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)), + numeric_df = _select_dtypes( + input, + dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE + + dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES, + ) + if len(numeric_df.columns) == 0: + # Describe eligible non-numeric columns + return _describe_non_numeric(input) + + # Otherwise, only describe numeric columns + return _describe_numeric(input) + + elif include == "all": + numeric_result = _describe_numeric(input) + non_numeric_result = _describe_non_numeric(input) + + if len(numeric_result.columns) == 0: + return non_numeric_result + elif len(non_numeric_result.columns) == 0: + return numeric_result + else: + # Use reindex after join to preserve the original column order. + return rs.concat( + [non_numeric_result, numeric_result], axis=1 + )._reindex_columns(input.columns) + + else: + raise ValueError(f"Unsupported include type: {include}") + + +def _describe_numeric(df: dataframe.DataFrame) -> dataframe.DataFrame: + number_df_result = typing.cast( + dataframe.DataFrame, + _select_dtypes(df, dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE).agg( + [ + "count", + "mean", + "std", + "min", + "25%", + "50%", + "75%", + "max", + ] + ), + ) + temporal_df_result = typing.cast( + dataframe.DataFrame, + _select_dtypes(df, dtypes.TEMPORAL_NUMERIC_BIGFRAMES_TYPES).agg(["count"]), ) - 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] - elif dtypes.is_json_like(dtype) or dtype == dtypes.OBJ_REF_DTYPE: - return [aggregations.count_op] + + if len(number_df_result.columns) == 0: + return temporal_df_result + elif len(temporal_df_result.columns) == 0: + return number_df_result else: - return [] + import bigframes.core.reshape.api as rs + + original_columns = _select_dtypes( + df, + dtypes.NUMERIC_BIGFRAMES_TYPES_RESTRICTIVE + + 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(df: dataframe.DataFrame) -> dataframe.DataFrame: + return typing.cast( + dataframe.DataFrame, + _select_dtypes( + df, + [ + dtypes.STRING_DTYPE, + dtypes.BOOL_DTYPE, + dtypes.BYTES_DTYPE, + dtypes.TIME_DTYPE, + ], + ).agg(["count", "nunique"]), + ) + + +def _select_dtypes( + df: dataframe.DataFrame, dtypes: typing.Sequence[dtypes.Dtype] +) -> dataframe.DataFrame: + """Selects columns without considering inheritance relationships.""" + columns = [ + col_id + for col_id, dtype in zip(df._block.value_columns, df._block.dtypes) + if dtype in dtypes + ] + return dataframe.DataFrame(df._block.select_columns(columns)) diff --git a/bigframes/pandas/core/tools/timedeltas.py b/bigframes/pandas/core/tools/timedeltas.py index 5d08bec5f7c..070a41d62d5 100644 --- a/bigframes/pandas/core/tools/timedeltas.py +++ b/bigframes/pandas/core/tools/timedeltas.py @@ -14,11 +14,11 @@ import typing -import pandas as pd -import pandas.api.types as pdtypes from bigframes_vendored.pandas.core.tools import ( timedeltas as vendored_pandas_timedeltas, ) +import pandas as pd +import pandas.api.types as pdtypes from bigframes import operations as ops from bigframes import series, session @@ -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, session=session), unit, session=session) + return to_timedelta(series.Series(arg), unit, session=session) return pd.to_timedelta(arg, unit) diff --git a/bigframes/pandas/io/api.py b/bigframes/pandas/io/api.py index fa0f503a08b..cf4b4eb19c7 100644 --- a/bigframes/pandas/io/api.py +++ b/bigframes/pandas/io/api.py @@ -19,49 +19,46 @@ import os import threading import typing -import warnings from typing import ( - IO, Any, Callable, Dict, + IO, Iterable, Literal, MutableSequence, Optional, + overload, Sequence, Tuple, Union, - overload, ) +import warnings import bigframes_vendored.constants as constants import bigframes_vendored.pandas.io.gbq as vendored_pandas_gbq +from google.cloud import bigquery import numpy import pandas -import pyarrow as pa -from google.cloud import bigquery from pandas._typing import ( CompressionOptions, FilePath, ReadPickleBuffer, StorageOptions, ) +import pyarrow as pa import bigframes._config as config -import bigframes._importing import bigframes.core.global_session as global_session import bigframes.core.indexes 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.iceberg import bigframes.session.metrics -from bigframes.core import bq_data -from bigframes.session import dry_runs # Note: the following methods are duplicated from Session. This duplication # enables the following: @@ -94,21 +91,6 @@ def read_arrow(pa_table: pa.Table) -> bigframes.dataframe.DataFrame: return session.read_arrow(pa_table=pa_table) -def read_avro( - path: str | IO["bytes"], - *, - engine: str = "auto", -) -> bigframes.dataframe.DataFrame: - return global_session.with_default_session( - bigframes.session.Session.read_avro, - path, - engine=engine, - ) - - -read_avro.__doc__ = inspect.getdoc(bigframes.session.Session.read_avro) - - def read_csv( filepath_or_buffer: str | IO["bytes"], *, @@ -205,8 +187,8 @@ def read_gbq( # type: ignore[overload-overlap] use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., - allow_large_results: Optional[bool] = ..., -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @overload @@ -221,8 +203,8 @@ def read_gbq( use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., - allow_large_results: Optional[bool] = ..., -) -> pandas.Series: ... +) -> pandas.Series: + ... def read_gbq( @@ -236,7 +218,6 @@ def read_gbq( use_cache: Optional[bool] = None, col_order: Iterable[str] = (), 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( @@ -250,7 +231,6 @@ def read_gbq( use_cache=use_cache, col_order=col_order, dry_run=dry_run, - allow_large_results=allow_large_results, ) @@ -269,7 +249,7 @@ def _run_read_gbq_colab_sessionless_dry_run( pyformat_args=pyformat_args, dry_run=True, ) - bqclient, _ = _get_bqclient_and_project() + bqclient = _get_bqclient() job = _dry_run(query_formatted, bqclient) return dry_runs.get_query_stats_with_inferred_dtypes(job, (), ()) @@ -300,26 +280,25 @@ def _try_read_gbq_colab_sessionless_dry_run( def _read_gbq_colab( # type: ignore[overload-overlap] query_or_table: str, *, - callback: Optional[Callable[[bigframes.core.events.EventEnvelope], None]] = None, - pyformat_args: Optional[Dict[str, Any]] = None, - dry_run: Literal[False] = False, -) -> bigframes.dataframe.DataFrame: ... + pyformat_args: Optional[Dict[str, Any]] = ..., + dry_run: Literal[False] = ..., +) -> bigframes.dataframe.DataFrame: + ... @overload def _read_gbq_colab( query_or_table: str, *, - callback: Optional[Callable[[bigframes.core.events.EventEnvelope], None]] = None, - pyformat_args: Optional[Dict[str, Any]] = None, - dry_run: Literal[True], -) -> pandas.Series: ... + pyformat_args: Optional[Dict[str, Any]] = ..., + dry_run: Literal[True] = ..., +) -> pandas.Series: + ... def _read_gbq_colab( query_or_table: str, *, - callback: Optional[Callable[[bigframes.core.events.EventEnvelope], None]] = None, pyformat_args: Optional[Dict[str, Any]] = None, dry_run: bool = False, ) -> bigframes.dataframe.DataFrame | pandas.Series: @@ -331,8 +310,6 @@ def _read_gbq_colab( Args: query_or_table (str): SQL query or table ID (table ID not yet supported). - callback (Optional[Callable[[bigframes.core.events.EventEnvelope], None]]): - Callback to receive query execution events. pyformat_args (Optional[Dict[str, Any]]): Parameters to format into the query string. dry_run (bool): @@ -372,19 +349,15 @@ def _read_gbq_colab( ) _set_default_session_location_if_possible_deferred_query(create_query) if not config.options.bigquery._session_started: - # Don't warning about Polars in SQL cell. - # Related to b/437090788. - try: - bigframes._importing.import_polars() + 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 - except ImportError: - pass # don't fail if polars isn't available return global_session.with_default_session( bigframes.session.Session._read_gbq_colab, query_or_table, - callback=callback, pyformat_args=pyformat_args, dry_run=dry_run, ) @@ -400,6 +373,21 @@ def read_gbq_model(model_name: str): read_gbq_model.__doc__ = inspect.getdoc(bigframes.session.Session.read_gbq_model) +def read_gbq_object_table( + object_table: str, *, name: Optional[str] = None +) -> bigframes.dataframe.DataFrame: + return global_session.with_default_session( + bigframes.session.Session.read_gbq_object_table, + object_table, + name=name, + ) + + +read_gbq_object_table.__doc__ = inspect.getdoc( + bigframes.session.Session.read_gbq_object_table +) + + @overload def read_gbq_query( # type: ignore[overload-overlap] query: str, @@ -412,8 +400,8 @@ def read_gbq_query( # type: ignore[overload-overlap] col_order: Iterable[str] = ..., filters: vendored_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., - allow_large_results: Optional[bool] = ..., -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @overload @@ -428,8 +416,8 @@ def read_gbq_query( col_order: Iterable[str] = ..., filters: vendored_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., - allow_large_results: Optional[bool] = ..., -) -> pandas.Series: ... +) -> pandas.Series: + ... def read_gbq_query( @@ -443,7 +431,6 @@ def read_gbq_query( col_order: Iterable[str] = (), filters: vendored_pandas_gbq.FiltersType = (), 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( @@ -457,7 +444,6 @@ def read_gbq_query( col_order=col_order, filters=filters, dry_run=dry_run, - allow_large_results=allow_large_results, ) @@ -475,7 +461,8 @@ def read_gbq_table( # type: ignore[overload-overlap] use_cache: bool = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @overload @@ -489,7 +476,8 @@ def read_gbq_table( use_cache: bool = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., -) -> pandas.Series: ... +) -> pandas.Series: + ... def read_gbq_table( @@ -520,29 +508,13 @@ def read_gbq_table( read_gbq_table.__doc__ = inspect.getdoc(bigframes.session.Session.read_gbq_table) -def read_orc( - path: str | IO["bytes"], - *, - engine: str = "auto", - write_engine: constants.WriteEngineType = "default", -) -> bigframes.dataframe.DataFrame: - return global_session.with_default_session( - bigframes.session.Session.read_orc, - path, - engine=engine, - write_engine=write_engine, - ) - - -read_orc.__doc__ = inspect.getdoc(bigframes.session.Session.read_orc) - - @typing.overload def read_pandas( pandas_dataframe: pandas.DataFrame, *, write_engine: constants.WriteEngineType = "default", -) -> bigframes.dataframe.DataFrame: ... +) -> bigframes.dataframe.DataFrame: + ... @typing.overload @@ -550,7 +522,8 @@ def read_pandas( pandas_dataframe: pandas.Series, *, write_engine: constants.WriteEngineType = "default", -) -> bigframes.series.Series: ... +) -> bigframes.series.Series: + ... @typing.overload @@ -558,7 +531,8 @@ def read_pandas( pandas_dataframe: pandas.Index, *, write_engine: constants.WriteEngineType = "default", -) -> bigframes.core.indexes.Index: ... +) -> bigframes.core.indexes.Index: + ... def read_pandas( @@ -626,55 +600,42 @@ def read_gbq_function( read_gbq_function.__doc__ = inspect.getdoc(bigframes.session.Session.read_gbq_function) -def _from_glob_path( +def from_glob_path( path: str, *, connection: Optional[str] = None, name: Optional[str] = None ) -> bigframes.dataframe.DataFrame: return global_session.with_default_session( - bigframes.session.Session._from_glob_path, + bigframes.session.Session.from_glob_path, path=path, connection=connection, name=name, ) -_from_glob_path.__doc__ = inspect.getdoc(bigframes.session.Session._from_glob_path) +from_glob_path.__doc__ = inspect.getdoc(bigframes.session.Session.from_glob_path) _default_location_lock = threading.Lock() -def _get_bqclient_and_project() -> Tuple[bigquery.Client, str]: - # Address circular imports in doctest due to bigframes/session/__init__.py - # containing a lot of logic and samples. - import bigframes._config.auth - from bigframes.session import clients - - credentials, project = bigframes._config.auth.resolve_credentials_and_project( - config.options.bigquery - ) - - clients_provider = clients.ClientsProvider( - project=project, +def _get_bqclient() -> bigquery.Client: + clients_provider = bigframes.session.clients.ClientsProvider( + project=config.options.bigquery.project, location=config.options.bigquery.location, use_regional_endpoints=config.options.bigquery.use_regional_endpoints, - credentials=credentials, + credentials=config.options.bigquery.credentials, 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, project + return clients_provider.bqclient 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() + if bigframes.session.metrics.LOGGING_NAME_ENV_VAR in os.environ: + metrics = bigframes.session.metrics.ExecutionMetrics() metrics.count_job_stats(job) return job @@ -684,10 +645,6 @@ def _set_default_session_location_if_possible(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 @@ -707,21 +664,14 @@ def _set_default_session_location_if_possible_deferred_query(create_query): return query = create_query() - bqclient, default_project = _get_bqclient_and_project() + bqclient = _get_bqclient() - if bigquery.is_query(query): + 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 = _dry_run(query, bqclient) config.options.bigquery.location = job.location - elif bq_data.is_irc_table(query): - irc_table = bigframes.session.iceberg.get_table( - default_project, query, bqclient._credentials - ) - config.options.bigquery.location = bq_data.get_default_bq_region( - irc_table.metadata.location - ) else: table = bqclient.get_table(query) config.options.bigquery.location = table.location diff --git a/bigframes/series.py b/bigframes/series.py index 07dcb0de628..c95b2ca37fc 100644 --- a/bigframes/series.py +++ b/bigframes/series.py @@ -18,42 +18,41 @@ import datetime import functools +import inspect import itertools import numbers import textwrap import typing -import warnings from typing import ( Any, - Callable, + cast, Iterable, List, Literal, Mapping, Optional, + overload, Sequence, Tuple, - TypeVar, Union, - cast, - overload, ) +import warnings import bigframes_vendored.constants as constants import bigframes_vendored.pandas.core.series as vendored_pandas_series -import google.cloud.bigquery.job +import google.cloud.bigquery as bigquery import numpy import pandas +from pandas.api import extensions as pd_ext +import pandas.core.dtypes.common import pyarrow as pa import typing_extensions -from pandas.api import extensions as pd_ext import bigframes.core +from bigframes.core import groupby, log_adapter import bigframes.core.block_transforms as block_ops import bigframes.core.blocks as blocks -import bigframes.core.col import bigframes.core.expression as ex -import bigframes.core.identifiers as ids import bigframes.core.indexers import bigframes.core.indexes as indexes import bigframes.core.ordering as order @@ -61,6 +60,7 @@ 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 @@ -69,24 +69,17 @@ 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.python_op_maps as python_ops +import bigframes.operations.strings as strings import bigframes.operations.structs as structs -import bigframes.session -from bigframes._tools import docs -from bigframes.core import agg_expressions, groupby -from bigframes.core.logging import log_adapter -from bigframes.core.window import rolling if typing.TYPE_CHECKING: - import bigframes.extensions.bigframes.series_accessor as series_bigquery_accessor import bigframes.geopandas.geoseries - import bigframes.operations.datetimes as datetimes - import bigframes.operations.strings as strings - -U = TypeVar("U") LevelType = typing.Union[str, int] LevelsType = typing.Union[LevelType, typing.Sequence[LevelType]] @@ -100,8 +93,7 @@ @log_adapter.class_logger -@docs.inherit_docs(vendored_pandas_series.Series) -class Series: +class Series(bigframes.operations.base.SeriesMethods, vendored_pandas_series.Series): # Must be above 5000 for pandas to delegate to bigframes for binops __pandas_priority__ = 13000 @@ -109,120 +101,21 @@ class Series: # 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[google.cloud.bigquery.job.QueryJob] = 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): - 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 - + def __init__(self, *args, **kwargs): + self._query_job: Optional[bigquery.QueryJob] = None + super().__init__(*args, **kwargs) self._block.session._register_object(self) @property - def dt(self) -> datetimes.DatetimeMethods: - import bigframes.operations.datetimes as datetimes - - return datetimes.DatetimeMethods(self) + def dt(self) -> dt.DatetimeMethods: + return dt.DatetimeMethods(self._block) @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 @@ -245,6 +138,7 @@ def loc(self) -> bigframes.core.indexers.LocSeriesIndexer: return bigframes.core.indexers.LocSeriesIndexer(self) @property + @validations.requires_ordering() def iloc(self) -> bigframes.core.indexers.IlocSeriesIndexer: return bigframes.core.indexers.IlocSeriesIndexer(self) @@ -302,26 +196,7 @@ def keys(self) -> indexes.Index: return self.index @property - def bigquery( - self, - ) -> series_bigquery_accessor.BigframesBigQuerySeriesAccessor: - """ - Accessor for BigQuery functionality. - - Returns: - bigframes.extensions.core.series_accessor.BigQuerySeriesAccessor: - Accessor that exposes BigQuery functionality on a Series, - with method names closer to SQL. - """ - # Import the accessor here to avoid circular imports. - import bigframes.extensions.bigframes.series_accessor - - return bigframes.extensions.bigframes.series_accessor.BigframesBigQuerySeriesAccessor( - self - ) - - @property - def query_job(self) -> Optional[google.cloud.bigquery.job.QueryJob]: + def query_job(self) -> Optional[bigquery.QueryJob]: """BigQuery job metadata for the most recent query. Returns: @@ -334,11 +209,15 @@ def query_job(self) -> Optional[google.cloud.bigquery.job.QueryJob]: @property def struct(self) -> structs.StructAccessor: - return structs.StructAccessor(self) + return structs.StructAccessor(self._block) @property def list(self) -> lists.ListAccessor: - return lists.ListAccessor(self) + return lists.ListAccessor(self._block) + + @property + def blob(self) -> blob.BlobAccessor: + return blob.BlobAccessor(self._block) @property @validations.requires_ordering() @@ -361,32 +240,17 @@ def _struct_fields(self) -> List[str]: struct_type = typing.cast(pa.StructType, self._dtype.pyarrow_dtype) return [struct_type.field(i).name for i in range(struct_type.num_fields)] - @property - def sql(self) -> str: - """Compiles this Series's expression tree to SQL. - - Returns: - A string representing the compiled SQL. - """ - - return self.to_frame().sql - @validations.requires_ordering() def transpose(self) -> Series: return self - def _set_internal_query_job( - self, query_job: Optional[google.cloud.bigquery.job.QueryJob] - ): + def _set_internal_query_job(self, query_job: Optional[bigquery.QueryJob]): self._query_job = query_job def __len__(self): return self.shape[0] - def __bool__(self): - raise ValueError( - "Cannot convert Series into bool. Consider using .empty(), .item(), .any(), or .all() methods." - ) + __len__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__len__) def __iter__(self) -> typing.Iterator: return itertools.chain.from_iterable( @@ -403,7 +267,8 @@ def copy(self) -> Series: def rename( self, index: Union[blocks.Label, Mapping[Any, Any]] = None, - ) -> Series: ... + ) -> Series: + ... @overload def rename( @@ -412,7 +277,8 @@ def rename( *, inplace: Literal[False], **kwargs, - ) -> Series: ... + ) -> Series: + ... @overload def rename( @@ -421,7 +287,8 @@ def rename( *, inplace: Literal[True], **kwargs, - ) -> None: ... + ) -> None: + ... def rename( self, @@ -467,6 +334,7 @@ def rename( # 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]) @@ -482,7 +350,8 @@ def rename( def rename_axis( self, mapper: typing.Union[blocks.Label, typing.Sequence[blocks.Label]], - ) -> Series: ... + ) -> Series: + ... @overload def rename_axis( @@ -491,7 +360,8 @@ def rename_axis( *, inplace: Literal[False], **kwargs, - ) -> Series: ... + ) -> Series: + ... @overload def rename_axis( @@ -500,7 +370,8 @@ def rename_axis( *, inplace: Literal[True], **kwargs, - ) -> None: ... + ) -> None: + ... @validations.requires_index def rename_axis( @@ -544,7 +415,8 @@ def reset_index( drop: Literal[False] = ..., inplace: Literal[False] = ..., allow_duplicates: Optional[bool] = ..., - ) -> bigframes.dataframe.DataFrame: ... + ) -> bigframes.dataframe.DataFrame: + ... @overload def reset_index( @@ -555,7 +427,8 @@ def reset_index( drop: Literal[True] = ..., inplace: Literal[False] = ..., allow_duplicates: Optional[bool] = ..., - ) -> Series: ... + ) -> Series: + ... @overload def reset_index( @@ -566,7 +439,8 @@ def reset_index( drop: bool = ..., inplace: Literal[True] = ..., allow_duplicates: Optional[bool] = ..., - ) -> None: ... + ) -> None: + ... @validations.requires_ordering() def reset_index( @@ -595,20 +469,6 @@ def reset_index( block = block.assign_label(self._value_column, name) return bigframes.dataframe.DataFrame(block) - def _prepare_display_df(self) -> bigframes.dataframe.DataFrame: - return self.to_frame()._prepare_display_df() - - 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 @@ -620,22 +480,27 @@ def __repr__(self) -> str: # TODO(swast): Avoid downloading the whole series by using job # metadata, like we do with DataFrame. opts = bigframes.options.display - if opts.repr_mode == "deferred": + max_results = opts.max_rows + # anywdiget mode uses the same display logic as the "deferred" mode + # for faster execution + if opts.repr_mode in ("deferred", "anywidget"): return formatter.repr_query_job(self._compute_dry_run()) self._cached() - pandas_df, row_count, query_job = self._block.retrieve_repr_request_results( - opts.max_rows - ) + pandas_df, _, query_job = self._block.retrieve_repr_request_results(max_results) self._set_internal_query_job(query_job) - from bigframes.display import plaintext - return plaintext.create_text_representation( - pandas_df, - row_count, - is_series=True, - has_index=len(self._block.index_columns) > 0, - ) + 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 def astype( self, @@ -646,17 +511,9 @@ def astype( if errors not in ["raise", "null"]: raise ValueError("Argument 'errors' must be one of 'raise' or 'null'") dtype = bigframes.dtypes.bigframes_type(dtype) - safe = errors == "null" - if dtype == bigframes.dtypes.JSON_DTYPE: - return self._apply_unary_op(bigframes.operations.ToJSON(safe=safe)) - elif self.dtype == bigframes.dtypes.JSON_DTYPE: - return self._apply_unary_op( - bigframes.operations.JSONDecode(to_type=dtype, safe=safe) - ) - else: - return self._apply_unary_op( - bigframes.operations.AsTypeOp(to_type=dtype, safe=safe) - ) + return self._apply_unary_op( + bigframes.operations.AsTypeOp(to_type=dtype, safe=(errors == "null")) + ) def to_pandas( self, @@ -672,6 +529,8 @@ def to_pandas( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([4, 3, 2]) Download the data from BigQuery and convert it into an in-memory pandas Series. @@ -790,7 +649,6 @@ def to_pandas_batches( max_results: Optional[int] = None, *, allow_large_results: Optional[bool] = None, - cell_execution_count: Optional[int] = None, ) -> Iterable[pandas.Series]: """Stream Series results to an iterable of pandas Series. @@ -799,6 +657,8 @@ def to_pandas_batches( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([4, 3, 2, 2, 3]) Iterate through the results in batches, limiting the total rows yielded @@ -843,11 +703,10 @@ def to_pandas_batches( page_size=page_size, max_results=max_results, allow_large_results=allow_large_results, - cell_execution_count=cell_execution_count, ) return map(lambda df: cast(pandas.Series, df.squeeze(1)), batches) - def _compute_dry_run(self) -> google.cloud.bigquery.job.QueryJob: + def _compute_dry_run(self) -> bigquery.QueryJob: _, query_job = self._block._compute_dry_run((self._value_column,)) return query_job @@ -949,6 +808,7 @@ def ffill(self, *, limit: typing.Optional[int] = None) -> Series: return self._apply_window_op(agg_ops.LastNonNullOp(), window) pad = ffill + pad.__doc__ = inspect.getdoc(vendored_pandas_series.Series.ffill) @validations.requires_ordering() def bfill(self, *, limit: typing.Optional[int] = None) -> Series: @@ -991,11 +851,8 @@ 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, pct=pct) - ) + return Series(block_ops.rank(self._block, method, na_option, ascending)) def fillna(self, value=None) -> Series: return self._apply_binary_op(value, ops.fillna_op) @@ -1178,7 +1035,7 @@ 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": + def isin(self, values) -> "Series" | None: if isinstance(values, Series): return Series(self._block.isin(values._block)) if isinstance(values, indexes.Index): @@ -1196,45 +1053,61 @@ def isna(self) -> "Series": return self._apply_unary_op(ops.isnull_op) isnull = isna + isnull.__doc__ = inspect.getdoc(vendored_pandas_series.Series.isna) def notna(self) -> "Series": return self._apply_unary_op(ops.notnull_op) notnull = notna + notnull.__doc__ = inspect.getdoc(vendored_pandas_series.Series.notna) def __and__(self, other: bool | int | Series) -> Series: return self._apply_binary_op(other, ops.and_op) + __and__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__and__) + __rand__ = __and__ def __or__(self, other: bool | int | Series) -> Series: return self._apply_binary_op(other, ops.or_op) + __or__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__or__) + __ror__ = __or__ def __xor__(self, other: bool | int | Series) -> Series: return self._apply_binary_op(other, ops.xor_op) + __or__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__xor__) + __rxor__ = __xor__ - def __add__(self, other: float | int | pandas.Timedelta | Series) -> Series: + def __add__(self, other: float | int | Series) -> Series: return self.add(other) - def __radd__(self, other: float | int | pandas.Timedelta | Series) -> Series: + __add__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__add__) + + def __radd__(self, other: float | int | Series) -> Series: return self.radd(other) - def add(self, other: float | int | pandas.Timedelta | Series) -> Series: + __radd__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__radd__) + + def add(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.add_op) - def radd(self, other: float | int | pandas.Timedelta | Series) -> Series: + def radd(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.add_op, reverse=True) def __sub__(self, other: float | int | Series) -> Series: return self.sub(other) + __sub__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__sub__) + def __rsub__(self, other: float | int | Series) -> Series: return self.rsub(other) + __rsub__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rsub__) + def sub(self, other) -> Series: return self._apply_binary_op(other, ops.sub_op) @@ -1242,13 +1115,18 @@ def rsub(self, other) -> Series: return self._apply_binary_op(other, ops.sub_op, reverse=True) subtract = sub + subtract.__doc__ = inspect.getdoc(vendored_pandas_series.Series.sub) def __mul__(self, other: float | int | Series) -> Series: return self.mul(other) + __mul__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__mul__) + def __rmul__(self, other: float | int | Series) -> Series: return self.rmul(other) + __rmul__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rmul__) + def mul(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.mul_op) @@ -1256,41 +1134,56 @@ def rmul(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.mul_op, reverse=True) multiply = mul + multiply.__doc__ = inspect.getdoc(vendored_pandas_series.Series.mul) - def __truediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: + def __truediv__(self, other: float | int | Series) -> Series: return self.truediv(other) - def __rtruediv__(self, other: float | int | pandas.Timedelta | Series) -> Series: + __truediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__truediv__) + + def __rtruediv__(self, other: float | int | Series) -> Series: return self.rtruediv(other) - def truediv(self, other: float | int | pandas.Timedelta | Series) -> Series: + __rtruediv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rtruediv__) + + def truediv(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.div_op) - def rtruediv(self, other: float | int | pandas.Timedelta | Series) -> Series: + def rtruediv(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.div_op, reverse=True) + truediv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.truediv) div = divide = truediv rdiv = rtruediv + rdiv.__doc__ = inspect.getdoc(vendored_pandas_series.Series.rtruediv) - def __floordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: + def __floordiv__(self, other: float | int | Series) -> Series: return self.floordiv(other) - def __rfloordiv__(self, other: float | int | pandas.Timedelta | Series) -> Series: + __floordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__floordiv__) + + def __rfloordiv__(self, other: float | int | Series) -> Series: return self.rfloordiv(other) - def floordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: + __rfloordiv__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rfloordiv__) + + def floordiv(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op) - def rfloordiv(self, other: float | int | pandas.Timedelta | Series) -> Series: + def rfloordiv(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.floordiv_op, reverse=True) def __pow__(self, other: float | int | Series) -> Series: return self.pow(other) + __pow__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__pow__) + def __rpow__(self, other: float | int | Series) -> Series: return self.rpow(other) + __rpow__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rpow__) + def pow(self, other: float | int | Series) -> Series: return self._apply_binary_op(other, ops.pow_op) @@ -1324,9 +1217,13 @@ def ge(self, other) -> Series: def __mod__(self, other) -> Series: # type: ignore return self.mod(other) + __mod__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__mod__) + def __rmod__(self, other) -> Series: # type: ignore return self.rmod(other) + __rmod__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rmod__) + def mod(self, other) -> Series: # type: ignore return self._apply_binary_op(other, ops.mod_op) @@ -1349,9 +1246,13 @@ def dot(self, other): def __matmul__(self, other): return self.dot(other) + __matmul__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__matmul__) + def __rmatmul__(self, other): return self.dot(other) + __rmatmul__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__rmatmul__) + def combine_first(self, other: Series) -> Series: result = self._apply_binary_op(other, ops.coalesce_op) result.name = self.name @@ -1429,7 +1330,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)[0] for f in func] + aggregations = [agg_ops.lookup_agg_func(f) for f in func] return Series( self._block.summarize( [self._value_column], @@ -1437,9 +1338,12 @@ def agg(self, func: str | typing.Sequence[str]) -> scalars.Scalar | Series: ) ) else: - return self._apply_aggregation(agg_ops.lookup_agg_func(func)[0]) + return self._apply_aggregation( + agg_ops.lookup_agg_func(typing.cast(str, func)) + ) aggregate = agg + aggregate.__doc__ = inspect.getdoc(vendored_pandas_series.Series.agg) def describe(self) -> Series: from bigframes.pandas.core.methods import describe @@ -1479,19 +1383,18 @@ def kurt(self): return (numerator / denominator) - adjustment kurtosis = kurt + kurtosis.__doc__ = inspect.getdoc(vendored_pandas_series.Series.kurt) def mode(self) -> Series: block = self._block # Approach: Count each value, return each value for which count(x) == max(counts)) - block = block.aggregate( + block, agg_ids = block.aggregate( by_column_ids=[self._value_column], aggregations=( - agg_expressions.UnaryAggregation( - agg_ops.count_op, ex.deref(self._value_column) - ), + ex.UnaryAggregation(agg_ops.count_op, ex.deref(self._value_column)), ), ) - value_count_col_id = block.value_columns[0] + value_count_col_id = agg_ids[0] block, max_value_count_col_id = block.apply_window_op( value_count_col_id, agg_ops.max_op, @@ -1543,6 +1446,7 @@ def prod(self) -> float: return typing.cast(float, self._apply_aggregation(agg_ops.product_op)) product = prod + product.__doc__ = inspect.getdoc(vendored_pandas_series.Series.prod) def __eq__(self, other: object) -> Series: # type: ignore return self.eq(other) @@ -1553,6 +1457,8 @@ def __ne__(self, other: object) -> Series: # type: ignore def __invert__(self) -> Series: return self._apply_unary_op(ops.invert_op) + __invert__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__invert__) + def __pos__(self) -> Series: return self._apply_unary_op(ops.pos_op) @@ -1572,9 +1478,9 @@ def ne(self, other: object) -> Series: def items(self): for batch_df in self._block.to_pandas_batches(): - assert batch_df.shape[1] == 1, ( - f"Expected 1 column in the dataframe, but got {batch_df.shape[1]}." - ) + assert ( + batch_df.shape[1] == 1 + ), f"Expected 1 column in the dataframe, but got {batch_df.shape[1]}." for item in batch_df.squeeze(axis=1).items(): yield item @@ -1582,7 +1488,7 @@ def _apply_callable(self, condition): """ "Executes the possible callable condition as needed.""" if callable(condition): # When it's a bigframes function. - if isinstance(condition, bigframes.functions.Udf): + if hasattr(condition, "bigframes_bigquery_function"): return self.apply(condition) # When it's a plain Python function. else: @@ -1730,6 +1636,8 @@ def __getitem__(self, indexer): return Series(block) return self.loc[indexer] + __getitem__.__doc__ = inspect.getdoc(vendored_pandas_series.Series.__getitem__) + def __getattr__(self, key: str): # Protect against recursion errors with uninitialized Series objects. # We use "_block" attribute to check whether the instance is initialized. @@ -1737,7 +1645,7 @@ def __getattr__(self, key: str): # https://github.com/googleapis/python-bigquery-dataframes/issues/728 # and # https://nedbatchelder.com/blog/201010/surprising_getattr_recursion.html - if "_block" not in self.__dict__ or key == "_block": + if key == "_block": raise AttributeError(key) elif hasattr(pandas.Series, key): log_adapter.submit_pandas_labels( @@ -1772,11 +1680,7 @@ def _apply_window_op( block, result_id = block.apply_window_op( self._value_column, op, window_spec=window_spec, result_label=self.name ) - result = Series(block.select_column(result_id)) - if op.skips_nulls: - return result.where(self.notna(), None) - else: - return result + return Series(block.select_column(result_id)) def value_counts( self, @@ -1802,9 +1706,10 @@ def sort_values( axis=..., inplace: Literal[True] = ..., ascending: bool | typing.Sequence[bool] = ..., - kind: str | None = ..., + kind: str = ..., na_position: typing.Literal["first", "last"] = ..., - ) -> None: ... + ) -> None: + ... @typing.overload def sort_values( @@ -1813,9 +1718,10 @@ def sort_values( axis=..., inplace: Literal[False] = ..., ascending: bool | typing.Sequence[bool] = ..., - kind: str | None = ..., + kind: str = ..., na_position: typing.Literal["first", "last"] = ..., - ) -> Series: ... + ) -> Series: + ... def sort_values( self, @@ -1823,21 +1729,19 @@ def sort_values( axis=0, inplace: bool = False, ascending=True, - kind: str | None = None, + 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"]: raise ValueError("Param na_position must be one of 'first' or 'last'") - is_stable = (kind or constants.DEFAULT_SORT_KIND) in constants.STABLE_SORT_KINDS block = self._block.order_by( [ order.ascending_over(self._value_column, (na_position == "last")) if ascending else order.descending_over(self._value_column, (na_position == "last")) ], - stable=is_stable, ) if inplace: self._set_block(block) @@ -1847,35 +1751,19 @@ def sort_values( @typing.overload # type: ignore[override] def sort_index( - self, - *, - axis=..., - inplace: Literal[False] = ..., - ascending=..., - kind: str | None = ..., - na_position=..., - ) -> Series: ... + self, *, axis=..., inplace: Literal[False] = ..., ascending=..., na_position=... + ) -> Series: + ... @typing.overload def sort_index( - self, - *, - axis=0, - inplace: Literal[True] = ..., - ascending=..., - kind: str | None = ..., - na_position=..., - ) -> None: ... + self, *, axis=0, inplace: Literal[True] = ..., ascending=..., na_position=... + ) -> None: + ... @validations.requires_index def sort_index( - self, - *, - axis=0, - inplace: bool = False, - ascending=True, - kind: str | None = None, - na_position="last", + 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": @@ -1890,8 +1778,7 @@ def sort_index( else order.descending_over(column, na_last) for column in block.index_columns ] - is_stable = (kind or constants.DEFAULT_SORT_KIND) in constants.STABLE_SORT_KINDS - block = block.order_by(ordering, stable=is_stable) + block = block.order_by(ordering) if inplace: self._set_block(block) return None @@ -1930,22 +1817,6 @@ def expanding(self, min_periods: int = 1) -> bigframes.core.window.Window: self._block, window_spec, self._block.value_columns, is_series=True ) - def pipe( - self, - func: Union[Callable[..., U], tuple[Callable[..., U], str]], - *args, - **kwargs, - ) -> U: - import bigframes_vendored.pandas.core.common as common - - return common.pipe(self, func, *args, **kwargs) - - def get(self, key, default=None): - try: - return self[key] - except (KeyError, ValueError, IndexError): - return default - def groupby( self, by: typing.Union[ @@ -1980,18 +1851,12 @@ 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( @@ -2003,22 +1868,17 @@ 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] = [] value_col = self._value_column for key in by: if isinstance(key, Series): - ( - block, - ( - get_column_left, - get_column_right, - ), + block, ( + get_column_left, + get_column_right, ) = block.join(key._block, how="inner" if dropna else "left") value_col = get_column_left[value_col] @@ -2041,7 +1901,6 @@ def _groupby_values( by_col_ids=grouping_cols, value_name=self.name, dropna=dropna, - by_key_is_singular=by_key_is_singular, ) def apply( @@ -2066,72 +1925,56 @@ def apply( if by_row not in ["compat", False]: raise ValueError("Param by_row must be one of 'compat' or False") - if not callable(func) and not isinstance(func, numpy.ufunc): + if not callable(func): raise ValueError( "Only a ufunc (a function that applies to the entire Series) or" " a BigFrames BigQuery function that only works on single values" " are supported." ) - # Highest priority: try to map directly to an operator, for eg numpy - # ufuncs, or simple arithmetic/logic operators. - 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) - - if by_row: - from bigframes._config import options + if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): + # 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." + ) - enable_transpile = options.experiments.enable_python_transpiler - return self._apply_by_row( - func, args=args, transpile_enabled=enable_transpile - ) - 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 _apply_by_row( - self, - func: typing.Callable, - args: typing.Tuple = (), - transpile_enabled: bool = False, - ) -> Series: - """ - Apply callable or deployed udf row-wise on the series. - """ - if not callable(func): - raise ValueError( - "Expected a callable function. If you meant to use a BigQuery function, please wrap it with bigframes.pandas.udf(...)" + 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 + # bigquery function instead + if hasattr(ex, "message"): + ex.message += f"\n{_bigquery_function_recommendation_message}" + raise + + # 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 ) - try: - expr = ops.func_to_expr(func) - # We get this message even if transpiler could have in theory translated it. - except Exception: - raise ValueError( - "You have passed a functi1on 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." + # 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 = self._apply_callable_expr(expr, 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 + result_series = func._post_process_series(result_series) return result_series @@ -2140,40 +1983,30 @@ def combine( other, func, ) -> Series: - if not callable(func) and not isinstance(func, numpy.ufunc): + if not callable(func): raise ValueError( "Only a ufunc (a function that applies to the entire Series) or" " a BigFrames BigQuery function that only works on single values" " are supported." ) - from bigframes._config import options - - if isinstance(func, bigframes.functions.Udf) or ( - options.experiments.enable_python_transpiler and callable(func) - ): - result_series = self._apply_callable_expr(ops.func_to_expr(func), (other,)) - if hasattr(other, "name") and other.name != self._name: # type: ignore - result_series.name = None - else: - result_series.name = self.name - 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 + if not isinstance(func, bigframes.functions.BigqueryCallableRoutine): + # 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 + + result_series = self._apply_binary_op( + other, ops.BinaryRemoteFunctionOp(function_def=func.udf_def) + ) + result_series = func._post_process_series(result_series) + return result_series @validations.requires_index def add_prefix(self, prefix: str, axis: int | str | None = None) -> Series: @@ -2272,6 +2105,8 @@ 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) @@ -2279,19 +2114,17 @@ def unique(self, keep_order=True) -> Series: if keep_order: validations.enforce_ordered(self, "unique(keep_order != False)") return self.drop_duplicates() - block = self._block.aggregate( - [ - agg_expressions.UnaryAggregation( - agg_ops.AnyValueOp(), ex.deref(self._value_column) - ) - ], + block, result = self._block.aggregate( [self._value_column], + [ex.UnaryAggregation(agg_ops.AnyValueOp(), ex.deref(self._value_column))], column_labels=self._block.column_labels, dropna=False, ) - return Series(block.reset_index()) + return Series(block.select_columns(result).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 ) @@ -2312,14 +2145,11 @@ def mask(self, cond, other=None) -> Series: return self.where(~cond, other) def to_frame(self, name: blocks.Label = None) -> bigframes.dataframe.DataFrame: - provided_name = name if name is not None else self.name + provided_name = name if name else self.name # To be consistent with Pandas, it assigns 0 as the column name if missing. 0 is the first element of RangeIndex. - column_names: List[blocks.Label] - if provided_name is None or pandas.isna([cast(Any, provided_name)])[0]: - column_names = [0] - else: - column_names = [provided_name] - block = self._block.with_column_labels(column_names) + block = self._block.with_column_labels( + [provided_name] if provided_name else [0] + ) return bigframes.dataframe.DataFrame(block) def to_csv( @@ -2351,16 +2181,13 @@ def to_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 + return typing.cast(dict, self.to_pandas(allow_large_results=allow_large_results).to_dict(into)) # type: ignore 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=sheet_name, **kwargs + excel_writer, sheet_name, **kwargs ) def to_json( @@ -2384,12 +2211,8 @@ def to_json( ) else: pd_series = self.to_pandas(allow_large_results=allow_large_results) - # Pandas Series.to_json only supports a subset of orients, but bigframes Series.to_json allows all of them. return pd_series.to_json( - path_or_buf=path_or_buf, - orient=orient, # type: ignore[arg-type] - lines=lines, - index=index, # type: ignore + path_or_buf=path_or_buf, orient=orient, lines=lines, index=index # type: ignore ) def to_latex( @@ -2414,6 +2237,7 @@ def tolist( 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) def to_markdown( self, @@ -2424,9 +2248,7 @@ def to_markdown( allow_large_results: Optional[bool] = None, **kwargs, ) -> typing.Optional[str]: - return self.to_pandas(allow_large_results=allow_large_results).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, @@ -2446,6 +2268,8 @@ def __array__(self, dtype=None, copy: Optional[bool] = None) -> numpy.ndarray: 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, *, allow_large_results=None, **kwargs) -> None: return self.to_pandas(allow_large_results=allow_large_results).to_pickle( path, **kwargs @@ -2499,7 +2323,7 @@ def _throw_if_index_contains_duplicates( def map( self, - arg: typing.Union[Mapping, Series, Callable], + arg: typing.Union[Mapping, Series], na_action: Optional[str] = None, *, verify_integrity: bool = False, @@ -2521,20 +2345,14 @@ def map( ) map_df = map_df.set_index("keys") elif callable(arg): - # This is for remote function and managed funtion. - from bigframes._config import options - - enable_transpile = options.experiments.enable_python_transpiler - return self._apply_by_row(arg, transpile_enabled=enable_transpile) + return self.apply(arg) else: # Mirroring pandas, call the uncallable object arg() # throws TypeError: object is not callable self_df = self.to_frame(name="series") result_df = self_df.join(map_df, on="series") - result = cast(Series, result_df[self.name]) - result.name = self.name - return result + return result_df[self.name] @validations.requires_ordering() def sample( @@ -2564,7 +2382,7 @@ def explode(self, *, ignore_index: Optional[bool] = False) -> Series: ) @validations.requires_ordering() - def resample( + def _resample( self, rule: str, *, @@ -2578,6 +2396,46 @@ 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, @@ -2619,6 +2477,8 @@ def hist( ): return self.plot.hist(by=by, bins=bins, **kwargs) + hist.__doc__ = inspect.getdoc(plotting.PlotAccessor.hist) + def line( self, x: typing.Optional[typing.Hashable] = None, @@ -2627,6 +2487,8 @@ def line( ): return self.plot.line(x=x, y=y, **kwargs) + line.__doc__ = inspect.getdoc(plotting.PlotAccessor.line) + def area( self, x: typing.Optional[typing.Hashable] = None, @@ -2636,6 +2498,8 @@ def area( ): return self.plot.area(x=x, y=y, stacked=stacked, **kwargs) + area.__doc__ = inspect.getdoc(plotting.PlotAccessor.area) + def bar( self, x: typing.Optional[typing.Hashable] = None, @@ -2644,13 +2508,15 @@ def bar( ): return self.plot.bar(x=x, y=y, **kwargs) + bar.__doc__ = inspect.getdoc(plotting.PlotAccessor.bar) + def _slice( self, start: typing.Optional[int] = None, stop: typing.Optional[int] = None, step: typing.Optional[int] = None, - ) -> Series: - return Series( + ) -> bigframes.series.Series: + return bigframes.series.Series( self._block.slice( start=start, stop=stop, step=step if (step is not None) else 1 ).select_column(self._value_column), @@ -2676,230 +2542,7 @@ def _cached(self, *, force: bool = True, session_aware: bool = True) -> Series: # confusing type checker by overriding str @property def str(self) -> strings.StringMethods: - 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).with_column_labels([None])) - - def _apply_callable_expr( - self, - callable_expr: bigframes.operations.to_op.CallableExpression, - others: Sequence[typing.Union[Series, scalars.Scalar]], - ignore_self=False, - ): - """Applies a CallableExpression to the series and others.""" - values, block = self._align_n( - others, ignore_self=ignore_self, cast_scalars=False - ) - block, result_id = block.project_expr(callable_expr.apply(*values)) - return Series(block.select_column(result_id).with_column_labels([None])) - - 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, ex.OmittedArg] - - @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, bigframes.core.col.Expression, scalars.Scalar] - ], - how="outer", - ignore_self=False, - cast_scalars: bool = False, - ) -> tuple[ - typing.Sequence[Union[ex.ScalarConstantExpression, ex.DerefOp, ex.OmittedArg]], - blocks.Block, - ]: - if ignore_self: - value_ids: List[ - Union[ex.ScalarConstantExpression, ex.DerefOp, ex.OmittedArg] - ] = [] - 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]), - ] - elif isinstance(other, bigframes.core.col.Expression): - if isinstance(other._value, ex.OmittedArg): - value_ids = [*value_ids, other._value] - continue - - label_to_col_ref = { - label: ex.deref(id) for id, label in block.col_id_to_label.items() - } - resolved_expr = other._value.bind_variables(label_to_col_ref) - block = block.project_block_exprs([resolved_expr], labels=[None]) - value_ids = [*value_ids, ex.deref(block.value_columns[-1])] - 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." - ) + return strings.StringMethods(self._block) 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 e20f61901f9..66b0196286d 100644 --- a/bigframes/session/__init__.py +++ b/bigframes/session/__init__.py @@ -16,31 +16,30 @@ from __future__ import annotations +from collections import abc import datetime import fnmatch import inspect import logging import os import secrets -import threading import typing -import warnings -import weakref -from collections import abc from typing import ( - IO, Any, Callable, Dict, + IO, Iterable, Literal, MutableSequence, Optional, + overload, Sequence, Tuple, Union, - overload, ) +import warnings +import weakref import bigframes_vendored.constants as constants import bigframes_vendored.google_cloud_bigquery.retry as third_party_gcb_retry @@ -50,40 +49,41 @@ 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 -import pyarrow as pa from pandas._typing import ( CompressionOptions, FilePath, ReadPickleBuffer, StorageOptions, ) +import pyarrow as pa -import bigframes._config -import bigframes._config.auth +from bigframes import exceptions as bfe +from bigframes import version import bigframes._config.bigquery_options as bigquery_options import bigframes.clients import bigframes.constants import bigframes.core -import bigframes.core.events -import bigframes.core.indexes -import bigframes.core.indexes.multi +from bigframes.core import blocks, log_adapter, utils import bigframes.core.pyformat -import bigframes.formatting_helpers + +# 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.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.anonymous_dataset import bigframes.session.clients +import bigframes.session.loader +import bigframes.session.metrics import bigframes.session.validation -from bigframes import exceptions as bfe -from bigframes import version -from bigframes.core import blocks, utils -from bigframes.core.logging import log_adapter -from bigframes.functions import _function_client, _function_session -from bigframes.session import bigquery_session, executor, proxy_executor # 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 @@ -110,58 +110,6 @@ logger = logging.getLogger(__name__) -class _ExecutionHistory: - def __init__(self, jobs: list[dict]): - self._df = pandas.DataFrame(jobs) - if self._df.empty: - self._df = pandas.DataFrame( - columns=[ - "job_id", - "query_id", - "job_type", - "status", - "query", - "total_bytes_processed", - "job_url", - ] - ) - - def to_dataframe(self) -> pandas.DataFrame: - """Returns the execution history as a pandas DataFrame.""" - return self._df - - def _repr_html_(self) -> str | None: - import bigframes.formatting_helpers as formatter - - if self._df.empty: - return "
No executions found.
" - - cols = ["job_type", "job_id", "status", "total_bytes_processed", "job_url"] - - # Filter columns to only those that exist in the dataframe - available_cols = [c for c in cols if c in self._df.columns] - - def format_url(url): - return f'Open Job' if url else "" - - try: - df_display = self._df[available_cols].copy() - if "total_bytes_processed" in df_display.columns: - df_display["total_bytes_processed"] = df_display[ - "total_bytes_processed" - ].apply(formatter.get_formatted_bytes) - if "job_url" in df_display.columns: - df_display["job_url"] = df_display["job_url"].apply(format_url) - - # Rename job_id to query_id to match user expectations - if "job_id" in df_display.columns: - df_display = df_display.rename(columns={"job_id": "query_id"}) - - return df_display.to_html(escape=False, index=False) - except Exception: - return self._df.to_html() - - @log_adapter.class_logger class Session( third_party_pandas_gbq.GBQIOMixin, @@ -186,74 +134,46 @@ 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.create_progress_callback() - ) - if context is None: context = bigquery_options.BigQueryOptions() + if context.location is None: + self._location = "US" + 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() + # -> Session() + # + # Note: We could also have: + # User's code + # -> read_gbq() + # -> with_default_session() + # -> get_global_session() + # -> connect() + # -> Session() + # but we currently have no way to disambiguate these + # situations. + warnings.warn(msg, stacklevel=4, category=bfe.DefaultLocationWarning) + else: + self._location = context.location + self._bq_kms_key_name = context.kms_key_name # Instantiate a clients provider to help with cloud clients that will be # used in the future operations in the session if clients_provider: - # this path is only for unit testing. Not meant to be used by end users. self._clients_provider = clients_provider - self._location = context.location or "US" - project = "test_project" else: - ( - credentials, - project, - ) = bigframes._config.auth.resolve_credentials_and_project(context) - if context.location is None: - with bigquery.Client( - project=project, - credentials=credentials, - ) as temp_client: - row_iter = temp_client.query_and_wait( - "SELECT 1", - job_config=bigquery.QueryJobConfig(dry_run=True), - ) - self._location = row_iter.location or "US" - 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() - # -> Session() - # - # Note: We could also have: - # User's code - # -> read_gbq() - # -> with_default_session() - # -> get_global_session() - # -> connect() - # -> Session() - # but we currently have no way to disambiguate these - # situations. - warnings.warn( - msg, stacklevel=4, category=bfe.DefaultLocationWarning - ) - else: - self._location = context.location - - self._clients_provider = clients.ClientsProvider( - project=project, - credentials=credentials, + self._clients_provider = bigframes.session.clients.ClientsProvider( + project=context.project, location=self._location, use_regional_endpoints=context.use_regional_endpoints, + credentials=context.credentials, application_name=context.application_name, bq_kms_key_name=self._bq_kms_key_name, client_endpoints_override=context.client_endpoints_override, @@ -282,9 +202,6 @@ 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[ @@ -304,39 +221,21 @@ def __init__( else bigframes.enums.DefaultIndexKind.NULL ) - self._metrics = metrics.ExecutionMetrics() - self._publisher.subscribe(self._metrics.on_event) - 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._function_session = _function_session.FunctionSession( - _function_client.FunctionClient( - gcp_project_id=project, - bq_location=self._location, - bq_client=self._clients_provider.bqclient, - bq_connection_manager=bigframes.clients.BqConnectionManager( - self._clients_provider.bqconnectionclient, - self._clients_provider.resourcemanagerclient, - ), - cloud_functions_client=self._clients_provider.cloudfunctionsclient, - publisher=self._publisher, - ), - dataset_manager=self._anon_dataset_manager, - default_connection=self._bq_connection, - location=self._location, - session_id=self._session_id, - manage_connections=not self._skip_bq_connection_check, + self._metrics = bigframes.session.metrics.ExecutionMetrics() + self._function_session = bff_session.FunctionSession() + self._anon_dataset_manager = ( + bigframes.session.anonymous_dataset.AnonymousDatasetManager( + self._clients_provider.bqclient, + location=self._location, + session_id=self._session_id, + kms_key=self._bq_kms_key_name, + ) ) # 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 @@ -344,7 +243,7 @@ def __init__( self._temp_storage_manager = ( self._session_resource_manager or self._anon_dataset_manager ) - self._loader = loader.GbqDataLoader( + self._loader = bigframes.session.loader.GbqDataLoader( session=self, bqclient=self._clients_provider.bqclient, storage_manager=self._temp_storage_manager, @@ -353,23 +252,15 @@ def __init__( scan_index_uniqueness=self._strictly_ordered, force_total_order=self._strictly_ordered, metrics=self._metrics, - publisher=self._publisher, ) - - labels = {} - if not self._strictly_ordered: - labels["bigframes-mode"] = "unordered" - - self._executor: executor.Executor = proxy_executor.DualCompilerProxyExecutor( + 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, - labels=tuple(labels.items()), - function_manager=self._function_session, ) def __del__(self): @@ -422,15 +313,6 @@ 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 @@ -460,81 +342,16 @@ def bytes_processed_sum(self): @property def slot_millis_sum(self): """The sum of all slot time used by bigquery jobs in this session.""" - return self._metrics.slot_millis - - def execution_history( - self, - *, - events: Optional[Iterable[bigframes.core.events.Event]] = None, - job_ids: Optional[Iterable[str]] = None, - all_cells: bool = True, - ) -> _ExecutionHistory: - """Returns the history of executions initiated by BigFrames in the current session. - - Use `.to_dataframe()` on the result to get a pandas DataFrame. - - Args: - events (Iterable[Event], optional): - Filter execution history to only include jobs associated with the given events. - job_ids (Iterable[str], optional): - Filter execution history to only include jobs matching the given job IDs. - all_cells (bool, optional): - If True, do not filter execution history by notebook cell. If False, - and running in Colab/Jupyter, automatically filter history to only include - jobs executed within the current cell. Defaults to True. - """ - jobs = [job.__dict__ for job in self._metrics.jobs] - - if events is not None: - event_job_ids = { - getattr(event, "job_id", None) - for event in events - if getattr(event, "job_id", None) is not None - } - event_query_ids = { - getattr(event, "query_id", None) - for event in events - if getattr(event, "query_id", None) is not None - } - jobs = [ - job - for job in jobs - if ( - job.get("job_id") is not None and job.get("job_id") in event_job_ids - ) - or ( - job.get("query_id") is not None - and job.get("query_id") in event_query_ids - ) - ] - - elif job_ids is not None: - target_job_ids = set(job_ids) - jobs = [ - job - for job in jobs - if ( - job.get("job_id") is not None - and job.get("job_id") in target_job_ids - ) - or ( - job.get("query_id") is not None - and job.get("query_id") in target_job_ids - ) - ] - - elif not all_cells: - from bigframes.core.utils import get_ipython_execution_count - - current_count = get_ipython_execution_count() - if current_count is not None: - jobs = [ - job - for job in jobs - if job.get("cell_execution_count") == current_count - ] + if not bigframes.options._allow_large_results: + msg = bfe.format_message( + "Queries executed with `allow_large_results=False` within the session will not " + "have their slot milliseconds counted in this sum. If you need precise slot " + "milliseconds information, query the `INFORMATION_SCHEMA` tables " + "to get relevant metrics.", + ) + warnings.warn(msg, UserWarning) - return _ExecutionHistory(jobs) + return self._metrics.slot_millis @property def _allows_ambiguity(self) -> bool: @@ -544,16 +361,6 @@ def _allows_ambiguity(self) -> bool: def _anonymous_dataset(self): return self._anon_dataset_manager.dataset - @property - def bq_connection(self) -> str: - msg = bfe.format_message( - f"""You are using the BigFrames session default connection: {self._bq_connection}, - which can be different from the BigQuery project default connection. - This default connection may change in the future.""" - ) - warnings.warn(msg, category=FutureWarning) - return self._bq_connection - def __hash__(self): # Stable hash needed to use in expression tree return hash(str(self._session_id)) @@ -573,12 +380,8 @@ def close(self): remote_function_session = getattr(self, "_function_session", None) if remote_function_session: - remote_function_session.clean_up() - - publisher_session = getattr(self, "_publisher", None) - if publisher_session: - publisher_session.publish( - bigframes.core.events.SessionClosed(self.session_id) + self._function_session.clean_up( + self.bqclient, self.cloudfunctionsclient, self.session_id ) @overload @@ -594,8 +397,8 @@ def read_gbq( # type: ignore[overload-overlap] use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., - allow_large_results: Optional[bool] = ..., - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... @overload def read_gbq( @@ -610,8 +413,8 @@ def read_gbq( use_cache: Optional[bool] = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., - allow_large_results: Optional[bool] = ..., - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def read_gbq( self, @@ -624,8 +427,8 @@ def read_gbq( filters: third_party_pandas_gbq.FiltersType = (), use_cache: Optional[bool] = None, col_order: Iterable[str] = (), - dry_run: bool = False, - allow_large_results: Optional[bool] = None, + dry_run: bool = False + # Add a verify index argument that fails if the index is not unique. ) -> dataframe.DataFrame | pandas.Series: # TODO(b/281571214): Generate prompt to show the progress of read_gbq. if columns and col_order: @@ -635,9 +438,6 @@ 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( # type: ignore # for dry_run overload query_or_table, @@ -648,7 +448,6 @@ def read_gbq( use_cache=use_cache, filters=filters, dry_run=dry_run, - allow_large_results=allow_large_results, ) else: if configuration is not None: @@ -681,29 +480,27 @@ def _read_gbq_colab( self, query: str, *, - callback: Optional[Callable[[bigframes.core.events.EventEnvelope], None]] = ..., pyformat_args: Optional[Dict[str, Any]] = None, dry_run: Literal[False] = ..., - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... @overload def _read_gbq_colab( self, query: str, *, - callback: Optional[Callable[[bigframes.core.events.EventEnvelope], None]] = ..., pyformat_args: Optional[Dict[str, Any]] = None, dry_run: Literal[True] = ..., - ) -> pandas.Series: ... + ) -> 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. *, - callback: Optional[ - Callable[[bigframes.core.events.EventEnvelope], None] - ] = None, pyformat_args: Optional[Dict[str, Any]] = None, dry_run: bool = False, ) -> Union[dataframe.DataFrame, pandas.Series]: @@ -716,8 +513,6 @@ def _read_gbq_colab( query (str): A SQL query string to execute. Results (if any) are turned into a DataFrame. - callback (Optional[Callable[[bigframes.core.events.EventEnvelope], None]]): - Callback to receive query execution events. pyformat_args (dict): A dictionary of potential variables to replace in ``query``. Note: strings are _not_ escaped. Use query parameters for these, @@ -728,8 +523,6 @@ def _read_gbq_colab( 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, @@ -737,19 +530,16 @@ def _read_gbq_colab( dry_run=dry_run, ) - def _run_query(): - 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, - ) - - if callback is not None: - with self._publisher.subscribe(callback): - return _run_query() - return _run_query() + 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), + # TODO(tswast): we may need to allow allow_large_results to be overwritten + # or possibly a general configuration object for an explicit + # destination table and write disposition. + allow_large_results=False, + ) @overload def read_gbq_query( # type: ignore[overload-overlap] @@ -764,8 +554,8 @@ def read_gbq_query( # type: ignore[overload-overlap] col_order: Iterable[str] = ..., filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., - allow_large_results: Optional[bool] = ..., - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... @overload def read_gbq_query( @@ -780,8 +570,8 @@ def read_gbq_query( col_order: Iterable[str] = ..., filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., - allow_large_results: Optional[bool] = ..., - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def read_gbq_query( self, @@ -795,7 +585,6 @@ def read_gbq_query( col_order: Iterable[str] = (), filters: third_party_pandas_gbq.FiltersType = (), dry_run: bool = False, - allow_large_results: Optional[bool] = None, ) -> dataframe.DataFrame | pandas.Series: """Turn a SQL query into a DataFrame. @@ -806,9 +595,11 @@ 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, @@ -833,11 +624,9 @@ def read_gbq_query( ... WHERE year = 2016 ... GROUP BY pitcherFirstName, pitcherLastName ... ''', index_col="rowindex") - >>> print("START_OF_OUTPUT"); df.head(2) # doctest: +ELLIPSIS,+NORMALIZE_WHITESPACE - START_OF_OUTPUT - ... + >>> df.head(2) pitcherFirstName pitcherLastName averagePitchSpeed - ... + rowindex 1 Albertin Chapman 96.514113 2 Zachary Britton 94.591039 @@ -845,48 +634,9 @@ 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 or pandas.Series: - A DataFrame representing the result of the query. If ``dry_run`` - is ``True``, a ``pandas.Series`` containing query statistics is - returned. + bigframes.pandas.DataFrame: + A DataFrame representing results of the query or table. Raises: ValueError: @@ -901,9 +651,6 @@ def read_gbq_query( elif col_order: columns = col_order - 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, @@ -913,7 +660,6 @@ def read_gbq_query( use_cache=use_cache, filters=filters, dry_run=dry_run, - allow_large_results=allow_large_results, ) @overload @@ -928,7 +674,8 @@ def read_gbq_table( # type: ignore[overload-overlap] use_cache: bool = ..., col_order: Iterable[str] = ..., dry_run: Literal[False] = ..., - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... @overload def read_gbq_table( @@ -942,7 +689,8 @@ def read_gbq_table( use_cache: bool = ..., col_order: Iterable[str] = ..., dry_run: Literal[True] = ..., - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def read_gbq_table( self, @@ -960,47 +708,18 @@ def read_gbq_table( **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 or pandas.Series: - A DataFrame representing the contents of the table. If - ``dry_run`` is ``True``, a ``pandas.Series`` containing table - statistics is returned. + bigframes.pandas.DataFrame: + A DataFrame representing results of the query or table. Raises: ValueError: @@ -1037,6 +756,8 @@ 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") @@ -1064,9 +785,11 @@ 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) @@ -1093,7 +816,8 @@ def read_pandas( pandas_dataframe: pandas.Index, *, write_engine: constants.WriteEngineType = "default", - ) -> bigframes.core.indexes.Index: ... + ) -> bigframes.core.indexes.Index: + ... @typing.overload def read_pandas( @@ -1101,7 +825,8 @@ def read_pandas( pandas_dataframe: pandas.Series, *, write_engine: constants.WriteEngineType = "default", - ) -> bigframes.series.Series: ... + ) -> bigframes.series.Series: + ... @typing.overload def read_pandas( @@ -1109,7 +834,8 @@ def read_pandas( pandas_dataframe: pandas.DataFrame, *, write_engine: constants.WriteEngineType = "default", - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... def read_pandas( self, @@ -1129,6 +855,9 @@ 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) @@ -1519,7 +1248,7 @@ def read_parquet( "The provided path contains a wildcard character (*), which is not " "supported by the current engine. To read files from wildcard paths, " "please use the 'bigquery' engine by setting `engine='bigquery'` in " - "the function call." + "your configuration." ) read_parquet_kwargs: Dict[str, Any] = {} @@ -1535,87 +1264,6 @@ def read_parquet( ) return self._read_pandas(pandas_obj, write_engine=write_engine) - def read_orc( - self, - path: str | IO["bytes"], - *, - engine: str = "auto", - write_engine: constants.WriteEngineType = "default", - ) -> dataframe.DataFrame: - """Load an ORC file to a BigQuery DataFrames DataFrame. - - Args: - path (str or IO): - The path or buffer to the ORC file. Can be a local path or Google Cloud Storage URI. - engine (str, default "auto"): - The engine used to read the file. Supported values: `auto`, `bigquery`, `pyarrow`. - write_engine (str, default "default"): - The write engine used to persist the data to BigQuery if needed. - - Returns: - bigframes.pandas.DataFrame: - A new DataFrame representing the data from the ORC file. - """ - bigframes.session.validation.validate_engine_compatibility( - engine=engine, - write_engine=write_engine, - ) - if engine == "bigquery": - job_config = bigquery.LoadJobConfig() - job_config.source_format = bigquery.SourceFormat.ORC - job_config.labels = {"bigframes-api": "read_orc"} - table_id = self._loader.load_file(path, job_config=job_config) - return self._loader.read_gbq_table(table_id) - elif engine in ("auto", "pyarrow"): - if isinstance(path, str) and "*" in path: - raise ValueError( - "The provided path contains a wildcard character (*), which is not " - "supported by the current engine. To read files from wildcard paths, " - "please use the 'bigquery' engine by setting `engine='bigquery'` in " - "your configuration." - ) - - read_orc_kwargs: Dict[str, Any] = {} - if not pandas.__version__.startswith("1."): - read_orc_kwargs["dtype_backend"] = "pyarrow" - - pandas_obj = pandas.read_orc(path, **read_orc_kwargs) - return self._read_pandas(pandas_obj, write_engine=write_engine) - else: - raise ValueError( - f"Unsupported engine: {repr(engine)}. Supported values: 'auto', 'bigquery', 'pyarrow'." - ) - - def read_avro( - self, - path: str | IO["bytes"], - *, - engine: str = "auto", - ) -> dataframe.DataFrame: - """Load an Avro file to a BigQuery DataFrames DataFrame. - - Args: - path (str or IO): - The path or buffer to the Avro file. Can be a local path or Google Cloud Storage URI. - engine (str, default "auto"): - The engine used to read the file. Only `bigquery` is supported for Avro. - - Returns: - bigframes.pandas.DataFrame: - A new DataFrame representing the data from the Avro file. - """ - if engine not in ("auto", "bigquery"): - raise ValueError( - f"Unsupported engine: {repr(engine)}. Supported values: 'auto', 'bigquery'." - ) - - job_config = bigquery.LoadJobConfig() - job_config.use_avro_logical_types = True - job_config.source_format = bigquery.SourceFormat.AVRO - job_config.labels = {"bigframes-api": "read_avro"} - table_id = self._loader.load_file(path, job_config=job_config) - return self._loader.read_gbq_table(table_id) - def read_json( self, path_or_buf: str | IO["bytes"], @@ -1635,6 +1283,7 @@ def read_json( write_engine=write_engine, ) if engine == "bigquery": + if dtype is not None: raise NotImplementedError( "BigQuery engine does not support the dtype arguments." @@ -1699,7 +1348,7 @@ def _check_file_size(self, filepath: str): if filepath.startswith("gs://"): # GCS file path bucket_name, blob_path = filepath.split("/", 3)[2:] - client = self._clients_provider.storageclient + client = storage.Client() bucket = client.bucket(bucket_name) list_blobs_params = inspect.signature(bucket.list_blobs).parameters @@ -1754,6 +1403,13 @@ def deploy_remote_function( """ 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, ) @@ -1778,11 +1434,7 @@ def remote_function( 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] = None, - cloud_function_cpus: Optional[float] = None, + cloud_function_memory_mib: Optional[int] = 1024, cloud_function_ingress_settings: Literal[ "all", "internal-only", "internal-and-gclb" ] = "internal-only", @@ -1947,13 +1599,6 @@ 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 @@ -1963,10 +1608,6 @@ def remote_function( default memory of cloud functions be allocated, pass `None`. See for more details https://cloud.google.com/functions/docs/configuring/memory. - cloud_function_cpus (float, Optional): - The number of cpus to allocate for the cloud - function (2nd gen) created. - https://docs.cloud.google.com/run/docs/configuring/services/cpu. cloud_function_ingress_settings (str, Optional): Ingress settings controls dictating what traffic can reach the function. Options are: `all`, `internal-only`, or `internal-and-gclb`. @@ -1994,6 +1635,12 @@ def remote_function( `bigframes_remote_function` - The bigquery remote function capable of calling into `bigframes_cloud_function`. """ return self._function_session.remote_function( + # 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, @@ -2009,9 +1656,7 @@ 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_cpus=cloud_function_cpus, cloud_function_ingress_settings=cloud_function_ingress_settings, cloud_build_service_account=cloud_build_service_account, ) @@ -2040,6 +1685,10 @@ def deploy_udf( """ return self._function_session.deploy_udf( func, + # Session-provided arguments. + session=self, + bigquery_client=self._clients_provider.bqclient, + # User-provided arguments. **kwargs, ) @@ -2048,9 +1697,9 @@ def udf( *, input_types: Union[None, type, Sequence[type]] = None, output_type: Optional[type] = None, - dataset: Optional[str] = None, + dataset: str, bigquery_connection: Optional[str] = None, - name: Optional[str] = None, + name: str, packages: Optional[Sequence[str]] = None, max_batching_rows: Optional[int] = None, container_cpu: Optional[float] = None, @@ -2073,12 +1722,14 @@ def udf( **Examples:** + >>> import bigframes.pandas as bpd >>> import datetime + >>> bpd.options.display.progress_bar = None 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 + >>> @bpd.udf(dataset="bigfranes_testing", name=bq_name) ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -2091,8 +1742,8 @@ def udf( 4 120 dtype: Int64 - >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP - >>> hours # doctest: +SKIP + >>> hours = minutes.apply(minutes_to_hours) + >>> hours 0 0.0 1 0.5 2 1.0 @@ -2105,7 +1756,7 @@ def udf( 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 + >>> @bpd.udf( ... dataset="bigfranes_testing", ... name=bq_name, ... packages=["cryptography"] @@ -2122,14 +1773,14 @@ def udf( ... return f.encrypt(input.encode()).decode() >>> names = bpd.Series(["Alice", "Bob"]) - >>> hashes = names.apply(get_hash) # doctest: +SKIP + >>> hashes = names.apply(get_hash) 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 + >>> session = bpd.get_global_session() + >>> session.bqclient.delete_routine(minutes_to_hours.bigframes_bigquery_function) + >>> session.bqclient.delete_routine(get_hash.bigframes_bigquery_function) Args: input_types (type or sequence(type), Optional): @@ -2142,7 +1793,7 @@ def udf( be specified. The supported output types are `bool`, `bytes`, `float`, `int`, `str`, `list[bool]`, `list[float]`, `list[int]` and `list[str]`. - dataset (str, Optional): + dataset (str): Dataset in which to create a BigQuery managed function. It should be in `.` or `` format. @@ -2200,6 +1851,10 @@ def udf( 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, @@ -2231,10 +1886,12 @@ 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 @@ -2294,13 +1951,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(cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(cloud_function_service_account="default") ... def tenfold(num: int) -> float: ... return num * 10 Then, read back the deployed BQ remote function: - >>> tenfold_ref = bpd.read_gbq_function( # doctest: +SKIP + >>> tenfold_ref = bpd.read_gbq_function( ... tenfold.bigframes_remote_function, ... ) @@ -2312,7 +1969,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df['a'].apply(tenfold_ref) # doctest: +SKIP + >>> df['a'].apply(tenfold_ref) 0 10.0 1 20.0 Name: a, dtype: Float64 @@ -2321,11 +1978,11 @@ def read_gbq_function( note, row processor implies that the function has only one input parameter. - >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP - ... def row_sum(s: pd.Series) -> float: + >>> @bpd.remote_function(cloud_function_service_account="default") + ... def row_sum(s: bpd.Series) -> float: ... return s['a'] + s['b'] + s['c'] - >>> row_sum_ref = bpd.read_gbq_function( # doctest: +SKIP + >>> row_sum_ref = bpd.read_gbq_function( ... row_sum.bigframes_remote_function, ... is_row_processor=True, ... ) @@ -2338,7 +1995,7 @@ def read_gbq_function( [2 rows x 3 columns] - >>> df.apply(row_sum_ref, axis=1) # doctest: +SKIP + >>> df.apply(row_sum_ref, axis=1) 0 9.0 1 12.0 dtype: Float64 @@ -2400,7 +2057,7 @@ 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 - iterator, query_job = bf_io_bigquery.start_query_with_job( + iterator, query_job = bf_io_bigquery.start_query_with_client( self.bqclient, sql, job_config=job_config, @@ -2408,25 +2065,11 @@ def _start_query_ml_ddl( 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 _from_glob_path( - self, path: str, *, connection: Optional[str] = None, name: Optional[str] = None - ) -> dataframe.DataFrame: - """Create a BigFrames DataFrame that contains a BigFrames ObjectRef column from a global wildcard path.""" - import bigframes.bigquery as bq - - connection = self._create_bq_connection(connection=connection) - table = self._create_object_table(path, connection) - s = bq.obj.make_ref( - self._loader.read_gbq_table(table)["uri"], authorizer=connection - ) - return s.rename(name).to_frame() - 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._anon_dataset_manager.generate_unique_resource_id()) @@ -2442,7 +2085,7 @@ def _create_object_table(self, path: str, connection: str) -> str: uris = ['{path}']); """ ) - bf_io_bigquery.start_query_with_job( + bf_io_bigquery.start_query_with_client( self.bqclient, sql, job_config=bigquery.QueryJobConfig(), @@ -2450,8 +2093,7 @@ def _create_object_table(self, path: str, connection: str) -> str: location=None, project=None, timeout=None, - publisher=self._publisher, - session=self, + query_with_job=True, ) return table @@ -2468,6 +2110,40 @@ def _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: + r"""Create a BigFrames DataFrame that contains a BigFrames Blob column from a global wildcard path. + This operation creates a temporary BQ Object Table under the hood and requires bigquery.connections.delegate permission or BigQuery Connection Admin role. + If you have an existing BQ Object Table, use read_gbq_object_table(). + + .. 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). + + Args: + path (str): + The wildcard global path, such as "gs:////\*". + connection (str or None, default 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. + name (str): + The column name of the Blob column. + Returns: + bigframes.pandas.DataFrame: + Result BigFrames DataFrame. + """ + # TODO(garrettwu): switch to pseudocolumn when b/374988109 is done. + connection = self._create_bq_connection(connection=connection) + + table = self._create_object_table(path, connection) + + s = self._loader.read_gbq_table(table)["uri"].str.to_blob(connection) + return s.rename(name).to_frame() + def _create_bq_connection( self, *, @@ -2475,9 +2151,8 @@ def _create_bq_connection( 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 + 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.get_canonical_bq_connection_id( connection_id=connection, default_project=self._project, @@ -2495,118 +2170,32 @@ def _create_bq_connection( return connection - # ========================================================================= - # 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 read_gbq_object_table( + self, object_table: str, *, name: Optional[str] = None + ) -> dataframe.DataFrame: + """Read an existing object table to create a BigFrames Blob DataFrame. Use the connection of the object table for the connection of the blob. + This function dosen't retrieve the object table data. If you want to read the data, use read_gbq() instead. - def to_timedelta(self, *args, **kwargs): - """Converts a BigQuery DataFrames object to timedelta/duration dtype. + .. 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). - Included for compatibility between bpd and Session. + Args: + object_table (str): name of the object table of form ... + name (str or None): the returned blob column name. - See :func:`bigframes.pandas.to_timedelta` for full documentation. + Returns: + bigframes.pandas.DataFrame: + Result BigFrames DataFrame. """ - import bigframes.pandas.core.tools.timedeltas + # TODO(garrettwu): switch to pseudocolumn when b/374988109 is done. + table = self.bqclient.get_table(object_table) + connection = table._properties["externalDataConfiguration"]["connectionId"] - return bigframes.pandas.core.tools.timedeltas.to_timedelta( - *args, - session=self, - **kwargs, - ) + s = self._loader.read_gbq_table(object_table)["uri"].str.to_blob(connection) + return s.rename(name).to_frame() def connect(context: Optional[bigquery_options.BigQueryOptions] = None) -> Session: diff --git a/bigframes/session/_io/bigquery/__init__.py b/bigframes/session/_io/bigquery/__init__.py index 3d60bcc8074..83f63e8b9a5 100644 --- a/bigframes/session/_io/bigquery/__init__.py +++ b/bigframes/session/_io/bigquery/__init__.py @@ -22,33 +22,21 @@ 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 -import bigframes.core.events +from bigframes.core import log_adapter +import bigframes.core.compile.googlesql as googlesql import bigframes.core.sql +import bigframes.formatting_helpers as formatting_helpers import bigframes.session.metrics -from bigframes.core.compile.sqlglot import sql as sg_sql -from bigframes.core.logging import log_adapter -CHECK_DRIVE_PERMISSIONS = ( - "\nCheck https://cloud.google.com/bigquery/docs/" - "query-drive-data#Google_Drive_permissions." -) +CHECK_DRIVE_PERMISSIONS = "\nCheck https://cloud.google.com/bigquery/docs/query-drive-data#Google_Drive_permissions." IO_ORDERING_ID = "bqdf_row_nums" @@ -64,11 +52,13 @@ def create_job_configs_labels( ) -> Dict[str, str]: if job_configs_labels is None: job_configs_labels = {} - else: - job_configs_labels = dict(job_configs_labels) + + # If the user has labels they wish to set, make sure we set those first so + # they are preserved. + for key, value in bigframes.options.compute.extra_query_labels.items(): + job_configs_labels[key] = value if api_methods and "bigframes-api" not in job_configs_labels: - api_methods = list(api_methods) job_configs_labels["bigframes-api"] = api_methods[0] del api_methods[0] @@ -93,10 +83,7 @@ def create_job_configs_labels( def create_export_data_statement( - table_id: str, - uri: str, - format: str, - export_options: Dict[str, Union[bool, str]], + table_id: str, uri: str, format: str, export_options: Dict[str, Union[bool, str]] ) -> str: all_options: Dict[str, Union[bool, str]] = { "uri": uri, @@ -137,7 +124,6 @@ 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. @@ -150,10 +136,10 @@ def create_temp_table( if cluster_columns: destination.clustering_fields = cluster_columns if kms_key: - enc_config = bigquery.EncryptionConfiguration(kms_key_name=kms_key) - destination.encryption_configuration = enc_config - # Ok if already exists, since this will only happen from retries - # internal to this method + destination.encryption_configuration = bigquery.EncryptionConfiguration( + kms_key_name=kms_key + ) + # 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}" @@ -165,7 +151,6 @@ def create_temp_view( *, expiration: datetime.datetime, sql: str, - session=None, ) -> str: """Create an empty table with an expiration in the desired session. @@ -176,8 +161,7 @@ def create_temp_view( destination.expires = expiration destination.view_query = sql - # Ok if already exists, since this will only happen from retries - # internal to this method + # 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}" @@ -211,10 +195,7 @@ def bq_field_to_type_sql(field: bigquery.SchemaField): if field.mode == "REPEATED": nested_type = bq_field_to_type_sql( bigquery.SchemaField( - field.name, - field.field_type, - mode="NULLABLE", - fields=field.fields, + field.name, field.field_type, mode="NULLABLE", fields=field.fields ) ) return f"ARRAY<{nested_type}>" @@ -245,115 +226,81 @@ def format_option(key: str, value: Union[bool, str]) -> str: return f"{key}={repr(value)}" -def add_and_trim_labels( - job_config, - session=None, - extra_query_labels: Optional[Mapping[str, str]] = None, -): +def add_and_trim_labels(job_config): """ - Add additional labels to the job configuration and trim the total - number of labels to ensure they do not exceed MAX_LABELS_COUNT labels - per job. + Add additional labels to the job configuration and trim the total number of labels + 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, session=session - ) + api_methods = log_adapter.get_and_reset_api_methods(dry_run=job_config.dry_run) job_config.labels = create_job_configs_labels( job_configs_labels=job_config.labels, api_methods=api_methods, ) -def create_bq_event_callback(publisher, cell_execution_count=None): - event_map = { - google.cloud.bigquery._job_helpers.QueryFinishedEvent: ( - bigframes.core.events.BigQueryFinishedEvent - ), - google.cloud.bigquery._job_helpers.QueryReceivedEvent: ( - bigframes.core.events.BigQueryReceivedEvent - ), - google.cloud.bigquery._job_helpers.QueryRetryEvent: ( - bigframes.core.events.BigQueryRetryEvent - ), - google.cloud.bigquery._job_helpers.QuerySentEvent: ( - bigframes.core.events.BigQuerySentEvent - ), - } - - def publish_bq_event(event): - bf_event = bigframes.core.events.BigQueryUnknownEvent(event) - for bq_type, bf_type in event_map.items(): - if isinstance(event, bq_type): - bf_event = bf_type.from_bqclient(event) # type: ignore - break - envelope = bigframes.core.events.EventEnvelope( - event=bf_event, - progress_bar=bigframes.core.events._DEFAULT, - cell_execution_count=cell_execution_count, - ) - publisher.publish(envelope) - - return publish_bq_event +@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], +) -> Tuple[bigquery.table.RowIterator, bigquery.QueryJob]: + ... -def start_query_with_job( +@overload +def start_query_with_client( bq_client: bigquery.Client, sql: str, *, job_config: bigquery.QueryJobConfig, - location: Optional[str] = None, - project: Optional[str] = None, - timeout: Optional[float] = None, - metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, - # 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), # noqa: E501 - publisher: bigframes.core.events.Publisher, - session=None, - cell_execution_count: Optional[int] = None, -) -> Tuple[google.cloud.bigquery.table.RowIterator, bigquery.QueryJob]: - """ - Starts query job and waits for results. - """ - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() + location: Optional[str], + project: Optional[str], + timeout: Optional[float], + metrics: Optional[bigframes.session.metrics.ExecutionMetrics], + query_with_job: Literal[False], +) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + ... - # 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: - 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 - - results_iterator = query_job.result() - _publish_events( - query_job=query_job, - total_rows=results_iterator.total_rows, - sql=sql, - publisher=publisher, - metrics=metrics, - cell_execution_count=cell_execution_count, - ) - return results_iterator, query_job +@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, +) -> Tuple[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, +) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: + ... -def start_query_job_optional( +def start_query_with_client( bq_client: bigquery.Client, sql: str, *, @@ -362,103 +309,63 @@ def start_query_job_optional( project: Optional[str] = None, timeout: Optional[float] = None, metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, + 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), # noqa: E501 - publisher: Optional[bigframes.core.events.Publisher] = None, - session=None, - cell_execution_count: Optional[int] = None, -) -> google.cloud.bigquery.table.RowIterator: + job_retry: google.api_core.retry.Retry = third_party_gcb_retry.DEFAULT_JOB_RETRY, +) -> Tuple[bigquery.table.RowIterator, Optional[bigquery.QueryJob]]: """ - Run a bigquery query, with job optional. - - See: - https://docs.cloud.google.com/bigquery/docs/running-queries#optional-job-creation + Starts query job and waits for results. """ - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() - - add_and_trim_labels(job_config, session=session) try: - results_iterator = bq_client._query_and_wait_bigframes( + # 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) + if not query_with_job: + results_iterator = bq_client.query_and_wait( + sql, + job_config=job_config, + location=location, + project=project, + api_timeout=timeout, + job_retry=job_retry, + ) + 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, - api_timeout=timeout, + timeout=timeout, job_retry=job_retry, - callback=create_bq_event_callback( - publisher, cell_execution_count=cell_execution_count - ) - if publisher - else lambda _: None, ) - if metrics is not None: - metrics.count_job_stats( - row_iterator=results_iterator, cell_execution_count=cell_execution_count - ) - return results_iterator except google.api_core.exceptions.Forbidden as ex: if "Drive credentials" in ex.message: ex.message += CHECK_DRIVE_PERMISSIONS raise - -def _publish_events( - query_job: bigquery.QueryJob, - sql: str, - total_rows: Optional[int], - publisher: bigframes.core.events.Publisher, - metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, - cell_execution_count: Optional[int] = None, -): - if not query_job.configuration.dry_run: - publisher.publish( - bigframes.core.events.EventEnvelope( - event=bigframes.core.events.BigQuerySentEvent( - sql, - billing_project=query_job.project, - location=query_job.location, - job_id=query_job.job_id, - request_id=None, - ), - cell_execution_count=cell_execution_count, - ) - ) - if not query_job.configuration.dry_run: - publisher.publish( - bigframes.core.events.EventEnvelope( - event=bigframes.core.events.BigQueryFinishedEvent( - billing_project=query_job.project, - location=query_job.location, - query_id=query_job.query_id, - job_id=query_job.job_id, - destination=query_job.destination, - total_rows=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, - ), - cell_execution_count=cell_execution_count, - ) + 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, + progress_bar=opts.progress_bar, ) + else: + results_iterator = query_job.result() if metrics is not None: - metrics.count_job_stats( - query_job=query_job, cell_execution_count=cell_execution_count - ) + metrics.count_job_stats(query_job=query_job) + return results_iterator, query_job def delete_tables_matching_session_id( - client: bigquery.Client, - dataset: bigquery.DatasetReference, - session_id: str, + client: bigquery.Client, dataset: bigquery.DatasetReference, session_id: str ) -> None: """Searches within the dataset for tables conforming to the expected session_id form, and instructs bigquery to delete them. @@ -492,8 +399,6 @@ def create_bq_dataset_reference( bq_client: bigquery.Client, location: Optional[str] = None, project: Optional[str] = None, - *, - publisher: bigframes.core.events.Publisher, ) -> bigquery.DatasetReference: """Create and identify dataset(s) for temporary BQ resources. @@ -512,12 +417,11 @@ def create_bq_dataset_reference( The project id of the project to create the dataset in. Returns: - bigquery.DatasetReference: The constructed reference to the - anonymous dataset. + bigquery.DatasetReference: The constructed reference to the anonymous dataset. """ job_config = google.cloud.bigquery.QueryJobConfig() - _, query_job = start_query_with_job( + _, query_job = start_query_with_client( bq_client, "SELECT 1", location=location, @@ -525,7 +429,7 @@ def create_bq_dataset_reference( project=project, timeout=None, metrics=None, - publisher=publisher, + query_with_job=True, ) # The anonymous dataset is used by BigQuery to write query results and @@ -546,8 +450,7 @@ def is_query(query_or_table: str) -> bool: def is_table_with_wildcard_suffix(query_or_table: str) -> bool: - """Determine if `query_or_table` is a table and contains a wildcard - suffix.""" + """Determine if `query_or_table` is a table and contains a wildcard suffix.""" return not is_query(query_or_table) and query_or_table.endswith("*") @@ -559,40 +462,33 @@ def to_query( time_travel_timestamp: Optional[datetime.datetime] = None, ) -> str: """Compile query_or_table with conditions(filters, wildcards) to query.""" - if is_query(query_or_table): - from_item = f"({query_or_table})" - else: - # Table ID can have 1, 2, 3, or 4 parts. Quoting all parts to be safe. - # See: - # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#identifiers - parts = query_or_table.split(".") - from_item = ".".join(f"`{part}`" for part in parts) + sub_query = ( + f"({query_or_table})" if is_query(query_or_table) else f"`{query_or_table}`" + ) # TODO(b/338111344): Generate an index based on DefaultIndexKind if we # don't have index columns specified. if columns: # We only reduce the selection if columns is set, but we always # want to make sure index_cols is also included. - select_clause = "SELECT " + ", ".join( - f"`_bf_source`.`{column}`" for column in columns - ) + select_clause = "SELECT " + ", ".join(f"`{column}`" for column in columns) else: select_clause = "SELECT *" time_travel_clause = "" if time_travel_timestamp is not None: - time_travel_literal = sg_sql.to_sql(sg_sql.literal(time_travel_timestamp)) # noqa: E501 + time_travel_literal = bigframes.core.sql.simple_literal(time_travel_timestamp) time_travel_clause = f" FOR SYSTEM_TIME AS OF {time_travel_literal}" limit_clause = "" if max_results is not None: - limit_clause = f" LIMIT {sg_sql.to_sql(sg_sql.literal(max_results))}" + limit_clause = f" LIMIT {bigframes.core.sql.simple_literal(max_results)}" where_clause = f" WHERE {sql_predicate}" if sql_predicate else "" return ( f"{select_clause} " - f"FROM {from_item} AS _bf_source" + f"FROM {sub_query}" f"{time_travel_clause}{where_clause}{limit_clause}" ) @@ -614,11 +510,10 @@ def compile_filters(filters: third_party_pandas_gbq.FiltersType) -> str: "!=": "!=", } - # If single layer filter, add another pseudo layer. So the single - # layer represents "and" logic. + # If single layer filter, add another pseudo layer. So the single layer represents "and" logic. filters_list: list = list(filters) if isinstance(filters_list[0], tuple) and ( - len(filters_list[0]) == 0 or not isinstance(list(filters_list[0])[0], tuple) # noqa: E501 + len(filters_list[0]) == 0 or not isinstance(list(filters_list[0])[0], tuple) ): filter_items = [filters_list] else: @@ -632,16 +527,14 @@ def compile_filters(filters: third_party_pandas_gbq.FiltersType) -> str: for filter_item in group: if not isinstance(filter_item, tuple) or (len(filter_item) != 3): raise ValueError( - f"Elements of filters must be tuples of length 3, " - f"but got {repr(filter_item)}.", + f"Elements of filters must be tuples of length 3, but got {repr(filter_item)}.", ) column, operator, value = filter_item if not isinstance(column, str): raise ValueError( - f"Column name should be a string, but received " - f"'{column}' of type {type(column).__name__}." + f"Column name should be a string, but received '{column}' of type {type(column).__name__}." ) if operator not in valid_operators: @@ -649,11 +542,11 @@ def compile_filters(filters: third_party_pandas_gbq.FiltersType) -> str: operator_str = valid_operators[operator] - column_ref = sg_sql.to_sql(sg_sql.identifier(column)) + column_ref = googlesql.identifier(column) if operator_str in ["IN", "NOT IN"]: value_literal = bigframes.core.sql.multi_literal(*value) else: - value_literal = sg_sql.to_sql(sg_sql.literal(value)) + value_literal = bigframes.core.sql.simple_literal(value) expression = bigframes.core.sql.infix_op( operator_str, column_ref, value_literal ) diff --git a/bigframes/session/_io/bigquery/read_gbq_query.py b/bigframes/session/_io/bigquery/read_gbq_query.py index cd6368974b6..70c83d78753 100644 --- a/bigframes/session/_io/bigquery/read_gbq_query.py +++ b/bigframes/session/_io/bigquery/read_gbq_query.py @@ -16,42 +16,19 @@ from __future__ import annotations -from typing import Iterable, Optional, Tuple, cast +from typing import Optional +from google.cloud import bigquery import google.cloud.bigquery.table import pandas -from google.cloud import bigquery +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 -from bigframes import dataframe -from bigframes.core import local_data, pyarrow_utils - - -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( @@ -76,11 +53,7 @@ def create_dataframe_from_query_job_stats( 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], + rows: google.cloud.bigquery.table.RowIterator, *, session: bigframes.session.Session ) -> dataframe.DataFrame: """Convert a RowIterator into a DataFrame wrapping a LocalNode. @@ -88,27 +61,11 @@ def create_dataframe_from_row_iterator( '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)) + # 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) # We use the ManagedArrowTable constructor directly, because the # results of to_arrow() should be the source of truth with regards @@ -117,27 +74,17 @@ def create_dataframe_from_row_iterator( # like the output of the BQ Storage Read API. mat = local_data.ManagedArrowTable( pa_table, - schemata.ArraySchema.from_bq_schema(bq_schema), + schemata.ArraySchema.from_bq_schema( + list(rows.schema) + [bigquery.SchemaField(offsets_col, "INTEGER")] + ), ) 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, + (offsets_col,), + [field.name for field in rows.schema], + (None,), ) - df = dataframe.DataFrame(block) - - if columns: - df = df[list(columns)] - - if not is_default_index: - df = df.sort_index() - - return df + return dataframe.DataFrame(block) diff --git a/bigframes/session/_io/bigquery/read_gbq_table.py b/bigframes/session/_io/bigquery/read_gbq_table.py index faaf0f01912..30a25762ebb 100644 --- a/bigframes/session/_io/bigquery/read_gbq_table.py +++ b/bigframes/session/_io/bigquery/read_gbq_table.py @@ -20,105 +20,100 @@ import datetime import typing +from typing import Dict, Iterable, List, Optional, Sequence, Tuple import warnings -from typing import Dict, Iterable, Optional, Sequence, Tuple, Union import bigframes_vendored.constants as constants import google.api_core.exceptions import google.cloud.bigquery as bigquery -import bigframes.core -import bigframes.core.events +import bigframes.clients +import bigframes.core.compile +import bigframes.core.compile.default_ordering +import bigframes.core.sql +import bigframes.dtypes import bigframes.exceptions as bfe import bigframes.session._io.bigquery -from bigframes.core import bq_data +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) +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]], + use_cache: bool = True, +) -> 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) + if use_cache and cached_table is not None: + 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, + ): + # 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) - dataset = "INFORMATION_SCHEMA" - table_id_short = ".".join(parts[dataset_index + 1 :]) - return bigquery.TableReference( - bigquery.DatasetReference(project, dataset), - table_id_short, - ) + return cached_table + 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. + if (table.created is not None) and (table.created > bq_time): + bq_time = table.created -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 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()) - ) + cached_table = (bq_time, table) + cache[table_ref] = cached_table + return cached_table def is_time_travel_eligible( bqclient: bigquery.Client, - table: Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable], + table: bigquery.table.Table, columns: Optional[Sequence[str]], snapshot_time: datetime.datetime, filter_str: Optional[str] = None, *, should_warn: bool, should_dry_run: bool, - publisher: bigframes.core.events.Publisher, ): """Check if a table is eligible to use time-travel. @@ -145,55 +140,48 @@ def is_time_travel_eligible( # -> is_time_travel_eligible stacklevel = 7 - if isinstance(table, bq_data.GbqNativeTable): - # Anonymous dataset, does not support snapshot ever - if table.dataset_id.startswith("_"): - return False + # Anonymous dataset, does not support snapshot ever + if table.dataset_id.startswith("_"): + return False - # Only true tables support time travel - if table.table_id.endswith("*"): + # 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": 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." + "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.metadata.type != "TABLE": - if table.metadata.type == "MATERIALIZED_VIEW": - 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.metadata.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=table.get_full_id( - quoted=False - ), # to_query will quote for us + 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=snapshot_time, ) try: # If this succeeds, we know that time travel will for sure work. - bigframes.session._io.bigquery.start_query_job_optional( + bigframes.session._io.bigquery.start_query_with_client( bq_client=bqclient, sql=snapshot_sql, job_config=bigquery.QueryJobConfig(dry_run=True), @@ -201,7 +189,7 @@ def is_time_travel_eligible( project=None, timeout=None, metrics=None, - publisher=publisher, + query_with_job=False, ) return True @@ -228,8 +216,10 @@ def is_time_travel_eligible( def infer_unique_columns( - table: Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable], - index_cols: Sequence[str], + bqclient: bigquery.Client, + table: bigquery.table.Table, + index_cols: List[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. @@ -238,43 +228,19 @@ def infer_unique_columns( """ # If index_cols contain the primary_keys, the query engine assumes they are # provide a unique index. - primary_keys = table.primary_key or () + primary_keys = tuple(_get_primary_keys(table)) if (len(primary_keys) > 0) and frozenset(primary_keys) <= frozenset(index_cols): # 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: + if primary_keys or metadata_only or (not index_cols): + # Sometimes not worth scanning data to check uniqueness return primary_keys - - return () - - -def check_if_index_columns_are_unique( - bqclient: bigquery.Client, - table: Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable], - index_cols: Sequence[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.get_table_ref() - ) + is_unique_sql = bigframes.core.sql.is_distinct_sql(index_cols, table.reference) job_config = bigquery.QueryJobConfig() - results = bigframes.session._io.bigquery.start_query_job_optional( - bq_client=bqclient, - sql=is_unique_sql, - job_config=job_config, - timeout=None, - location=None, - project=None, - metrics=None, - publisher=publisher, - ) + results = bqclient.query_and_wait(is_unique_sql, job_config=job_config) row = next(iter(results)) if row["total_count"] == row["distinct_count"]: @@ -282,8 +248,49 @@ def check_if_index_columns_are_unique( return () +def _get_primary_keys( + table: bigquery.table.Table, +) -> List[str]: + """Get primary keys from table if they are set.""" + + primary_keys: List[str] = [] + if ( + (table_constraints := getattr(table, "table_constraints", None)) is not None + and (primary_key := table_constraints.primary_key) is not None + # This will be False for either None or empty list. + # We want primary_keys = None if no primary keys are set. + and (columns := primary_key.columns) + ): + primary_keys = columns if columns is not None else [] + + return primary_keys + + +def _is_table_clustered_or_partitioned( + table: bigquery.table.Table, +) -> bool: + """Returns True if the table is clustered or partitioned.""" + + # Could be None or an empty tuple if it's not clustered, both of which are + # falsey. + if table.clustering_fields: + return True + + if ( + time_partitioning := table.time_partitioning + ) is not None and time_partitioning.type_ is not None: + return True + + if ( + range_partitioning := table.range_partitioning + ) is not None and range_partitioning.field is not None: + return True + + return False + + def get_index_cols( - table: Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable], + table: bigquery.table.Table, index_col: Iterable[str] | str | Iterable[int] @@ -291,8 +298,7 @@ def get_index_cols( | bigframes.enums.DefaultIndexKind, *, rename_to_schema: Optional[Dict[str, str]] = None, - default_index_type: bigframes.enums.DefaultIndexKind = bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64, -) -> Sequence[str]: +) -> 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 @@ -300,9 +306,9 @@ def get_index_cols( """ # Transform index_col -> index_cols so we have a variable that is # always a list of column names (possibly empty). - schema_len = len(table.physical_schema) + schema_len = len(table.schema) - index_cols = [] + 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. @@ -327,7 +333,7 @@ def get_index_cols( 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.physical_schema[index_col].name] + index_cols = [table.schema[index_col].name] elif isinstance(index_col, Iterable): for item in index_col: if isinstance(item, str): @@ -340,7 +346,7 @@ def get_index_cols( 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.physical_schema[item].name) + index_cols.append(table.schema[item].name) else: raise TypeError( "If index_col is an iterable, it must contain either strings " @@ -355,19 +361,15 @@ def get_index_cols( # 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. if len(index_cols) == 0: - primary_keys = table.primary_key or () + primary_keys = _get_primary_keys(table) # If table has clustering/partitioning, fail if we haven't been able to # 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 ( - (table.partition_col is not None or table.cluster_cols) - and not primary_keys - and default_index_type == bigframes.enums.DefaultIndexKind.SEQUENTIAL_INT64 - ): + if _is_table_clustered_or_partitioned(table) and not primary_keys: msg = bfe.format_message( - f"Table '{str(table.get_full_id())}' is clustered and/or " + 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: " # TODO(b/338037499): Allow max_results to override this too, @@ -379,6 +381,6 @@ def get_index_cols( # If there are primary keys defined, the query engine assumes these # columns are unique, even if the constraint is not enforced. We make # the same assumption and use these columns as the total ordering keys. - index_cols = list(primary_keys) + index_cols = primary_keys return index_cols diff --git a/bigframes/session/_io/pandas.py b/bigframes/session/_io/pandas.py index 8d41474d8c4..9340e060aca 100644 --- a/bigframes/session/_io/pandas.py +++ b/bigframes/session/_io/pandas.py @@ -89,7 +89,7 @@ def arrow_to_pandas( # Preserve NA/NaN distinction. Note: This is currently needed, even if we use # nullable Float64Dtype in the types_mapper. See: # https://github.com/pandas-dev/pandas/issues/55668 - mask = pyarrow.compute.is_null(column) # type: ignore[attr-defined] + mask = pyarrow.compute.is_null(column) nonnull = pyarrow.compute.fill_null(column, float("nan")) # Regarding type: ignore, this class has been public at this # location since pandas 1.2.0. See: @@ -106,7 +106,7 @@ def arrow_to_pandas( elif dtype == pandas.Int64Dtype(): # Avoid out-of-bounds errors in Pandas 1.5.x, which incorrectly # casts to float64 in an intermediate step. - mask = pyarrow.compute.is_null(column) # type: ignore[attr-defined] + mask = pyarrow.compute.is_null(column) nonnull = pyarrow.compute.fill_null(column, 0) pd_array = pandas.arrays.IntegerArray( nonnull.to_numpy() diff --git a/bigframes/session/anonymous_dataset.py b/bigframes/session/anonymous_dataset.py index ed718ff909f..ec624d4eb43 100644 --- a/bigframes/session/anonymous_dataset.py +++ b/bigframes/session/anonymous_dataset.py @@ -14,24 +14,16 @@ import datetime import threading -import uuid -import warnings -from concurrent.futures import ThreadPoolExecutor from typing import List, Optional, Sequence +import uuid import google.cloud.bigquery as bigquery -from google.api_core import retry as api_core_retry -import bigframes.core.events -import bigframes.exceptions as bfe -import bigframes.session._io.bigquery as bf_io_bigquery from bigframes import constants 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): @@ -45,12 +37,10 @@ def __init__( location: str, session_id: str, *, - kms_key: Optional[str] = None, - publisher: bigframes.core.events.Publisher, + kms_key: Optional[str] = None ): self.bqclient = bqclient self._location = location - self._publisher = publisher self.session_id = session_id self._table_ids: List[bigquery.TableReference] = [] @@ -72,7 +62,6 @@ def dataset(self) -> bigquery.DatasetReference: self._datset_ref = bf_io_bigquery.create_bq_dataset_reference( self.bqclient, location=self._location, - publisher=self._publisher, ) return self._datset_ref @@ -144,56 +133,8 @@ def generate_unique_resource_id(self) -> bigquery.TableReference: ) 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.""" - if self._table_ids: - try: - with ThreadPoolExecutor() as executor: - futures = [ - executor.submit( - self.bqclient.delete_table, table_ref, not_found_ok=True - ) - for table_ref in self._table_ids - ] - for future in futures: - future.result() - finally: - 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) + for table_ref in self._table_ids: + self.bqclient.delete_table(table_ref, not_found_ok=True) + self._table_ids.clear() diff --git a/bigframes/session/bigquery_session.py b/bigframes/session/bigquery_session.py index 18f8cdeaff4..883087df07d 100644 --- a/bigframes/session/bigquery_session.py +++ b/bigframes/session/bigquery_session.py @@ -12,21 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import datetime import logging import threading -import uuid 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 -import bigframes.core.events -import bigframes.session._io.bigquery as bfbqio -from bigframes.core.compile.sqlglot import sql as sg_sql +from bigframes.core.compile import googlesql from bigframes.session import temporary_storage KEEPALIVE_QUERY_TIMEOUT_SECONDS = 5.0 @@ -42,19 +38,12 @@ 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, - ): + def __init__(self, bqclient: bigquery.Client, location: str): 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): @@ -80,7 +69,7 @@ def create_temp_table( ibis_schema = ibis_bq.BigQuerySchema.to_ibis(list(schema)) fields = [ - f"{sg_sql.to_sql(sg_sql.identifier(name))} {ibis_bq.BigQueryType.from_ibis(ibis_type)}" + f"{googlesql.identifier(name)} {ibis_bq.BigQueryType.from_ibis(ibis_type)}" for name, ibis_type in ibis_schema.fields.items() ] fields_string = ",".join(fields) @@ -88,47 +77,28 @@ def create_temp_table( cluster_string = "" if cluster_cols: cluster_cols_sql = ", ".join( - f"{sg_sql.to_sql(sg_sql.identifier(cluster_col))}" + 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`.{sg_sql.to_sql(sg_sql.identifier(table_ref.table_id))} ({fields_string}){cluster_string}" + ddl = f"CREATE TEMP TABLE `_SESSION`.{googlesql.identifier(table_ref.table_id)} ({fields_string}){cluster_string}" - _, job = bfbqio.start_query_with_job( - self.bqclient, - ddl, - job_config=job_config, - location=self.location, - project=None, - timeout=None, - metrics=None, - publisher=self._publisher, + job = self.bqclient.query( + ddl, job_config=job_config, location=self.location ) 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 + return job.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_job_optional( - self.bqclient, + self.bqclient.query_and_wait( f"CALL BQ.ABORT_SESSION('{self._session_id}')", - # Assume this is being called in the user thread, so we can access - # this thread-local config. - job_config=bigquery.QueryJobConfig( - labels=dict(bigframes.options.compute.extra_query_labels) - ), location=self.location, - project=None, - timeout=None, - metrics=None, - publisher=self._publisher, ) def _get_session_id(self) -> str: @@ -139,15 +109,8 @@ def _get_session_id(self) -> str: 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_job( - self.bqclient, - "SELECT 1", - job_config=job_config, - location=self.location, - project=None, - timeout=None, - metrics=None, - publisher=self._publisher, + query_job = self.bqclient.query( + "SELECT 1", job_config=job_config, location=self.location ) query_job.result() # blocks until finished assert query_job.session_info is not None @@ -170,15 +133,11 @@ def _keep_session_alive(self): ] ) try: - bfbqio.start_query_job_optional( - self.bqclient, + self.bqclient.query_and_wait( "SELECT 1", - job_config=job_config, location=self.location, - project=None, - timeout=KEEPALIVE_QUERY_TIMEOUT_SECONDS, - metrics=None, - publisher=self._publisher, + job_config=job_config, + wait_timeout=KEEPALIVE_QUERY_TIMEOUT_SECONDS, ) except Exception as e: logging.warning("BigQuery session keep-alive query errored : %s", e) diff --git a/bigframes/session/bq_caching_executor.py b/bigframes/session/bq_caching_executor.py index dede318d813..a970e75a0f4 100644 --- a/bigframes/session/bq_caching_executor.py +++ b/bigframes/session/bq_caching_executor.py @@ -14,54 +14,44 @@ from __future__ import annotations -import asyncio -import concurrent.futures import dataclasses import math +import os import threading -from typing import Literal, Optional, Sequence, Tuple +from typing import cast, Literal, Mapping, Optional, Sequence, Tuple, Union +import warnings +import weakref import google.api_core.exceptions -import google.cloud.bigquery_storage_v1 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 -import bigframes.core.events +from bigframes.core import compile, local_data, rewrite +import bigframes.core.compile.sqlglot.sqlglot_ir as sqlglot_ir import bigframes.core.guid import bigframes.core.nodes as nodes -import bigframes.core.ordering +import bigframes.core.ordering as order import bigframes.core.schema as schemata import bigframes.core.tree_properties as tree_properties import bigframes.dtypes -import bigframes.functions._function_session as bff_session -import bigframes.operations as ops -import bigframes.session._io.bigquery as bq_io -import bigframes.session.execution_cache as execution_cache -import bigframes.session.execution_spec as ex_spec -import bigframes.session.metrics -import bigframes.session.planner -import bigframes.session.temporary_storage -from bigframes.core import ( - compile, - expression, - guid, - identifiers, - local_data, - rewrite, -) -from bigframes.core.compile.sqlglot import sql as sg_sql -from bigframes.core.compile.sqlglot import sqlglot_ir -from bigframes.functions import udf_def +import bigframes.features from bigframes.session import ( - direct_gbq_execution, executor, loader, local_scan_executor, read_api_execution, semi_executor, ) +import bigframes.session._io.bigquery as bq_io +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 @@ -71,41 +61,79 @@ MAX_SMALL_RESULT_BYTES = 10 * 1024 * 1024 * 1024 # 10G -_bg_loop = None -_bg_thread = None -_bg_lock = threading.Lock() +@dataclasses.dataclass +class OutputSpec: + require_bq_table: bool + cluster_cols: tuple[str, ...] + def with_require_table(self, value: bool) -> OutputSpec: + return dataclasses.replace(self, require_bq_table=value) -def _get_bg_loop(): - global _bg_loop, _bg_thread - with _bg_lock: - if _bg_loop is None: - loop = asyncio.new_event_loop() - _bg_loop = loop - def run(): - asyncio.set_event_loop(loop) - loop.run_forever() +def _get_default_output_spec() -> OutputSpec: + return OutputSpec( + require_bq_table=bigframes.options._allow_large_results, cluster_cols=() + ) - _bg_thread = threading.Thread( - target=run, daemon=True, name="bigframes-bg-loop" - ) - _bg_thread.start() - return _bg_loop + +SourceIdMapping = Mapping[str, str] -def _run_sync(coro): - try: - loop = asyncio.get_running_loop() - except RuntimeError: - loop = None +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[nodes.BigqueryDataSource, SourceIdMapping], + ] = weakref.WeakKeyDictionary() - if loop is None: - return asyncio.run(coro) - else: - bg_loop = _get_bg_loop() - future = asyncio.run_coroutine_threadsafe(coro, bg_loop) - return future.result() + @property + def mapping(self) -> Mapping[nodes.BigFrameNode, nodes.BigFrameNode]: + return self._cached_executions + + def cache_results_table( + self, + original_root: nodes.BigFrameNode, + table: bigquery.Table, + ordering: order.RowOrdering, + num_rows: Optional[int] = None, + ): + # Assumption: GBQ cached table uses field name as bq column name + scan_list = nodes.ScanList( + tuple( + nodes.ScanItem(field.id, field.dtype, field.id.sql) + for field in original_root.fields + ) + ) + cached_replacement = nodes.CachedTableNode( + source=nodes.BigqueryDataSource( + nodes.GbqTable.from_table(table), + ordering=ordering, + n_rows=num_rows, + ), + 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: nodes.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): @@ -124,26 +152,18 @@ def __init__( 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, - labels: tuple[tuple[str, str], ...] = (), - compiler_name: Literal["ibis", "sqlglot"] = "sqlglot", - cache: Optional[execution_cache.ExecutionCache] = None, - function_manager: bff_session.FunctionSession, ): self.bqclient = bqclient self.storage_manager = storage_manager - self.cache: execution_cache.ExecutionCache = ( - cache or execution_cache.ExecutionCache() - ) + 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 - self._compiler_name = compiler_name - - # TODO(tswast): Send events from semi-executors, too. self._semi_executors: Sequence[semi_executor.SemiExecutor] = ( read_api_execution.ReadApiSemiExecutor( bqstoragereadclient=bqstoragereadclient, @@ -158,15 +178,7 @@ def __init__( *self._semi_executors, polars_executor.PolarsExecutor(), ) - self._gbq_executor = direct_gbq_execution.DirectGbqExecutor( - bqclient, - compiler=compiler_name, - bqstoragereadclient=bqstoragereadclient, - metrics=self.metrics, - publisher=self._publisher, - labels=dict(labels), - ) - self._function_manager = function_manager + self._upload_lock = threading.Lock() def to_sql( self, @@ -177,190 +189,149 @@ def to_sql( ) -> str: if offset_column: array_value, _ = array_value.promote_offsets() - node = ( - self._prepare_plan_simplify(array_value.node) - if enable_cache - else array_value.node - ) - node = _run_sync(self._substitute_large_local_sources(node)) - compiled = compile.compile_sql( - compile.CompileRequest(node, sort_rows=ordered), - compiler_name=self._compiler_name, - ) + node = self.logical_plan(array_value.node) 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, + *, + ordered: bool = True, + use_explicit_destination: Optional[bool] = None, ) -> executor.ExecuteResult: - # Need to grab thread local before starting async execution. - execution_spec = execution_spec.with_compute_options(bigframes.options.compute) - return _run_sync( - self._execute_async( - array_value, - execution_spec, - ) + if bigframes.options.compute.enable_multi_query_execution: + self._simplify_with_caching(array_value) + + output_spec = _get_default_output_spec() + if use_explicit_destination is not None: + output_spec = output_spec.with_require_table(use_explicit_destination) + + plan = self.logical_plan(array_value.node) + return self._execute_plan( + plan, + ordered=ordered, + output_spec=output_spec, ) - async def _execute_async( + def peek( self, array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, + n_rows: int, + use_explicit_destination: Optional[bool] = None, ) -> executor.ExecuteResult: - await self._publisher.publish_async(bigframes.core.events.ExecutionStarted()) - maybe_result = await self._try_execute_semi_executors( - array_value, execution_spec - ) - if maybe_result is not None: - return maybe_result - result = await self._execute_bigquery( - array_value, - execution_spec, - ) - await self._publisher.publish_async( - bigframes.core.events.EventEnvelope( - event=bigframes.core.events.ExecutionFinished(result=result), - cell_execution_count=execution_spec.cell_execution_count, - ) + """ + A 'peek' efficiently accesses a small number of rows in the dataframe. + """ + plan = self.logical_plan(array_value.node) + if not tree_properties.can_fast_peek(plan): + msg = bfe.format_message("Peeking this value cannot be done efficiently.") + warnings.warn(msg) + + output_spec = _get_default_output_spec() + if use_explicit_destination is not None: + output_spec = output_spec.with_require_table(use_explicit_destination) + + return self._execute_plan( + plan, ordered=False, output_spec=output_spec, peek=n_rows ) - return result - async def _try_execute_semi_executors( + def export_gbq( self, array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, - ) -> Optional[executor.ExecuteResult]: - plan = self._prepare_plan_simplify(array_value.node) - for exec in self._semi_executors: - maybe_result = await exec.execute(plan, execution_spec) - if maybe_result: - await self._publisher.publish_async( - bigframes.core.events.EventEnvelope( - event=bigframes.core.events.ExecutionFinished( - result=maybe_result, - ), - cell_execution_count=execution_spec.cell_execution_count, - ) - ) - return maybe_result - return None + destination: bigquery.TableReference, + if_exists: Literal["fail", "replace", "append"] = "fail", + cluster_cols: Sequence[str] = [], + ): + """ + Export the ArrayValue to an existing BigQuery table. + """ + if bigframes.options.compute.enable_multi_query_execution: + self._simplify_with_caching(array_value) - async def _execute_bigquery( - self, - array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, - ) -> executor.ExecuteResult: - dest_spec = execution_spec.destination_spec - # Recursive handlers for different cases, maybe extract to explicit interface. - if isinstance(dest_spec, ex_spec.GcsOutputSpec): - execution_spec = dataclasses.replace( - execution_spec, destination_spec=ex_spec.EphemeralTableSpec() - ) - results = await self._execute_bigquery( - array_value, - execution_spec, - ) - await self._export_result_gcs(results, dest_spec) - return results - elif isinstance(dest_spec, ex_spec.TableOutputSpec): - return await self._execute_gbq_table_export( - array_value, - execution_spec, - ) - # Force table creation if result might be large (and user explicitly allowed large results) - elif isinstance(dest_spec, ex_spec.EphemeralTableSpec) or (dest_spec is None): - if not execution_spec.promise_under_10gb: - table = await asyncio.to_thread( - self.storage_manager.create_temp_table, - array_value.schema.to_bigquery(), - ) - execution_spec = dataclasses.replace( - execution_spec, - destination_spec=ex_spec.TableOutputSpec( - table=table, if_exists="append" - ), - ) - # We don't use _execute_gbq_table_export, as this result is internal, not exported. - return await self._execute_gbq_query_only( - array_value, - execution_spec, + table_exists = True + try: + table = self.bqclient.get_table(destination) + if if_exists == "fail": + raise ValueError(f"Table already exists: {destination.__str__()}") + except google.api_core.exceptions.NotFound: + table_exists = False + + if len(cluster_cols) != 0: + if table_exists and table.clustering_fields != cluster_cols: + raise ValueError( + "Table clustering fields cannot be changed after the table has " + f"been created. Existing clustering fields: {table.clustering_fields}" ) - # At this point, dst should be unspecified, a specific bq table, or an ephemeral temp table that fits in <10gb - return await self._execute_gbq_query_only( - array_value, - execution_spec, - ) - async def _execute_gbq_table_export( - self, - array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, - ) -> executor.ExecuteResult: - dest_spec = execution_spec.destination_spec - assert isinstance(dest_spec, ex_spec.TableOutputSpec) - existing_table = await self._maybe_find_existing_table(dest_spec) - if (existing_table is not None) and _is_schema_match( - existing_table.schema, array_value.schema - ): - # Special DML path - maybe this should be configurable, dml vs query destination has tradeoffs - execution_spec = dataclasses.replace( - execution_spec, destination_spec=ex_spec.EphemeralTableSpec() - ) - results = await self._execute_bigquery( - array_value, - execution_spec, - ) - assert isinstance(results, executor.BQTableExecuteResult) - await self._export_gbq_with_dml(results, dest_spec) - result: executor.ExecuteResult = results + sql = self.to_sql(array_value, ordered=False) + if table_exists and _if_schema_match(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 if_exists == "append": + sql = ir.insert(destination) + else: # for "replace" + assert if_exists == "replace" + sql = ir.replace(destination) else: - result = await self._execute_gbq_query_only( - array_value, - execution_spec, + dispositions = { + "fail": bigquery.WriteDisposition.WRITE_EMPTY, + "replace": bigquery.WriteDisposition.WRITE_TRUNCATE, + "append": bigquery.WriteDisposition.WRITE_APPEND, + } + job_config = bigquery.QueryJobConfig( + write_disposition=dispositions[if_exists], + destination=destination, + clustering_fields=cluster_cols if cluster_cols else None, ) - has_special_dtype_col = any( - t in (bigframes.dtypes.TIMEDELTA_DTYPE, bigframes.dtypes.OBJ_REF_DTYPE) - for t in array_value.schema.dtypes + # 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, + ) + + has_timedelta_col = any( + t == bigframes.dtypes.TIMEDELTA_DTYPE for t in array_value.schema.dtypes ) - if dest_spec.if_exists != "append" and has_special_dtype_col: - table = await asyncio.to_thread(self.bqclient.get_table, dest_spec.table) + + if 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(destination) table.schema = array_value.schema.to_bigquery() - await asyncio.to_thread(self.bqclient.update_table, table, ["schema"]) + self.bqclient.update_table(table, ["schema"]) - return result + return query_job - async def _execute_gbq_query_only( + def export_gcs( self, array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, - ) -> executor.ExecuteResult: - gbq_plan = await self._prepare_plan_bq_execution( - array_value.node, execution_spec.bigquery_config - ) - result = await self._gbq_executor.execute(gbq_plan, execution_spec) - if result is None: - raise ValueError( - f"Couldn't execute plan {array_value.node} with {execution_spec}" - ) - return result - - async def _export_result_gcs( - self, result: executor.ExecuteResult, gcs_export_spec: ex_spec.GcsOutputSpec + uri: str, + format: Literal["json", "csv", "parquet"], + export_options: Mapping[str, Union[bool, str]], ): - query_job = result.query_job + query_job = self.execute( + array_value, + ordered=False, + use_explicit_destination=True, + ).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), + uri=uri, + format=format, + export_options=dict(export_options), ) - await asyncio.to_thread( - bq_io.start_query_with_job, + + bq_io.start_query_with_client( self.bqclient, export_data_statement, job_config=bigquery.QueryJobConfig(), @@ -368,40 +339,9 @@ async def _export_result_gcs( project=None, location=None, timeout=None, - publisher=self._publisher, - ) - - async def _export_gbq_with_dml( - self, result: executor.BQTableExecuteResult, spec: ex_spec.TableOutputSpec - ): - """ - Export the ArrayValue to an existing BigQuery table, using DML. - """ - # 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 - assert result.query_job is not None - assert result.query_job.destination is not None - ir = sqlglot_ir.SQLGlotIR.from_table( - result.query_job.destination.project, - result.query_job.destination.dataset_id, - result.query_job.destination.table_id, - ) - sql = "" - if spec.if_exists == "append": - sql = sg_sql.to_sql(sg_sql.insert(ir.expr.as_select_all(), spec.table)) - else: # for "replace" - assert spec.if_exists == "replace" - sql = sg_sql.to_sql(sg_sql.replace(ir.expr.as_select_all(), spec.table)) - - await asyncio.to_thread( - bq_io.start_query_with_job, - self.bqclient, - sql, - job_config=bigquery.QueryJobConfig(), - metrics=self.metrics, - publisher=self._publisher, + query_with_job=True, ) + return query_job def dry_run( self, array_value: bigframes.core.ArrayValue, ordered: bool = True @@ -413,23 +353,6 @@ def dry_run( def cached( self, array_value: bigframes.core.ArrayValue, *, config: executor.CacheConfig - ) -> None: - # Get compute options before passing to async method, can be thread-local - bq_compute_options = ex_spec.BqComputeOptions.from_compute_options( - bigframes.options.compute - ) - return _run_sync( - self._cached_async( - array_value, config=config, compute_options=bq_compute_options - ) - ) - - async def _cached_async( - self, - array_value: bigframes.core.ArrayValue, - *, - config: executor.CacheConfig, - compute_options: ex_spec.BqComputeOptions, ) -> None: """Write the block to a session table.""" # First, see if we can reuse the existing cache @@ -451,64 +374,70 @@ async def _cached_async( raise ValueError(f"Unexpected 'if_cached' arg: {config.if_cached}") if config.optimize_for == "auto": - await self._cache_with_session_awareness( - array_value, compute_options=compute_options - ) + self._cache_with_session_awareness(array_value) elif config.optimize_for == "head": - await self._cache_with_offsets(array_value, compute_options=compute_options) + self._cache_with_offsets(array_value) else: assert isinstance(config.optimize_for, executor.HierarchicalKey) - await self._cache_with_cluster_cols( - array_value, - cluster_cols=config.optimize_for.columns, - compute_options=compute_options, + self._cache_with_cluster_cols( + array_value, cluster_cols=config.optimize_for.columns ) - async def _execute_to_cached_table( + # Helpers + def _run_execute_query( self, - plan: nodes.BigFrameNode, - cache_spec: ex_spec.CacheSpec, - compute_options: ex_spec.BqComputeOptions, - ) -> executor.ExecuteResult: - # "ephemeral" temp tables created in the course of exeuction, don't need to be allocated - # materialized ordering only really makes sense for internal temp tables used by caching - cluster_cols = cache_spec.cluster_cols - # Rewrite plan to materialize ordering as extra columns - if cache_spec.ordering == "offsets_col": - order_col_id = guid.generate_guid() - plan = nodes.PromoteOffsetsNode(plan, identifiers.ColumnId(order_col_id)) - cluster_cols = (order_col_id,) - ordering: bigframes.core.ordering.RowOrdering = ( - bigframes.core.ordering.TotalOrdering.from_offset_col(order_col_id) + sql: str, + job_config: Optional[bq_job.QueryJobConfig] = None, + query_with_job: bool = True, + ) -> 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 ) - elif cache_spec.ordering == "order_key": - plan, ordering = rewrite.pull_out_order(plan) - destination_table = await asyncio.to_thread( - self.storage_manager.create_temp_table, - plan.schema.to_bigquery(), - cluster_cols, - ) - arr_value = bigframes.core.ArrayValue(plan) - execution_spec = ex_spec.ExecutionSpec( - destination_spec=ex_spec.TableOutputSpec( - table=destination_table, - cluster_cols=cluster_cols, - if_exists="replace", - ), - bigquery_config=compute_options, - ) - # We don't use _execute_gbq_table_export, as this result is internal, not exported. - result = await self._execute_gbq_query_only( - arr_value, - execution_spec, - ) - assert isinstance(result, executor.BQTableExecuteResult), ( - "expected result to be BQTableExecuteResult" - ) - result._data = dataclasses.replace(result._data, ordering=ordering) - return result - # Helpers + 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, + ) + 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, + ) + + 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? @@ -516,129 +445,65 @@ def _is_trivially_executable(self, array_value: bigframes.core.ArrayValue): """ # Once rewriting is available, will want to rewrite before # evaluating execution cost. - simplified_plan = self._prepare_plan_simplify(array_value.node) - return tree_properties.is_trivially_executable(simplified_plan) + return tree_properties.is_trivially_executable( + self.logical_plan(array_value.node) + ) - def _prepare_plan_simplify(self, plan: nodes.BigFrameNode) -> nodes.BigFrameNode: - """Prepare the plan by simplifying it with caches and removing unused operators.""" - plan = self.cache.subsitute_cached_subplans(plan) + def logical_plan(self, root: nodes.BigFrameNode) -> nodes.BigFrameNode: + """ + Apply universal logical simplifications that are helpful regardless of engine. + """ + plan = self.replace_cached_subtrees(root) plan = rewrite.column_pruning(plan) plan = plan.top_down(rewrite.fold_row_counts) return plan - async def _deploy_undeployed_udfs( - self, plan: nodes.BigFrameNode - ) -> nodes.BigFrameNode: - referenced_udfs = list(set(self._collect_udf_defs(plan))) - deployed_mapping: dict[udf_def.PythonUdf, udf_def.BigqueryUdf] = {} - tasks = [ - asyncio.to_thread( - self._function_manager._deploy_udf, - udf, - ) - for udf in referenced_udfs - ] - results = await asyncio.gather(*tasks) - deployed_mapping = dict(zip(referenced_udfs, results)) - - return self._subsitute_temporary_functions(plan, deployed_mapping) - - def _collect_udf_defs(self, plan: nodes.BigFrameNode) -> list[udf_def.PythonUdf]: - udf_defs: list[udf_def.PythonUdf] = [] - exprs = [ - expr for node in plan.unique_nodes() for expr in node._node_expressions - ] - expr_nodes = [expr for expr in exprs for expr in expr.walk()] - for expr_node in expr_nodes: - if ( - isinstance(expr_node, expression.OpExpression) - and isinstance(expr_node.op, ops.PythonUdfOp) - and isinstance(expr_node.op.function_def, udf_def.PythonUdf) - ): - udf_defs.append(expr_node.op.function_def) - return udf_defs - - def _subsitute_temporary_functions( - self, - plan: nodes.BigFrameNode, - deployed_mapping: dict[udf_def.PythonUdf, udf_def.BigqueryUdf], - ) -> nodes.BigFrameNode: - def replace_udf_expr(e: expression.Expression) -> expression.Expression: - if isinstance(e, expression.OpExpression) and isinstance( - e.op, ops.PythonUdfOp - ): - func_def = e.op.function_def - # We will have already deployed the function - assert func_def in deployed_mapping - deployed_func = deployed_mapping[func_def] - rf_op = ops.RemoteFunctionOp(function_def=deployed_func) - return dataclasses.replace(e, op=rf_op) - return e - - def replace_in_expr(expr: expression.Expression) -> expression.Expression: - return expr.bottom_up(replace_udf_expr) - - def replace_in_node(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - if hasattr(node, "transform_exprs"): - return node.transform_exprs(replace_in_expr) - return node - - return plan.bottom_up(replace_in_node) - - async def _prepare_plan_bq_execution( - self, - plan: nodes.BigFrameNode, - compute_options: Optional[ex_spec.BqComputeOptions] = None, - ) -> nodes.BigFrameNode: - """Prepare the plan for BigQuery execution by caching subtrees and uploading large local sources.""" - plan = await self._deploy_undeployed_udfs(plan) - if compute_options is not None and compute_options.enable_multi_query_execution: - await self._simplify_with_caching(plan, compute_options=compute_options) - plan = self._prepare_plan_simplify(plan) - plan = await self._substitute_large_local_sources(plan) - return plan - - async def _cache_with_cluster_cols( - self, - array_value: bigframes.core.ArrayValue, - cluster_cols: Sequence[str], - compute_options: ex_spec.BqComputeOptions, + 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.""" - cluster_cols = [ - col - for col in cluster_cols - if bigframes.dtypes.is_clusterable(array_value.schema.get_type(col)) - ] - cluster_cols = cluster_cols[:_MAX_CLUSTER_COLUMNS] - result = await self._execute_to_cached_table( - array_value.node, - ex_spec.CacheSpec(cluster_cols=tuple(cluster_cols), ordering="order_key"), - compute_options=compute_options, + plan = self.logical_plan(array_value.node) + plan = self._substitute_large_local_sources(plan) + compiled = compile.compile_sql( + compile.CompileRequest( + plan, sort_rows=False, materialize_all_order_keys=True + ) + ) + tmp_table_ref, num_rows = self._sql_as_cached_temp_table( + compiled.sql, + compiled.sql_schema, + cluster_cols=bq_io.select_cluster_cols(compiled.sql_schema, cluster_cols), + ) + tmp_table = self.bqclient.get_table(tmp_table_ref) + assert compiled.row_order is not None + self.cache.cache_results_table( + array_value.node, tmp_table, compiled.row_order, num_rows=num_rows ) - assert isinstance(result, executor.BQTableExecuteResult) - assert result._data.ordering is not None - self.cache.cache_results_table(array_value.node, result._data) - async def _cache_with_offsets( - self, - array_value: bigframes.core.ArrayValue, - compute_options: ex_spec.BqComputeOptions, - ): + def _cache_with_offsets(self, array_value: bigframes.core.ArrayValue): """Executes the query and uses the resulting table to rewrite future executions.""" - result = await self._execute_to_cached_table( - array_value.node, - ex_spec.CacheSpec(ordering="offsets_col"), - compute_options=compute_options, + offset_column = bigframes.core.guid.generate_guid("bigframes_offsets") + w_offsets, offset_column = array_value.promote_offsets() + compiled = compile.compile_sql( + compile.CompileRequest( + self.logical_plan(self._substitute_large_local_sources(w_offsets.node)), + sort_rows=False, + ) + ) + tmp_table_ref, num_rows = self._sql_as_cached_temp_table( + compiled.sql, + compiled.sql_schema, + cluster_cols=[offset_column], + ) + tmp_table = self.bqclient.get_table(tmp_table_ref) + assert compiled.row_order is not None + self.cache.cache_results_table( + array_value.node, tmp_table, compiled.row_order, num_rows=num_rows ) - assert isinstance(result, executor.BQTableExecuteResult) - assert result._data.ordering is not None - self.cache.cache_results_table(array_value.node, result._data) - async def _cache_with_session_awareness( + def _cache_with_session_awareness( self, array_value: bigframes.core.ArrayValue, - compute_options: ex_spec.BqComputeOptions, ) -> None: session_forest = [obj._block._expr.node for obj in array_value.session.objects] # These node types are cheap to re-compute @@ -647,158 +512,238 @@ async def _cache_with_session_awareness( ) cluster_cols_sql_names = [id.sql for id in cluster_cols] if len(cluster_cols) > 0: - await self._cache_with_cluster_cols( - bigframes.core.ArrayValue(target), - cluster_cols_sql_names, - compute_options=compute_options, - ) - elif not target.order_ambiguous: - await self._cache_with_offsets( - bigframes.core.ArrayValue(target), - compute_options=compute_options, + 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: - await self._cache_with_cluster_cols( - bigframes.core.ArrayValue(target), - [], - compute_options=compute_options, - ) + self._cache_with_cluster_cols(bigframes.core.ArrayValue(target), []) - async def _simplify_with_caching( - self, plan: nodes.BigFrameNode, compute_options: ex_spec.BqComputeOptions - ): + 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): if ( - self._prepare_plan_simplify(plan).planning_complexity + self.logical_plan(array_value.node).planning_complexity < QUERY_COMPLEXITY_LIMIT ): return - did_cache = await self._cache_most_complex_subtree( - plan, compute_options=compute_options - ) + did_cache = self._cache_most_complex_subtree(array_value.node) if not did_cache: return - async def _cache_most_complex_subtree( - self, node: nodes.BigFrameNode, compute_options: ex_spec.BqComputeOptions - ) -> bool: + 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=self.cache, + cache=dict(self.cache.mapping), # Heuristic: subtree_compleixty * (copies of subtree)^2 - heuristic=lambda complexity, count: ( - math.log(complexity) + 2 * math.log(count) - ), + 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 - await self._cache_with_cluster_cols( - bigframes.core.ArrayValue(selection), - [], - compute_options=compute_options, - ) + self._cache_with_cluster_cols(bigframes.core.ArrayValue(selection), []) return True - async def _substitute_large_local_sources(self, original_root: nodes.BigFrameNode): + def _sql_as_cached_temp_table( + self, + sql: str, + schema: Sequence[bigquery.SchemaField], + cluster_cols: Sequence[str], + ) -> tuple[bigquery.TableReference, Optional[int]]: + 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, + ) + assert query_job is not None + iter = query_job.result() + return query_job.destination, iter.total_rows + + def _validate_result_schema( + self, + array_value: bigframes.core.ArrayValue, + bq_schema: list[bigquery.SchemaField], + ): + actual_schema = _sanitize(tuple(bq_schema)) + ibis_schema = compile.test_only_ibis_inferred_schema( + self.logical_plan(array_value.node) + ).to_bigquery() + internal_schema = _sanitize(array_value.schema.to_bigquery()) + if not bigframes.features.PANDAS_VERSIONS.is_arrow_list_dtype_usable: + return + + if internal_schema != actual_schema: + raise ValueError( + f"This error should only occur while testing. BigFrames internal schema: {internal_schema} does not match actual schema: {actual_schema}" + ) + + if ibis_schema != actual_schema: + raise ValueError( + f"This error should only occur while testing. Ibis schema: {ibis_schema} does not match actual schema: {actual_schema}" + ) + + 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 - needs_upload = [] 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 ): - needs_upload.append(leaf.local_data_source) - - futures: dict[concurrent.futures.Future, local_data.ManagedArrowTable] = dict() - for local_source in needs_upload: - future = self.loader.read_data_async( - local_source, bigframes.core.guid.generate_guid() - ) - futures[future] = local_source - try: - results = await asyncio.gather( - *(asyncio.wrap_future(f) for f in futures.keys()) - ) - for future, result in zip(futures.keys(), results): - self.cache.cache_remote_replacement(futures[future], result) - except Exception as e: - # cancel all futures - for future in futures: - future.cancel() - raise e + 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 - uploaded_local_data = self.cache.get_uploaded_local_data( - node.local_data_source - ) - if uploaded_local_data is None: + if node.local_data_source not in self.cache._uploaded_local_data: return node - - scan_list = node.scan_list.remap_source_ids( - uploaded_local_data.source_mapping - ) + 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( - uploaded_local_data.bq_source.table.physical_schema[-1].name, + bq_source.table.physical_schema[-1].name, bigframes.dtypes.INT_DTYPE, node.offsets_col, ) - return nodes.ReadTableNode( - uploaded_local_data.bq_source, scan_list, node.session - ) + return nodes.ReadTableNode(bq_source, scan_list, node.session) return original_root.bottom_up(map_local_scans) - async def _maybe_find_existing_table( - self, spec: ex_spec.TableOutputSpec - ) -> Optional[bigquery.Table]: - # validate destination table - try: - table = await asyncio.to_thread(self.bqclient.get_table, spec.table) - if spec.if_exists == "fail": - raise ValueError(f"Table already exists: {spec.table.__str__()}") + 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) - 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 _execute_plan( + self, + plan: nodes.BigFrameNode, + ordered: bool, + output_spec: OutputSpec, + peek: Optional[int] = None, + ) -> executor.ExecuteResult: + """Just execute whatever plan as is, without further caching or decomposition.""" + # First try to execute fast-paths + if not output_spec.require_bq_table: + for exec in self._semi_executors: + maybe_result = exec.execute(plan, ordered=ordered, peek=peek) + if maybe_result: + return maybe_result + + # Use explicit destination to avoid 10GB limit of temporary table + destination_table = ( + self.storage_manager.create_temp_table( + plan.schema.to_bigquery(), cluster_cols=output_spec.cluster_cols + ) + if output_spec.require_bq_table + else None + ) + + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. + job_config = bigquery.QueryJobConfig() + # Use explicit destination to avoid 10GB limit of temporary table + if destination_table is not None: + job_config.destination = destination_table + plan = self._substitute_large_local_sources(plan) + compiled = compile.compile_sql( + compile.CompileRequest(plan, sort_rows=ordered, peek_count=peek) + ) + iterator, query_job = self._run_execute_query( + sql=compiled.sql, + job_config=job_config, + query_with_job=(destination_table is not None), + ) -def _is_schema_match( - table_schema: Tuple[bigquery.SchemaField, ...], - schema: schemata.ArraySchema, + if query_job: + size_bytes = self.bqclient.get_table(query_job.destination).num_bytes + else: + size_bytes = None + + if size_bytes is not None and size_bytes >= MAX_SMALL_RESULT_BYTES: + msg = bfe.format_message( + "The query result size has exceeded 10 GB. In BigFrames 2.0 and " + "later, you might need to manually set `allow_large_results=True` in " + "the IO method or adjust the BigFrames option: " + "`bigframes.options.compute.allow_large_results=True`." + ) + warnings.warn(msg, FutureWarning) + # 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: + self._validate_result_schema( + bigframes.core.ArrayValue(plan), iterator.schema + ) + + return executor.ExecuteResult( + _arrow_batches=iterator.to_arrow_iterable( + bqstorage_client=self.bqstoragereadclient + ), + schema=plan.schema, + query_job=query_job, + total_bytes=size_bytes, + total_rows=iterator.total_rows, + ) + + +def _if_schema_match( + table_schema: Tuple[bigquery.SchemaField, ...], schema: schemata.ArraySchema ) -> bool: if len(table_schema) != len(schema.items): return False - for field, schema_item in zip(table_schema, schema.items): - if field.name != schema_item.column: + for field in table_schema: + if field.name not in schema.names: return False - _, field_dtype = bigframes.dtypes.convert_schema_field(field) - if field_dtype != schema_item.dtype: + if bigframes.dtypes.convert_schema_field(field)[1] != schema.get_type( + field.name + ): return False return True + + +def _sanitize( + schema: Tuple[bigquery.SchemaField, ...] +) -> Tuple[bigquery.SchemaField, ...]: + # Schema inferred from SQL strings and Ibis expressions contain only names, types and modes, + # so we disregard other fields (e.g timedelta description for timedelta columns) for validations. + return tuple( + bigquery.SchemaField( + f.name, + f.field_type, + f.mode, # type:ignore + fields=_sanitize(f.fields), + ) + for f in schema + ) diff --git a/bigframes/session/clients.py b/bigframes/session/clients.py index 49822bac16b..d680b94b8ad 100644 --- a/bigframes/session/clients.py +++ b/bigframes/session/clients.py @@ -14,7 +14,9 @@ """Clients manages the connection to Google APIs.""" +import os import threading +import typing from typing import Optional, Sequence, Tuple import google.api_core.client_info @@ -27,7 +29,7 @@ import google.cloud.bigquery_storage_v1 import google.cloud.functions_v2 import google.cloud.resourcemanager_v3 -import google.cloud.storage # type: ignore +import pydata_google_auth import requests import bigframes.constants @@ -35,7 +37,9 @@ 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. @@ -46,6 +50,10 @@ _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) + + def _get_application_names(): apps = [_APPLICATION_NAME] @@ -66,10 +74,10 @@ class ClientsProvider: def __init__( self, - project: str, - credentials: google.auth.credentials.Credentials, + project: Optional[str] = None, location: Optional[str] = None, use_regional_endpoints: Optional[bool] = None, + credentials: Optional[google.auth.credentials.Credentials] = None, application_name: Optional[str] = None, bq_kms_key_name: Optional[str] = None, client_endpoints_override: dict = {}, @@ -78,6 +86,29 @@ def __init__( Tuple[str, requests.adapters.BaseAdapter] ] = (), ): + credentials_project = None + if credentials is None: + credentials, credentials_project = _get_default_credentials_with_project() + + # Ensure an access token is available. + credentials.refresh(google.auth.transport.requests.Request()) + + # Prefer the project in this order: + # 1. Project explicitly specified by the user + # 2. Project set in the environment + # 3. Project associated with the default credentials + project = ( + project + or os.getenv(_ENV_DEFAULT_PROJECT) + or typing.cast(Optional[str], credentials_project) + ) + + if not project: + raise ValueError( + "Project must be set to initialize BigQuery client. " + "Try setting `bigframes.options.bigquery.project` first." + ) + self._application_name = ( f"{_get_application_names()} {application_name}" if application_name @@ -134,9 +165,6 @@ def __init__( 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 "bqclient" in self._client_endpoints_override: @@ -163,11 +191,10 @@ def _create_bigquery_client(self): client_options=bq_options, project=self._project, location=self._location, - # Use _http so that users can override + # Instead of credentials, 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 @@ -320,17 +347,3 @@ def resourcemanagerclient(self): ) 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/deferred.py b/bigframes/session/deferred.py deleted file mode 100644 index 75906e2a124..00000000000 --- a/bigframes/session/deferred.py +++ /dev/null @@ -1,76 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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, Callable, Optional, Union - -import pandas as pd - -import bigframes.dataframe -import bigframes.series - - -class DeferredBigQueryDataFrame: - """A proxy object that defers the execution of a BigQuery job until requested.""" - - def __init__( - self, - execution_func: Callable[ - [], - Union[ - bigframes.dataframe.DataFrame, - bigframes.series.Series, - pd.Series, - pd.DataFrame, - ], - ], - ): - self._execution_func = execution_func - self._result: Optional[ - Union[ - bigframes.dataframe.DataFrame, - bigframes.series.Series, - pd.Series, - pd.DataFrame, - ] - ] = None - - @property - def executed(self) -> bool: - return self._result is not None - - def execute( - self, - ) -> Union[ - bigframes.dataframe.DataFrame, - bigframes.series.Series, - pd.Series, - pd.DataFrame, - ]: - """Executes the deferred operation and returns the resulting DataFrame.""" - if self._result is None: - self._result = self._execution_func() - return self._result - - def _repr_mimebundle_(self, include=None, exclude=None): - from bigframes.display.anywidget import TableWidget - - return TableWidget(self)._repr_mimebundle_(include=include, exclude=exclude) # type: ignore - - def __getattr__(self, name: str) -> Any: - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'. " - "This is a deferred object. Display it to run the query interactively." - ) diff --git a/bigframes/session/direct_gbq_execution.py b/bigframes/session/direct_gbq_execution.py index bcfc29ba971..ff91747a626 100644 --- a/bigframes/session/direct_gbq_execution.py +++ b/bigframes/session/direct_gbq_execution.py @@ -13,182 +13,73 @@ # limitations under the License. from __future__ import annotations -import asyncio -from typing import Literal, Mapping, Optional, Tuple +from typing import Literal, Optional, Tuple -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 -from google.cloud import bigquery -import bigframes -import bigframes.core.compile -import bigframes.core.events +from bigframes.core import compile, nodes +from bigframes.core.compile import sqlglot +from bigframes.session import executor, semi_executor import bigframes.session._io.bigquery as bq_io -import bigframes.session.metrics -from bigframes import exceptions as bfe -from bigframes.core import bq_data, compile, nodes -from bigframes.core.compile.configs import CompileRequest -from bigframes.session import execution_spec, executor, semi_executor - -_WRITE_DISPOSITIONS = { - "fail": bigquery.WriteDisposition.WRITE_EMPTY, - "replace": bigquery.WriteDisposition.WRITE_TRUNCATE, - "append": bigquery.WriteDisposition.WRITE_APPEND, -} +# 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, - bqstoragereadclient: google.cloud.bigquery_storage_v1.BigQueryReadClient, - *, - publisher: bigframes.core.events.Publisher, - compiler: Literal["ibis", "sqlglot"] = "sqlglot", - metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, - labels: Mapping[str, str] = {}, + self, bqclient: bigquery.Client, compiler: Literal["ibis", "sqlglot"] = "ibis" ): self.bqclient = bqclient - self._compiler_name = compiler - self._bqstoragereadclient = bqstoragereadclient - self._publisher = publisher - self._metrics = metrics - self._labels = labels + self._compile_fn = ( + compile.compile_sql + if compiler == "ibis" + else sqlglot.SQLGlotCompiler()._compile_sql + ) - async def execute( + def execute( self, plan: nodes.BigFrameNode, - spec: execution_spec.ExecutionSpec, + ordered: bool, + peek: Optional[int] = None, ) -> executor.ExecuteResult: """Just execute whatever plan as is, without further caching or decomposition.""" - compiled = compile.compile_sql( - CompileRequest( - plan, - sort_rows=spec.ordered, - peek_count=spec.peek, - ), - compiler_name=self._compiler_name, - ) - job_config = bigquery.QueryJobConfig() - dest_spec = spec.destination_spec - cluster_cols = None - can_skip_job = True - if isinstance(dest_spec, execution_spec.TableOutputSpec): - job_config.destination = dest_spec.table - job_config.write_disposition = _WRITE_DISPOSITIONS[dest_spec.if_exists] - cluster_cols = dest_spec.cluster_cols if dest_spec.cluster_cols else None - job_config.clustering_fields = cluster_cols - can_skip_job = False - elif isinstance(dest_spec, execution_spec.EphemeralTableSpec): - # Need destination table, but jobless execution might not create a destination table - can_skip_job = False - elif dest_spec is not None: - raise ValueError( - f"Direct GBQ Executor does not support destination: {dest_spec}" - ) + # TODO(swast): plumb through the api_name of the user-facing api that + # caused this query. - job_config.labels["bigframes-dtypes"] = compiled.encoded_type_refs - if self._labels: - job_config.labels.update(self._labels) - if spec.bigquery_config is not None: - if spec.bigquery_config.extra_query_labels: - job_config.labels.update(spec.bigquery_config.extra_query_labels) - if spec.bigquery_config.maximum_bytes_billed is not None: - job_config.maximum_bytes_billed = ( - spec.bigquery_config.maximum_bytes_billed - ) + compiled = self._compile_fn( + compile.CompileRequest(plan, sort_rows=ordered, peek_count=peek) + ) - iterator, query_job = await asyncio.to_thread( - self._run_execute_query, + iterator, query_job = self._run_execute_query( sql=compiled.sql, - job_config=job_config, - query_with_job=(not can_skip_job), - session=plan.session, - cell_execution_count=spec.cell_execution_count, ) - result_bq_data = None - if query_job and query_job.destination: - dst = query_job.destination - result_bq_data = bq_data.BigqueryDataSource( - table=bq_data.GbqNativeTable.from_ref_and_schema( - dst, - tuple(compiled.sql_schema), - cluster_cols=cluster_cols or (), - location=iterator.location or self.bqclient.location, - table_type="TABLE", - ), - schema=plan.schema, - ordering=compiled.row_order, - n_rows=iterator.total_rows, - ) - 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() + return executor.ExecuteResult( + _arrow_batches=iterator.to_arrow_iterable(), + schema=plan.schema, + query_job=query_job, + total_rows=iterator.total_rows, ) - if (isinstance(dest_spec, execution_spec.EphemeralTableSpec)) or ( - (result_bq_data is not None) and not result_mostly_cached - ): - assert result_bq_data is not None, "expected result table but none exists" - 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 plan.schema.names), - ) - else: - return executor.LocalExecuteResult( - data=iterator.to_arrow().select(plan.schema.names), - bf_schema=plan.schema, - execution_metadata=execution_metadata, - ) - def _run_execute_query( self, sql: str, - job_config: bq_job.QueryJobConfig, - query_with_job: bool, - session, - cell_execution_count: Optional[int] = None, + job_config: Optional[bq_job.QueryJobConfig] = None, ) -> Tuple[bq_table.RowIterator, Optional[bigquery.QueryJob]]: """ Starts BigQuery query job and waits for results. """ - try: - if query_with_job: - return bq_io.start_query_with_job( - self.bqclient, - sql, - job_config=job_config, - metrics=self._metrics, - publisher=self._publisher, - session=session, - cell_execution_count=cell_execution_count, - ) - else: - return ( - bq_io.start_query_job_optional( - self.bqclient, - sql, - job_config=job_config, - metrics=self._metrics, - publisher=self._publisher, - session=session, - cell_execution_count=cell_execution_count, - ), - None, - ) - 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 + 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, + ) diff --git a/bigframes/session/dry_runs.py b/bigframes/session/dry_runs.py index 03688b38cd3..51e8e72c9a1 100644 --- a/bigframes/session/dry_runs.py +++ b/bigframes/session/dry_runs.py @@ -14,18 +14,15 @@ from __future__ import annotations import copy -from typing import Any, Dict, List, Sequence, Union +from typing import Any, Dict, List, Sequence -import pandas from google.cloud import bigquery +import pandas from bigframes import dtypes -from bigframes.core import bigframe_node, bq_data, nodes -def get_table_stats( - table: Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable], -) -> pandas.Series: +def get_table_stats(table: bigquery.Table) -> pandas.Series: values: List[Any] = [] index: List[Any] = [] @@ -34,7 +31,7 @@ def get_table_stats( values.append(False) # Populate column and index types - col_dtypes = dtypes.bf_type_from_type_kind(table.physical_schema) + col_dtypes = dtypes.bf_type_from_type_kind(table.schema) index.append("columnCount") values.append(len(col_dtypes)) index.append("columnDtypes") @@ -42,22 +39,17 @@ def get_table_stats( # Add raw BQ schema index.append("bigquerySchema") - values.append(table.physical_schema) + values.append(table.schema) - index.append("numBytes") - values.append(table.metadata.numBytes) - index.append("numRows") - values.append(table.metadata.numRows) - index.append("location") - values.append(table.metadata.location) - index.append("type") - values.append(table.metadata.type) + for key in ("numBytes", "numRows", "location", "type"): + index.append(key) + values.append(table._properties[key]) index.append("creationTime") - values.append(table.metadata.created_time) + values.append(table.created) index.append("lastModifiedTime") - values.append(table.metadata.modified_time) + values.append(table.modified) return pandas.Series(values, index=index) @@ -94,26 +86,13 @@ 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 + return pandas.concat([s, get_query_stats(query_job)]) def get_query_stats( @@ -166,24 +145,4 @@ def get_query_stats( 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) + return pandas.Series(values, index=index) diff --git a/bigframes/session/execution_cache.py b/bigframes/session/execution_cache.py deleted file mode 100644 index ef4f324afce..00000000000 --- a/bigframes/session/execution_cache.py +++ /dev/null @@ -1,88 +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 __future__ import annotations - -import dataclasses -import weakref -from typing import Mapping, Optional - -from bigframes.core import bq_data, local_data, nodes - -SourceIdMapping = Mapping[str, str] - - -@dataclasses.dataclass(frozen=True) -class UploadedLocalData: - bq_source: bq_data.BigqueryDataSource - source_mapping: SourceIdMapping - - -class ExecutionCache: - def __init__(self): - # effectively two separate caches that don't interact - self._cached_executions: weakref.WeakKeyDictionary[ - nodes.BigFrameNode, bq_data.BigqueryDataSource - ] = weakref.WeakKeyDictionary() - # This upload cache is entirely independent of the plan cache. - self._uploaded_local_data: weakref.WeakKeyDictionary[ - local_data.ManagedArrowTable, - UploadedLocalData, - ] = weakref.WeakKeyDictionary() - - def subsitute_cached_subplans(self, root: nodes.BigFrameNode) -> nodes.BigFrameNode: - def replace_if_cached(node: nodes.BigFrameNode) -> nodes.BigFrameNode: - if node not in self._cached_executions: - return node - # 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 node.fields) - ) - bq_data = self._cached_executions[node] - cached_replacement = nodes.CachedTableNode( - source=bq_data, - scan_list=scan_list, - table_session=node.session, - original_node=node, - ) - assert node.schema == cached_replacement.schema - return cached_replacement - - return nodes.top_down(root, replace_if_cached) - - def cache_results_table( - self, - original_root: nodes.BigFrameNode, - data: bq_data.BigqueryDataSource, - ): - self._cached_executions[original_root] = data - - ## Local data upload caching - 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] = UploadedLocalData(bq_data, mapping) - - def get_uploaded_local_data( - self, local_data: local_data.ManagedArrowTable - ) -> Optional[UploadedLocalData]: - return self._uploaded_local_data.get(local_data) diff --git a/bigframes/session/execution_spec.py b/bigframes/session/execution_spec.py deleted file mode 100644 index 89de6eec902..00000000000 --- a/bigframes/session/execution_spec.py +++ /dev/null @@ -1,146 +0,0 @@ -# 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, Mapping, Optional, Union - -from google.cloud import bigquery - -from bigframes._config import ComputeOptions - - -@dataclasses.dataclass(frozen=True) -class BqComputeOptions: - enable_multi_query_execution: bool = True - maximum_bytes_billed: Optional[int] = None - extra_query_labels: tuple[tuple[str, str], ...] = () - - @classmethod - def from_compute_options(cls, compute_options: ComputeOptions) -> BqComputeOptions: - return cls( - enable_multi_query_execution=compute_options.enable_multi_query_execution, - maximum_bytes_billed=compute_options.maximum_bytes_billed, - extra_query_labels=tuple(compute_options.extra_query_labels.items()), - ) - - def push_labels(self, labels: Mapping[str, str]) -> BqComputeOptions: - return dataclasses.replace( - self, - extra_query_labels=tuple(labels.items()) + self.extra_query_labels, - ) - - -@dataclasses.dataclass(frozen=True) -class ExecutionSpec: - # destination for the result of the operation. Executor may also incidentally create other temporary tables for its own purposes. - destination_spec: Union[ - TableOutputSpec, GcsOutputSpec, EphemeralTableSpec, None - ] = None - # If set, the result will be truncated to the given number of rows. Which N rows is - # implementation dependent and not stable. - peek: Optional[int] = None - # Controls whether output iterator is ordered. Cannot be true if destination is not - # guaranteed to be ordered. - ordered: bool = False - # 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 - - # BigQuery specific options - bigquery_config: Optional[BqComputeOptions] = None - cell_execution_count: Optional[int] = None - - def with_bq_labels(self, labels: Mapping[str, str]) -> ExecutionSpec: - bq_config = self.bigquery_config or BqComputeOptions() - return dataclasses.replace(self, bigquery_config=bq_config.push_labels(labels)) - - def with_compute_options(self, compute_options: ComputeOptions) -> ExecutionSpec: - """ - Grabs the current global or thread-local config and binds it to the execution spec. - - Returns a new ExecutionSpec with the current configuration applied. - """ - new_bq_config = BqComputeOptions.from_compute_options(compute_options) - if self.bigquery_config: - # merge labels, new ComputeOptions takes priority for everything else - new_bq_config = new_bq_config.push_labels( - dict(self.bigquery_config.extra_query_labels) - ) - - cell_execution_count = self.cell_execution_count - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() - - return dataclasses.replace( - self, - bigquery_config=new_bq_config, - cell_execution_count=cell_execution_count, - ) - - -# Used internally by execution -@dataclasses.dataclass(frozen=True) -class EphemeralTableSpec: - """ - Specifies that the result of an operation should be a temporary table of some sort. - - No guarantees on lifetime, may be a session temp table, or a bq-created temp table with <24hr life. - - Used internally when results need temporary staging, because they are large (>10GB), or needed in subsequent operations. - """ - - pass - - -@dataclasses.dataclass(frozen=True) -class CacheSpec: - """ - Specifies that the result of an operation should be a session temp table. - The table will be automatically deleted after the session ends. - """ - - cluster_cols: tuple[ - str, ... - ] = () # if empty, will cluster using order key if ordering_key is set - # Controls ordering and whether extra columns are materialized to preserve ordering - # Any extra columns will be appended to the end of the schema. - # None: ordering may be discarded entirely (ordering metadata will still be provided if ordering is derivable from materialized columns) - # order_rows: the result iterator itself will be ordered. For gbq execution, result cannot exceed 10GB. - # order_key: the result set ordered by a key, may materialize extra columns. - # offsets_col: order the result set by an offsets column, materializes one extra column. - ordering: Literal["order_rows", "offsets_col", "order_key"] | None = None - - -@dataclasses.dataclass(frozen=True) -class TableOutputSpec: - """ - Specifies that the result of an operation should be exported to a specific named table. - - The executor is not responsible for managing lifecycle of the table. - """ - - 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 ba5ac60d74f..cc8f086f9f9 100644 --- a/bigframes/session/executor.py +++ b/bigframes/session/executor.py @@ -18,21 +18,17 @@ import dataclasses import functools import itertools -from typing import Iterator, Literal, Optional, Sequence, Union +from typing import Iterator, Literal, Mapping, Optional, Sequence, Union -import google.cloud.bigquery.table as bq_table +from google.cloud import bigquery import pandas as pd import pyarrow -import pyarrow as pa -from google.cloud import bigquery, bigquery_storage_v1 import bigframes import bigframes.core +from bigframes.core import pyarrow_utils import bigframes.core.schema -import bigframes.dtypes import bigframes.session._io.pandas as io_pandas -import bigframes.session.execution_spec as ex_spec -from bigframes.core import bq_data, local_data, pyarrow_utils _ROW_LIMIT_EXCEEDED_TEMPLATE = ( "Execution has downloaded {result_rows} rows so far, which exceeds the " @@ -41,39 +37,20 @@ ) -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) +@dataclasses.dataclass(frozen=True) +class ExecuteResult: + _arrow_batches: Iterator[pyarrow.RecordBatch] + schema: bigframes.core.schema.ArraySchema + query_job: Optional[bigquery.QueryJob] = None + total_bytes: Optional[int] = None + total_rows: Optional[int] = None @property def arrow_batches(self) -> Iterator[pyarrow.RecordBatch]: result_rows = 0 - for batch in self._batches: + for batch in self._arrow_batches: + batch = pyarrow_utils.cast_batch(batch, self.schema.to_pyarrow()) result_rows += batch.num_rows maximum_result_rows = bigframes.options.compute.maximum_result_rows @@ -88,7 +65,7 @@ def arrow_batches(self) -> Iterator[pyarrow.RecordBatch]: yield batch - def to_arrow_table(self, limit: Optional[int] = None) -> pyarrow.Table: + 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. @@ -97,30 +74,23 @@ def to_arrow_table(self, limit: Optional[int] = None) -> pyarrow.Table: peek_value = list(peek_it) # TODO: Enforce our internal schema on the table for consistency if len(peek_value) > 0: - batches = itertools.chain(peek_value, batches) # reconstruct - if limit: - batches = pyarrow_utils.truncate_pyarrow_iterable( - batches, max_results=limit - ) - return pyarrow.Table.from_batches(batches) + 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() + return self.schema.to_pyarrow().empty_table() - def to_pandas(self, limit: Optional[int] = None) -> pd.DataFrame: - return io_pandas.arrow_to_pandas(self.to_arrow_table(limit=limit), self._schema) + 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 - ) + 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 @@ -133,7 +103,7 @@ def to_pandas_batches( ) yield from map( - functools.partial(io_pandas.arrow_to_pandas, schema=self._schema), + functools.partial(io_pandas.arrow_to_pandas, schema=self.schema), batch_iter, ) @@ -149,148 +119,6 @@ def to_py_scalar(self): return column[0] -class ExecuteResult(abc.ABC): - @property - @abc.abstractmethod - def execution_metadata(self) -> ExecutionMetadata: ... - - @property - @abc.abstractmethod - def schema(self) -> bigframes.core.schema.ArraySchema: ... - - @abc.abstractmethod - def batches(self, sample_rate: Optional[float] = None) -> ResultsIterator: ... - - @property - def query_job(self) -> Optional[bigquery.QueryJob]: - return self.execution_metadata.query_job - - @property - def total_bytes_processed(self) -> Optional[int]: - return self.execution_metadata.bytes_processed - - -@dataclasses.dataclass(frozen=True) -class ExecutionMetadata: - query_job: Optional[bigquery.QueryJob] = None - bytes_processed: Optional[int] = None - - @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) - - -class LocalExecuteResult(ExecuteResult): - def __init__( - self, - data: pa.Table, - bf_schema: bigframes.core.schema.ArraySchema, - execution_metadata: ExecutionMetadata = ExecutionMetadata(), - ): - 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, sample_rate: Optional[float] = None) -> ResultsIterator: - return ResultsIterator( - iter(self._data.to_arrow(sample_rate=sample_rate)[1]), - self.schema, - self._data.metadata.row_count, - self._data.metadata.total_bytes, - ) - - -class EmptyExecuteResult(ExecuteResult): - def __init__( - self, - bf_schema: bigframes.core.schema.ArraySchema, - execution_metadata: ExecutionMetadata = ExecutionMetadata(), - ): - self._schema = 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._schema - - def batches(self, sample_rate: Optional[float] = None) -> ResultsIterator: - return ResultsIterator(iter([]), self.schema, 0, 0) - - -class BQTableExecuteResult(ExecuteResult): - def __init__( - self, - 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, - ): - 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, sample_rate: Optional[float] = None) -> ResultsIterator: - read_batches = bq_data.get_arrow_batches( - self._data, - [x[0] for x in self._selected_fields], - self._storage_client, - self._project_id, - sample_rate=sample_rate, - ) - arrow_batches: Iterator[pa.RecordBatch] = map( - functools.partial( - pyarrow_utils.rename_batch, names=list(self.schema.names) - ), - read_batches.iter, - ) - 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 - ) - - if self._data.sql_predicate: - approx_bytes = None - approx_rows = None - - return ResultsIterator(arrow_batches, self.schema, approx_rows, approx_bytes) - - @dataclasses.dataclass(frozen=True) class HierarchicalKey: columns: tuple[str, ...] @@ -300,7 +128,6 @@ class HierarchicalKey: class CacheConfig(abc.ABC): optimize_for: Union[Literal["auto", "head"], HierarchicalKey] = "auto" if_cached: Literal["reuse-strict", "reuse-any", "replace"] = "reuse-any" - enable_multi_query_execution: Optional[bool] = None class Executor(abc.ABC): @@ -320,16 +147,41 @@ def to_sql( """ raise NotImplementedError("to_sql not implemented for this executor") - @abc.abstractmethod def execute( self, array_value: bigframes.core.ArrayValue, - execution_spec: ex_spec.ExecutionSpec, + *, + ordered: bool = True, + use_explicit_destination: Optional[bool] = False, ) -> ExecuteResult: """ - Execute the ArrayValue. + Execute the ArrayValue, storing the result to a temporary session-owned table. + """ + raise NotImplementedError("execute not implemented for this executor") + + def export_gbq( + self, + array_value: bigframes.core.ArrayValue, + 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") + + def export_gcs( + self, + array_value: bigframes.core.ArrayValue, + 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 dry_run( self, array_value: bigframes.core.ArrayValue, ordered: bool = True @@ -341,6 +193,17 @@ def dry_run( """ raise NotImplementedError("dry_run not implemented for this executor") + def peek( + self, + array_value: bigframes.core.ArrayValue, + n_rows: int, + use_explicit_destination: Optional[bool] = False, + ) -> ExecuteResult: + """ + A 'peek' efficiently accesses a small number of rows in the dataframe. + """ + raise NotImplementedError("peek not implemented for this executor") + def cached( self, array_value: bigframes.core.ArrayValue, diff --git a/bigframes/session/iceberg.py b/bigframes/session/iceberg.py deleted file mode 100644 index 0d2539f5554..00000000000 --- a/bigframes/session/iceberg.py +++ /dev/null @@ -1,207 +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 __future__ import annotations - -import datetime -import json -import urllib.parse -from typing import List - -import google.auth.transport.requests -import google.cloud.bigquery as bq -import pyiceberg -import pyiceberg.schema -import pyiceberg.types -import requests -from pyiceberg.catalog import load_catalog - -from bigframes.core import bq_data - - -def get_table( - user_project_id: str, full_table_id: str, credentials -) -> bq_data.BiglakeIcebergTable: - table_parts = full_table_id.split(".") - if len(table_parts) != 4: - raise ValueError("Iceberg catalog table must contain exactly 4 parts") - - catalog_project_id, catalog_id, namespace, table = table_parts - - credentials.refresh(google.auth.transport.requests.Request()) - token = credentials.token - - base_uri = "https://biglake.googleapis.com/iceberg/v1/restcatalog" - - # Maybe can drop the pyiceberg dependency at some point, but parsing through raw schema json seems a bit painful - catalog = load_catalog( - f"{catalog_project_id}.{catalog_id}", - **{ - "uri": base_uri, - "header.x-goog-user-project": user_project_id, - "oauth2-server-uri": "https://oauth2.googleapis.com/token", - "token": token, - "warehouse": f"gs://{catalog_id}", - }, - ) - - response = requests.get( - f"{base_uri}/extensions/projects/{urllib.parse.quote(catalog_project_id, safe='')}/catalogs/{urllib.parse.quote(catalog_id, safe='')}", - headers={ - "Authorization": f"Bearer {credentials.token}", - "Content-Type": "application/json", - "header.x-goog-user-project": user_project_id, - }, - ) - response.raise_for_status() - location = _extract_location_from_catalog_extension_data(response) - - iceberg_table = catalog.load_table(f"{namespace}.{table}") - bq_schema = pyiceberg.schema.visit(iceberg_table.schema(), SchemaVisitor()) - # TODO: Handle physical layout to help optimize - # TODO: Use snapshot metadata to get row, byte counts - return bq_data.BiglakeIcebergTable( - catalog_project_id, - catalog_id, - namespace, - table, - physical_schema=bq_schema, # type: ignore - cluster_cols=(), - metadata=bq_data.TableMetadata( - location=location, - type="TABLE", - modified_time=datetime.datetime.fromtimestamp( - iceberg_table.metadata.last_updated_ms / 1000.0 - ), - ), - ) - - -def _extract_location_from_catalog_extension_data(data): - catalog_extension_metadata = json.loads(data.text) - storage_region = catalog_extension_metadata["storage-regions"][ - 0 - ] # assumption: exactly 1 region - replicas = tuple(item["region"] for item in catalog_extension_metadata["replicas"]) - return bq_data.GcsRegion(storage_region, replicas) - - -class SchemaVisitor(pyiceberg.schema.SchemaVisitorPerPrimitiveType[bq.SchemaField]): - # Override returns a tuple of fields instead of a single field, violating supertype signature but intentional for this visitor. - def schema( # type: ignore[override] - self, schema: pyiceberg.schema.Schema, struct_result: bq.SchemaField - ) -> tuple[bq.SchemaField, ...]: - return tuple(f for f in struct_result.fields) - - def struct( - self, struct: pyiceberg.types.StructType, field_results: List[bq.SchemaField] - ) -> bq.SchemaField: - return bq.SchemaField("", "RECORD", fields=field_results) - - def field( - self, field: pyiceberg.types.NestedField, field_result: bq.SchemaField - ) -> bq.SchemaField: - return bq.SchemaField( - field.name, - field_result.field_type, - mode=field_result.mode or "NULLABLE", - fields=field_result.fields, - ) - - def map( - self, - map_type: pyiceberg.types.MapType, - key_result: bq.SchemaField, - value_result: bq.SchemaField, - ) -> bq.SchemaField: - return bq.SchemaField("", "UNKNOWN") - - def list( - self, list_type: pyiceberg.types.ListType, element_result: bq.SchemaField - ) -> bq.SchemaField: - return bq.SchemaField( - "", element_result.field_type, mode="REPEATED", fields=element_result.fields - ) - - def visit_fixed(self, fixed_type: pyiceberg.types.FixedType) -> bq.SchemaField: - return bq.SchemaField("", "UNKNOWN") - - def visit_decimal( - self, decimal_type: pyiceberg.types.DecimalType - ) -> bq.SchemaField: - # BIGNUMERIC not supported in iceberg tables yet, so just assume numeric - return bq.SchemaField("", "NUMERIC") - - def visit_boolean( - self, boolean_type: pyiceberg.types.BooleanType - ) -> bq.SchemaField: - return bq.SchemaField("", "NUMERIC") - - def visit_integer( - self, integer_type: pyiceberg.types.IntegerType - ) -> bq.SchemaField: - return bq.SchemaField("", "INTEGER") - - def visit_long(self, long_type: pyiceberg.types.LongType) -> bq.SchemaField: - return bq.SchemaField("", "INTEGER") - - def visit_float(self, float_type: pyiceberg.types.FloatType) -> bq.SchemaField: - # 32-bit IEEE 754 floating point - return bq.SchemaField("", "FLOAT") - - def visit_double(self, double_type: pyiceberg.types.DoubleType) -> bq.SchemaField: - # 64-bit IEEE 754 floating point - return bq.SchemaField("", "FLOAT") - - def visit_date(self, date_type: pyiceberg.types.DateType) -> bq.SchemaField: - # Date encoded as an int - return bq.SchemaField("", "DATE") - - def visit_time(self, time_type: pyiceberg.types.TimeType) -> bq.SchemaField: - return bq.SchemaField("", "TIME") - - def visit_timestamp( - self, timestamp_type: pyiceberg.types.TimestampType - ) -> bq.SchemaField: - return bq.SchemaField("", "DATETIME") - - def visit_timestamp_ns( - self, timestamp_type: pyiceberg.types.TimestampNanoType - ) -> bq.SchemaField: - return bq.SchemaField("", "UNKNOWN") - - def visit_timestamptz( - self, timestamptz_type: pyiceberg.types.TimestamptzType - ) -> bq.SchemaField: - return bq.SchemaField("", "TIMESTAMP") - - def visit_timestamptz_ns( - self, timestamptz_ns_type: pyiceberg.types.TimestamptzNanoType - ) -> bq.SchemaField: - return bq.SchemaField("", "UNKNOWN") - - def visit_string(self, string_type: pyiceberg.types.StringType) -> bq.SchemaField: - return bq.SchemaField("", "STRING") - - def visit_uuid(self, uuid_type: pyiceberg.types.UUIDType) -> bq.SchemaField: - return bq.SchemaField("", "UNKNOWN") - - def visit_unknown( - self, unknown_type: pyiceberg.types.UnknownType - ) -> bq.SchemaField: - """Type `UnknownType` can be promoted to any primitive type in V3+ tables per the Iceberg spec.""" - return bq.SchemaField("", "UNKNOWN") - - def visit_binary(self, binary_type: pyiceberg.types.BinaryType) -> bq.SchemaField: - return bq.SchemaField("", "BINARY") diff --git a/bigframes/session/loader.py b/bigframes/session/loader.py index 43f45a500f0..65007013241 100644 --- a/bigframes/session/loader.py +++ b/bigframes/session/loader.py @@ -14,79 +14,51 @@ 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 -import warnings from typing import ( - IO, + cast, Dict, + Generator, Hashable, + IO, Iterable, - Iterator, List, Literal, Optional, + overload, Sequence, Tuple, - TypeVar, - Union, - cast, - overload, ) import bigframes_vendored.constants as constants import bigframes_vendored.pandas.io.gbq as third_party_pandas_gbq import google.api_core.exceptions +from google.cloud import bigquery_storage_v1 import google.cloud.bigquery import google.cloud.bigquery as bigquery -import google.cloud.bigquery.table +from google.cloud.bigquery_storage_v1 import types as bq_storage_types import pandas import pyarrow as pa -from google.cloud import bigquery_storage_v1 -from google.cloud.bigquery.job.load import LoadJob -from google.cloud.bigquery.job.query import QueryJob -from google.cloud.bigquery_storage_v1 import ( - types as bq_storage_types, -) -from google.cloud.bigquery_storage_v1 import ( - writer as bq_storage_writer, -) -import bigframes._tools -import bigframes._tools.strings +from bigframes.core import guid, identifiers, local_data, nodes, ordering, utils import bigframes.core as core import bigframes.core.blocks as blocks -import bigframes.core.events import bigframes.core.schema as schemata import bigframes.dtypes -import bigframes.exceptions as bfe import bigframes.formatting_helpers as formatting_helpers +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.iceberg import bigframes.session.metrics import bigframes.session.temporary_storage import bigframes.session.time as session_time -from bigframes.core import ( - bq_data, - guid, - identifiers, - local_data, - nodes, - ordering, - utils, -) -from bigframes.session import dry_runs # Avoid circular imports. if typing.TYPE_CHECKING: @@ -109,8 +81,6 @@ bigframes.dtypes.TIMEDELTA_DTYPE: "INTEGER", } -TABLE_TYPE = Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable] - def _to_index_cols( index_col: Iterable[str] | str | bigframes.enums.DefaultIndexKind = (), @@ -291,8 +261,6 @@ def __init__( 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 @@ -300,24 +268,14 @@ def __init__( 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[str, Tuple[datetime.datetime, TABLE_TYPE]] = {} + self._df_snapshot: Dict[ + bigquery.TableReference, 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() - self._threadpool = concurrent.futures.ThreadPoolExecutor( - max_workers=1, thread_name_prefix="bigframes-loader" - ) - - def read_data_async( - self, local_data: local_data.ManagedArrowTable, offsets_col: str - ) -> concurrent.futures.Future[bq_data.BigqueryDataSource]: - future = self._threadpool.submit( - self._load_data_or_write_data, local_data, offsets_col - ) - return future def read_pandas( self, @@ -361,45 +319,25 @@ def read_managed_data( source=gbq_source, scan_list=nodes.ScanList( tuple( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem( + identifiers.ColumnId(item.column), item.dtype, item.column + ) for item in data.schema.items ) ), session=self._session, ) - def _load_data_or_write_data( - self, - data: local_data.ManagedArrowTable, - offsets_col: str, - ) -> bq_data.BigqueryDataSource: - """Write local data into BigQuery using the local API if possible, - otherwise use the write API.""" - can_load = all( - _is_dtype_can_load(item.column, item.dtype) for item in data.schema.items - ) - if can_load: - return self.load_data(data, offsets_col=offsets_col) - else: - return self.write_data(data, offsets_col=offsets_col) - def load_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> bq_data.BigqueryDataSource: + ) -> nodes.BigqueryDataSource: """Load managed data into bigquery""" - cannot_load_columns = { - item.column: item.dtype - for item in data.schema.items - if not _is_dtype_can_load(item.column, item.dtype) - } - - if cannot_load_columns: - raise NotImplementedError( - f"Nested JSON types are currently unsupported for BigQuery Load API. " - f"Unsupported columns: {cannot_load_columns}. {constants.FEEDBACK_LINK}" - ) + + # 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) @@ -436,9 +374,8 @@ def load_data( 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) - return bq_data.BigqueryDataSource( - bq_data.GbqNativeTable.from_table(destination_table), - schema=schema_w_offsets, + return nodes.BigqueryDataSource( + nodes.GbqTable.from_table(destination_table), ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -447,17 +384,8 @@ def stream_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> bq_data.BigqueryDataSource: + ) -> nodes.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) ) @@ -471,28 +399,19 @@ def stream_data( ) 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}" - ) + for errors in self._bqclient.insert_rows( + load_table_destination, + rows_w_offsets, + selected_fields=bq_schema, + row_ids=map(str, itertools.count()), # 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.GbqNativeTable.from_table(destination_table), - schema=schema_w_offsets, + return nodes.BigqueryDataSource( + nodes.GbqTable.from_table(destination_table), ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -501,118 +420,52 @@ def write_data( self, data: local_data.ManagedArrowTable, offsets_col: str, - ) -> bq_data.BigqueryDataSource: - """Load managed data into BigQuery using multiple concurrent streams.""" + ) -> nodes.BigqueryDataSource: + """Load managed data into bigquery""" 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() - # 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) + requested_stream = bq_storage_types.stream.WriteStream() + requested_stream.type_ = bq_storage_types.stream.WriteStream.Type.COMMITTED # type: ignore - schema, all_batches = data.to_arrow( - offsets_col=offsets_col, - duration_type="int", - max_chunksize=rows_per_batch, + stream_request = bq_storage_types.CreateWriteStreamRequest( + parent=bq_table_ref.to_bqstorage(), write_stream=requested_stream ) - serialized_schema = schema.serialize().to_pybytes() - - def stream_worker( - work: Iterator[pa.RecordBatch], max_outstanding: int = 5 - ) -> 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 - ) - base_request = bq_storage_types.AppendRowsRequest( - write_stream=stream.name, - ) - base_request.arrow_rows.writer_schema.serialized_schema = serialized_schema + stream = self._write_client.create_write_stream(request=stream_request) - stream_manager = bq_storage_writer.AppendRowsStream( - client=self._write_client, initial_request_template=base_request + def request_gen() -> Generator[bq_storage_types.AppendRowsRequest, None, None]: + schema, batches = data.to_arrow( + offsets_col=offsets_col, duration_type="int" ) - stream_name = stream.name - current_offset = 0 - futures: list[bq_storage_writer.AppendRowsFuture] = [] - - for batch in work: - if len(futures) >= max_outstanding: - row_errors = futures.pop(0).result().row_errors - if row_errors: - raise ValueError( - f"Problem loading rows: {row_errors}. {constants.FEEDBACK_LINK}" - ) - - request = bq_storage_types.AppendRowsRequest(offset=current_offset) + offset = 0 + for batch in batches: + request = bq_storage_types.AppendRowsRequest( + write_stream=stream.name, offset=offset + ) + request.arrow_rows.writer_schema.serialized_schema = ( + schema.serialize().to_pybytes() + ) request.arrow_rows.rows.serialized_record_batch = ( batch.serialize().to_pybytes() ) + offset += batch.num_rows + yield request - futures.append(stream_manager.send(request)) - current_offset += batch.num_rows - - for future in futures: - row_errors = future.result().row_errors - if row_errors: - raise ValueError( - f"Problem loading rows: {row_errors}. {constants.FEEDBACK_LINK}" - ) - - stream_manager.close() - 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) + for response in self._write_client.append_rows(requests=request_gen()): + if response.row_errors: + raise ValueError( + f"Problem loading at least one row from DataFrame: {response.row_errors}. {constants.FEEDBACK_LINK}" ) - futures.append(future) - - for future in concurrent.futures.as_completed(futures): - stream_name = future.result() - stream_names.append(stream_name) + # This step isn't strictly necessary in COMMITTED mode, but avoids max active stream limits + response = self._write_client.finalize_write_stream(name=stream.name) + assert response.row_count == data.data.num_rows - # 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 - ) - response = self._write_client.batch_commit_write_streams(commit_request) - for error in response.stream_errors: - raise ValueError(f"Errors commiting stream {error}") - - result_table = bq_data.GbqNativeTable.from_ref_and_schema( - bq_table_ref, - schema=bq_schema, - cluster_cols=[offsets_col], - location=self._storage_manager.location, - table_type="TABLE", - ) - return bq_data.BigqueryDataSource( - result_table, - schema=schema_w_offsets, + destination_table = self._bqclient.get_table(bq_table_ref) + return nodes.BigqueryDataSource( + nodes.GbqTable.from_table(destination_table), ordering=ordering.TotalOrdering.from_offset_col(offsets_col), n_rows=data.metadata.row_count, ) @@ -625,9 +478,6 @@ def _start_generic_job(self, job: formatting_helpers.GenericJob): else: job.result() - if self._metrics is not None and isinstance(job, (QueryJob, LoadJob)): - self._metrics.count_job_stats(query_job=job) - @overload def read_gbq_table( # type: ignore[overload-overlap] self, @@ -648,8 +498,8 @@ def read_gbq_table( # type: ignore[overload-overlap] force_total_order: Optional[bool] = ..., n_rows: Optional[int] = None, index_col_in_columns: bool = False, - publish_execution: bool = True, - ) -> dataframe.DataFrame: ... + ) -> dataframe.DataFrame: + ... @overload def read_gbq_table( @@ -671,8 +521,8 @@ def read_gbq_table( force_total_order: Optional[bool] = ..., n_rows: Optional[int] = None, index_col_in_columns: bool = False, - publish_execution: bool = True, - ) -> pandas.Series: ... + ) -> pandas.Series: + ... def read_gbq_table( self, @@ -693,7 +543,6 @@ def read_gbq_table( 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. @@ -753,12 +602,8 @@ def read_gbq_table( 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._tools.strings import bigframes.dataframe as dataframe # --------------------------------- @@ -772,6 +617,10 @@ def read_gbq_table( _check_duplicates("columns", columns) + table_ref = google.cloud.bigquery.table.TableReference.from_string( + table_id, default_project=self._bqclient.project + ) + columns = list(columns) include_all_columns = columns is None or len(columns) == 0 filters = typing.cast(list, list(filters)) @@ -780,33 +629,31 @@ def read_gbq_table( # Fetch table metadata and validate # --------------------------------- - time_travel_timestamp, table = self._get_table_metadata( - table_id=table_id, - default_project=self._bqclient.project, + time_travel_timestamp, table = bf_read_gbq_table.get_table_metadata( + self._bqclient, + table_ref=table_ref, bq_time=self._clock.get_time(), + cache=self._df_snapshot, use_cache=use_cache, ) - if not bq_data.is_compatible( - table.metadata.location, self._storage_manager.location - ): + if table.location.casefold() != self._storage_manager.location.casefold(): raise ValueError( - f"Current session is in {self._storage_manager.location} but table '{table.get_full_id()}' is located in {table.metadata.location}" + f"Current session is in {self._storage_manager.location} but dataset '{table.project}.{table.dataset_id}' is located in {table.location}" ) - table_column_names = [field.name for field in table.physical_schema] + 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.physical_schema) + 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.physical_schema[: len_schema - len_names] + field.name for field in table.schema[: len_schema - len_names] ] + list(names) assert len_schema >= len_names @@ -831,7 +678,6 @@ def read_gbq_table( table=table, index_col=index_col, rename_to_schema=rename_to_schema, - default_index_type=self._default_index_type, ) _check_index_col_param( index_cols, @@ -847,23 +693,18 @@ def read_gbq_table( # Optionally, execute the 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) - ): + # 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. + if max_results is not None: # TODO(b/338111344): If we are running a query anyway, we might as # well generate ROW_NUMBER() at the same time. all_columns: Iterable[str] = ( itertools.chain(index_cols, columns) if columns else () ) query = bf_io_bigquery.to_query( - table.get_full_id(quoted=False), + table_id, columns=all_columns, sql_predicate=bf_io_bigquery.compile_filters(filters) if filters @@ -880,9 +721,6 @@ def read_gbq_table( columns=columns, 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 @@ -914,7 +752,6 @@ def read_gbq_table( filter_str, should_warn=True, should_dry_run=True, - publisher=self._publisher, ) # ---------------------------- @@ -927,31 +764,18 @@ 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, + # 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, ) - - # 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) + schema = schemata.ArraySchema.from_bq_table(table) + if not include_all_columns: + schema = schema.select(index_cols + columns) array_value = core.ArrayValue.from_table( table, - columns=selected_cols, + schema=schema, predicate=filter_str, at_time=time_travel_timestamp if enable_snapshot else None, primary_key=primary_key, @@ -1023,90 +847,6 @@ def read_gbq_table( df.sort_index() return df - def _get_table_metadata( - self, - *, - table_id: str, - default_project: Optional[str], - bq_time: datetime.datetime, - use_cache: bool = True, - ) -> Tuple[ - datetime.datetime, Union[bq_data.GbqNativeTable, bq_data.BiglakeIcebergTable] - ]: - """Get the table metadata, either from cache or via REST API.""" - - cached_table = self._df_snapshot.get(table_id) - if use_cache and cached_table is not None: - snapshot_timestamp, table = cached_table - - if bf_read_gbq_table.is_time_travel_eligible( - bqclient=self._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=self._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 - - if bf_read_gbq_table.is_information_schema(table_id): - client_table = bf_read_gbq_table.get_information_schema_metadata( - bqclient=self._bqclient, - table_id=table_id, - default_project=default_project, - ) - table = bq_data.GbqNativeTable.from_table(client_table) - elif bq_data.is_irc_table(table_id): - table = bigframes.session.iceberg.get_table( - self._bqclient.project, table_id, self._bqclient._credentials - ) - else: - table_ref = google.cloud.bigquery.table.TableReference.from_string( - table_id, default_project=default_project - ) - client_table = self._bqclient.get_table(table_ref) - table = bq_data.GbqNativeTable.from_table(client_table) - - # 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. - if (table.metadata.created_time is not None) and ( - table.metadata.created_time > bq_time - ): - bq_time = table.metadata.created_time - - cached_table = (bq_time, table) - self._df_snapshot[table_id] = cached_table - return cached_table - def load_file( self, filepath_or_buffer: str | IO["bytes"], @@ -1155,8 +895,9 @@ def read_gbq_query( # type: ignore[overload-overlap] filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[False] = ..., force_total_order: Optional[bool] = ..., - allow_large_results: bool, - ) -> dataframe.DataFrame: ... + allow_large_results: bool = ..., + ) -> dataframe.DataFrame: + ... @overload def read_gbq_query( @@ -1171,8 +912,9 @@ def read_gbq_query( filters: third_party_pandas_gbq.FiltersType = ..., dry_run: Literal[True] = ..., force_total_order: Optional[bool] = ..., - allow_large_results: bool, - ) -> pandas.Series: ... + allow_large_results: bool = ..., + ) -> pandas.Series: + ... def read_gbq_query( self, @@ -1186,7 +928,7 @@ def read_gbq_query( filters: third_party_pandas_gbq.FiltersType = (), dry_run: bool = False, force_total_order: Optional[bool] = None, - allow_large_results: bool, + allow_large_results: bool = True, ) -> dataframe.DataFrame | pandas.Series: configuration = _transform_read_gbq_configuration(configuration) @@ -1211,7 +953,6 @@ 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) @@ -1245,12 +986,6 @@ def read_gbq_query( 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 @@ -1265,7 +1000,7 @@ def read_gbq_query( configuration=configuration, ) query_job_for_metrics = query_job - rows: Optional[google.cloud.bigquery.table.RowIterator] = None + rows = None else: job_config = typing.cast( bigquery.QueryJobConfig, @@ -1298,78 +1033,38 @@ def read_gbq_query( 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. + # It's possible that there's no job and 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( + if rows is not None and destination is None: + return 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( + # If there was no destination table and we've made it this far, that + # means the query must have been DDL or DML. Return some job metadata, + # instead. + if not destination: + return 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 - - # 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( + return self.read_gbq_table( f"{destination.project}.{destination.dataset_id}.{destination.table_id}", index_col=index_col, columns=columns, use_cache=configuration["query"]["useQueryCache"], force_total_order=force_total_order, - n_rows=n_rows, - publish_execution=False, + n_rows=query_job.result().total_rows, # 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, @@ -1442,7 +1137,7 @@ def _prepare_job_config( 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_job + # Maybe this should be pushed down into start_query_with_client job_config.maximum_bytes_billed = ( bigframes.options.compute.maximum_bytes_billed ) @@ -1462,7 +1157,7 @@ def _start_query_with_job_optional( 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_job_optional( + rows, _ = bf_io_bigquery.start_query_with_client( self._bqclient, sql, job_config=job_config, @@ -1470,8 +1165,7 @@ def _start_query_with_job_optional( location=None, project=None, metrics=None, - publisher=self._publisher, - session=self._session, + query_with_job=False, ) return rows @@ -1488,7 +1182,7 @@ def _start_query_with_job( Do not execute dataframe through this API, instead use the executor. """ job_config = self._prepare_job_config(job_config) - _, query_job = bf_io_bigquery.start_query_with_job( + _, query_job = bf_io_bigquery.start_query_with_client( self._bqclient, sql, job_config=job_config, @@ -1496,8 +1190,7 @@ def _start_query_with_job( location=None, project=None, metrics=None, - publisher=self._publisher, - session=self._session, + query_with_job=True, ) return query_job @@ -1524,7 +1217,23 @@ def _transform_read_gbq_configuration(configuration: Optional[dict]) -> dict: return configuration -def _is_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype) -> bool: +def _has_json_arrow_type(arrow_type: pa.DataType) -> bool: + """ + Searches recursively for JSON array type within a PyArrow DataType. + """ + if arrow_type == bigframes.dtypes.JSON_ARROW_TYPE: + return True + if pa.types.is_list(arrow_type): + return _has_json_arrow_type(arrow_type.value_type) + if pa.types.is_struct(arrow_type): + for i in range(arrow_type.num_fields): + if _has_json_arrow_type(arrow_type.field(i).type): + return True + return False + return False + + +def _validate_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype): """ Determines whether a datatype is supported by bq load jobs. @@ -1532,41 +1241,18 @@ def _is_dtype_can_load(name: str, column_type: bigframes.dtypes.Dtype) -> bool: 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 True + return - if isinstance( - column_type, pandas.ArrowDtype - ) and bigframes.dtypes.contains_db_dtypes_json_arrow_type( + if isinstance(column_type, pandas.ArrowDtype) and _has_json_arrow_type( column_type.pyarrow_dtype ): - return False - - return True - - -# 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 + raise NotImplementedError( + f"Nested JSON types, found in column `{name}`: `{column_type}`', " + f"are currently unsupported for upload. {constants.FEEDBACK_LINK}" + ) diff --git a/bigframes/session/local_scan_executor.py b/bigframes/session/local_scan_executor.py index 22007ec5eb7..65f088e8a10 100644 --- a/bigframes/session/local_scan_executor.py +++ b/bigframes/session/local_scan_executor.py @@ -16,7 +16,7 @@ from typing import Optional from bigframes.core import bigframe_node, rewrite -from bigframes.session import execution_spec, executor, semi_executor +from bigframes.session import executor, semi_executor class LocalScanExecutor(semi_executor.SemiExecutor): @@ -24,20 +24,18 @@ class LocalScanExecutor(semi_executor.SemiExecutor): Executes plans reducible to a arrow table scan. """ - async def execute( + def execute( self, plan: bigframe_node.BigFrameNode, - execution_spec: execution_spec.ExecutionSpec, + ordered: bool, + peek: Optional[int] = None, ) -> Optional[executor.ExecuteResult]: - if execution_spec.destination_spec is not None: - return None - reduced_result = rewrite.try_reduce_to_local_scan(plan) if not reduced_result: return None node, limit = reduced_result - peek = execution_spec.peek + if limit is not None: if peek is None or limit < peek: peek = limit @@ -59,7 +57,10 @@ async def execute( 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, + return executor.ExecuteResult( + _arrow_batches=arrow_table.to_batches(), + schema=plan.schema, + query_job=None, + total_bytes=None, + total_rows=total_rows, ) diff --git a/bigframes/session/metrics.py b/bigframes/session/metrics.py index a9a444ecb38..8d43a83d730 100644 --- a/bigframes/session/metrics.py +++ b/bigframes/session/metrics.py @@ -15,161 +15,16 @@ from __future__ import annotations import dataclasses -import datetime import os -from typing import Any, Mapping, Optional, Tuple, Union +from typing import Optional, Tuple import google.cloud.bigquery as bigquery +import google.cloud.bigquery.job as bq_job import google.cloud.bigquery.table as bq_table -from google.cloud.bigquery.job.load import LoadJob -from google.cloud.bigquery.job.query import QueryJob LOGGING_NAME_ENV_VAR = "BIGFRAMES_PERFORMANCE_LOG_NAME" -@dataclasses.dataclass -class JobMetadata: - job_id: Optional[str] = None - query_id: Optional[str] = None - location: Optional[str] = None - project: Optional[str] = None - creation_time: Optional[datetime.datetime] = None - start_time: Optional[datetime.datetime] = None - end_time: Optional[datetime.datetime] = None - duration_seconds: Optional[float] = None - status: Optional[str] = None - total_bytes_processed: Optional[int] = None - total_slot_ms: Optional[int] = None - job_type: Optional[str] = None - error_result: Optional[Mapping[str, Any]] = None - cached: Optional[bool] = None - job_url: Optional[str] = None - query: Optional[str] = None - destination_table: Optional[str] = None - source_uris: Optional[list[str]] = None - input_files: Optional[int] = None - input_bytes: Optional[int] = None - output_rows: Optional[int] = None - source_format: Optional[str] = None - cell_execution_count: Optional[int] = None - - @classmethod - def from_job( - cls, - query_job: Union[QueryJob, LoadJob], - exec_seconds: Optional[float] = None, - cell_execution_count: Optional[int] = None, - ) -> "JobMetadata": - query_text = getattr(query_job, "query", None) - if query_text and len(query_text) > 1024: - query_text = query_text[:1021] + "..." - - job_id = getattr(query_job, "job_id", None) - job_url = None - if job_id: - job_url = ( - f"https://console.cloud.google.com/bigquery?" - f"project={query_job.project}&j=bq:{query_job.location}:" - f"{job_id}&page=queryresults" - ) - - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() - - metadata = cls( - job_id=query_job.job_id, - location=query_job.location, - project=query_job.project, - creation_time=query_job.created, - start_time=query_job.started, - end_time=query_job.ended, - duration_seconds=exec_seconds, - status=query_job.state, - job_type=query_job.job_type, - error_result=query_job.error_result, - query=query_text, - job_url=job_url, - cell_execution_count=cell_execution_count, - ) - if isinstance(query_job, QueryJob): - metadata.cached = getattr(query_job, "cache_hit", None) - metadata.destination_table = ( - str(query_job.destination) if query_job.destination else None - ) - metadata.total_bytes_processed = getattr( - query_job, "total_bytes_processed", None - ) - metadata.total_slot_ms = getattr(query_job, "slot_millis", None) - elif isinstance(query_job, LoadJob): - metadata.output_rows = getattr(query_job, "output_rows", None) - metadata.input_files = getattr(query_job, "input_files", None) - metadata.input_bytes = getattr(query_job, "input_bytes", None) - metadata.destination_table = ( - str(query_job.destination) - if getattr(query_job, "destination", None) - else None - ) - if getattr(query_job, "source_uris", None): - metadata.source_uris = list(query_job.source_uris) - if query_job.configuration and hasattr( - query_job.configuration, "source_format" - ): - metadata.source_format = query_job.configuration.source_format - - return metadata - - @classmethod - def from_row_iterator( - cls, - row_iterator: bq_table.RowIterator, - exec_seconds: Optional[float] = None, - cell_execution_count: Optional[int] = None, - ) -> "JobMetadata": - query_text = getattr(row_iterator, "query", None) - if query_text and len(query_text) > 1024: - query_text = query_text[:1021] + "..." - - job_id = getattr(row_iterator, "job_id", None) - job_url = None - if job_id: - project = getattr(row_iterator, "project", "") - location = getattr(row_iterator, "location", "") - job_url = ( - f"https://console.cloud.google.com/bigquery?" - f"project={project}&j=bq:{location}:{job_id}&page=queryresults" - ) - - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() - - # fmt: off - return cls( - job_id=job_id, - query_id=getattr(row_iterator, "query_id", None), - location=getattr(row_iterator, "location", None), - project=getattr(row_iterator, "project", None), - creation_time=getattr(row_iterator, "created", None), - start_time=getattr(row_iterator, "started", None), - end_time=getattr(row_iterator, "ended", None), - duration_seconds=exec_seconds, - status="DONE", - total_bytes_processed=getattr( - row_iterator, "total_bytes_processed", None - ), - total_slot_ms=getattr(row_iterator, "slot_millis", None), - job_type="query", - cached=getattr(row_iterator, "cache_hit", None), - query=query_text, - job_url=job_url, - cell_execution_count=cell_execution_count, - ) - # fmt: on - - @dataclasses.dataclass class ExecutionMetrics: execution_count: int = 0 @@ -177,24 +32,17 @@ class ExecutionMetrics: bytes_processed: int = 0 execution_secs: float = 0 query_char_count: int = 0 - jobs: list[JobMetadata] = dataclasses.field(default_factory=list) - # fmt: off def count_job_stats( self, - query_job: Optional[Union[QueryJob, LoadJob]] = None, + query_job: Optional[bq_job.QueryJob] = None, row_iterator: Optional[bq_table.RowIterator] = None, - *, - cell_execution_count: Optional[int] = 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 - ) + # 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) @@ -209,126 +57,41 @@ def count_job_stats( self.slot_millis += slot_millis self.execution_secs += exec_seconds - self.jobs.append( - JobMetadata.from_row_iterator( - row_iterator, - exec_seconds=exec_seconds, - cell_execution_count=cell_execution_count, - ) - ) - - elif ( - isinstance(query_job, QueryJob) - and query_job.configuration.dry_run - ): - query_char_count = len(getattr(query_job, "query", "")) + 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. + # TODO(tswast): Pass None after making benchmark publishing robust to missing data. bytes_processed = 0 slot_millis = 0 exec_seconds = 0.0 - elif isinstance(query_job, bigquery.QueryJob): - if (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 - - metadata = JobMetadata.from_job( - query_job, - exec_seconds=exec_seconds, - cell_execution_count=cell_execution_count, - ) - self.jobs.append(metadata) - - else: + elif (stats := get_performance_stats(query_job)) is not None: + query_char_count, bytes_processed, slot_millis, exec_seconds = stats self.execution_count += 1 - duration = ( - (query_job.ended - query_job.created).total_seconds() - if query_job.ended and query_job.created - else None - ) - self.jobs.append( - JobMetadata.from_job( - query_job, - exec_seconds=duration, - cell_execution_count=cell_execution_count, - ) - ) - - # For pytest runs only, log information about the query job - # to a file in order to create a performance report. - if ( - isinstance(query_job, bigquery.QueryJob) - and not query_job.configuration.dry_run - ): - stats = get_performance_stats(query_job) - if stats: - write_stats_to_disk( - query_char_count=stats[0], - bytes_processed=stats[1], - slot_millis=stats[2], - exec_seconds=stats[3], - ) - elif row_iterator is not None: - 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.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, ) - # fmt: on - - def on_event(self, envelope: Any): - try: - import bigframes.core.events - from bigframes.session.executor import LocalExecuteResult - except ImportError: - return - - # Publisher.publish automatically wraps raw Event objects in an - # EventEnvelope, ensuring subscribers receive a consistent contract. - assert isinstance(envelope, bigframes.core.events.EventEnvelope) - event = envelope.event - cell_execution_count = envelope.cell_execution_count - - if isinstance(event, bigframes.core.events.ExecutionFinished): - if event.result and isinstance(event.result, LocalExecuteResult): - self.execution_count += 1 - bytes_processed = event.result.total_bytes_processed or 0 - self.bytes_processed += bytes_processed - if cell_execution_count is None: - from bigframes.core.utils import get_ipython_execution_count - - cell_execution_count = get_ipython_execution_count() + 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 - metadata = JobMetadata( - job_type="polars", - status="DONE", - total_bytes_processed=bytes_processed, - cell_execution_count=cell_execution_count, - ) - self.jobs.append(metadata) + 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( diff --git a/bigframes/session/polars_executor.py b/bigframes/session/polars_executor.py index f757de130ce..b93d31d2554 100644 --- a/bigframes/session/polars_executor.py +++ b/bigframes/session/polars_executor.py @@ -14,27 +14,21 @@ from __future__ import annotations import itertools -from typing import TYPE_CHECKING, Optional +from typing import Optional, TYPE_CHECKING +import pyarrow as pa + +from bigframes.core import array_value, bigframe_node, expression, local_data, nodes import bigframes.operations -from bigframes.core import ( - agg_expressions, - array_value, - bigframe_node, - expression, - nodes, -) 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 execution_spec, executor, semi_executor +from bigframes.session import executor, semi_executor if TYPE_CHECKING: import polars as pl @@ -66,21 +60,10 @@ 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.CeilOp, - numeric_ops.FloorOp, numeric_ops.FloorDivOp, numeric_ops.ModOp, generic_ops.AsTypeOp, @@ -105,9 +88,6 @@ agg_ops.SumOp, agg_ops.MeanOp, agg_ops.CountOp, - agg_ops.VarOp, - agg_ops.PopVarOp, - agg_ops.StdOp, ) @@ -121,8 +101,8 @@ 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 type(expr.op) not in _COMPATIBLE_AGG_OPS: + if isinstance(expr, expression.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): @@ -137,29 +117,41 @@ def __init__(self): self._compiler = PolarsCompiler() - async def execute( + def execute( self, plan: bigframe_node.BigFrameNode, - execution_spec: execution_spec.ExecutionSpec, + ordered: bool, + peek: Optional[int] = None, ) -> Optional[executor.ExecuteResult]: if not self._can_execute(plan): return None - if execution_spec.destination_spec is not None: - 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 execution_spec.peek is not None: - lazy_frame = lazy_frame.limit(execution_spec.peek) - pl_df = await lazy_frame.collect_async() - pa_table = pl_df.to_arrow() - return executor.LocalExecuteResult( - data=pa_table, - bf_schema=plan.schema, + if peek is not None: + lazy_frame = lazy_frame.limit(peek) + pa_table = lazy_frame.collect().to_arrow() + return executor.ExecuteResult( + _arrow_batches=iter(map(self._adapt_batch, pa_table.to_batches())), + schema=plan.schema, + total_bytes=pa_table.nbytes, + total_rows=pa_table.num_rows, ) def _can_execute(self, plan: bigframe_node.BigFrameNode): return all(_is_node_polars_executable(node) for node in plan.unique_nodes()) + + def _adapt_array(self, array: pa.Array) -> pa.Array: + target_type = local_data.logical_type_replacements(array.type) + if target_type != array.type: + # Safe is false to handle weird polars decimal scaling + return array.cast(target_type, safe=False) + return array + + def _adapt_batch(self, batch: pa.RecordBatch) -> pa.RecordBatch: + new_arrays = [self._adapt_array(arr) for arr in batch.columns] + return pa.RecordBatch.from_arrays(new_arrays, names=batch.column_names) diff --git a/bigframes/session/proxy_executor.py b/bigframes/session/proxy_executor.py deleted file mode 100644 index f6c914790cb..00000000000 --- a/bigframes/session/proxy_executor.py +++ /dev/null @@ -1,188 +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 __future__ import annotations - -import uuid -import warnings -from typing import Optional - -import google.cloud.bigquery as bigquery -import google.cloud.exceptions - -import bigframes.core -import bigframes.functions._function_session as bff_session -from bigframes import exceptions as bfe -from bigframes.session import ( - bq_caching_executor, - execution_cache, - execution_spec, - executor, - loader, - temporary_storage, -) - -_COMPILER_LABEL_KEY = "bigframes-compiler" - - -class DualCompilerProxyExecutor(executor.Executor): - """ - Used to rollout new compiler implementation. - """ - - def __init__( - self, - bqclient: bigquery.Client, - storage_manager: temporary_storage.TemporaryStorageManager, - bqstoragereadclient: google.cloud.bigquery_storage_v1.BigQueryReadClient, - loader: loader.GbqDataLoader, - *, - metrics: Optional[bigframes.session.metrics.ExecutionMetrics] = None, - enable_polars_execution: bool = False, - publisher: bigframes.core.events.Publisher, - function_manager: bff_session.FunctionSession, - labels: tuple[tuple[str, str], ...] = (), - ): - self._enable_polars_execution = enable_polars_execution - shared_cache = execution_cache.ExecutionCache() - self._ibis_executor = bq_caching_executor.BigQueryCachingExecutor( - bqclient, - storage_manager, - bqstoragereadclient, - loader, - metrics=metrics, - enable_polars_execution=self._enable_polars_execution, - publisher=publisher, - labels=labels, - cache=shared_cache, - compiler_name="ibis", - function_manager=function_manager, - ) - self._sqlglot_executor = bq_caching_executor.BigQueryCachingExecutor( - bqclient, - storage_manager, - bqstoragereadclient, - loader, - metrics=metrics, - enable_polars_execution=self._enable_polars_execution, - publisher=publisher, - labels=labels, - cache=shared_cache, - compiler_name="sqlglot", - function_manager=function_manager, - ) - - def to_sql( - self, - array_value: bigframes.core.ArrayValue, - offset_column: Optional[str] = None, - ordered: bool = False, - enable_cache: bool = True, - ) -> str: - """ - Convert an ArrayValue to a sql query that will yield its value. - """ - compiler_option = bigframes.options.experiments.sql_compiler - # Use ibis unless sqlglot explicitly selected, since we can't handle errors resulting - # from use of the sql produced by this method. - if compiler_option == "experimental": - return self._sqlglot_executor.to_sql( - array_value, - offset_column=offset_column, - ordered=ordered, - enable_cache=enable_cache, - ) - # stable or legacy use ibis - # TODO(b/510408650): Use sqlglot by default. - return self._ibis_executor.to_sql( - array_value, - offset_column=offset_column, - ordered=ordered, - enable_cache=enable_cache, - ) - - def execute( - self, - array_value: bigframes.core.ArrayValue, - execution_spec: execution_spec.ExecutionSpec, - ) -> executor.ExecuteResult: - compiler_option = bigframes.options.experiments.sql_compiler - if compiler_option == "legacy": - return self._ibis_executor.execute( - array_value, - execution_spec.with_bq_labels({_COMPILER_LABEL_KEY: "ibis"}), - ) - elif compiler_option == "experimental": - return self._sqlglot_executor.execute( - array_value, - execution_spec.with_bq_labels({_COMPILER_LABEL_KEY: "sqlglot"}), - ) - else: # stable - correlation_id = f"{uuid.uuid1().hex[:12]}" - try: - return self._sqlglot_executor.execute( - array_value, - execution_spec.with_bq_labels( - {_COMPILER_LABEL_KEY: f"sqlglot-{correlation_id}"} - ), - ) - except Exception as e: - msg = bfe.format_message( - f"Compiler ID {correlation_id}: Exception on sqlglot. " - f"Falling back to ibis. Details: {e}" - ) - warnings.warn(msg, category=UserWarning) - return self._ibis_executor.execute( - array_value, - execution_spec.with_bq_labels( - {_COMPILER_LABEL_KEY: f"ibis-{correlation_id}"} - ), - ) - - 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. - """ - # TODO(b/510408650): Use sqlglot for dry runs when sqlglot has been validated. - return self._ibis_executor.dry_run(array_value, ordered=ordered) - - def cached( - self, - array_value: bigframes.core.ArrayValue, - *, - config: executor.CacheConfig, - ) -> None: - compiler_option = bigframes.options.experiments.sql_compiler - if compiler_option == "legacy": - return self._ibis_executor.cached(array_value, config=config) - elif compiler_option == "experimental": - return self._sqlglot_executor.cached(array_value, config=config) - else: # stable - correlation_id = f"{uuid.uuid1().hex[:12]}" - try: - return self._sqlglot_executor.cached(array_value, config=config) - except Exception as e: - msg = bfe.format_message( - f"Compiler ID {correlation_id}: Exception on sqlglot. " - f"Falling back to ibis. Details: {e}" - ) - warnings.warn(msg, category=UserWarning) - return self._ibis_executor.cached( - array_value, - config=config, - ) diff --git a/bigframes/session/read_api_execution.py b/bigframes/session/read_api_execution.py index fff8022e40a..037fde011f1 100644 --- a/bigframes/session/read_api_execution.py +++ b/bigframes/session/read_api_execution.py @@ -13,12 +13,13 @@ # limitations under the License. from __future__ import annotations -from typing import Optional +from typing import Any, Iterator, Optional from google.cloud import bigquery_storage_v1 +import pyarrow as pa -from bigframes.core import bigframe_node, bq_data, nodes, rewrite -from bigframes.session import execution_spec, executor, semi_executor +from bigframes.core import bigframe_node, nodes, pyarrow_utils, rewrite +from bigframes.session import executor, semi_executor class ReadApiSemiExecutor(semi_executor.SemiExecutor): @@ -27,47 +28,90 @@ class ReadApiSemiExecutor(semi_executor.SemiExecutor): """ def __init__( - self, - bqstoragereadclient: bigquery_storage_v1.BigQueryReadClient, - project: str, + self, bqstoragereadclient: bigquery_storage_v1.BigQueryReadClient, project: str ): self.bqstoragereadclient = bqstoragereadclient self.project = project - async def execute( + def execute( self, plan: bigframe_node.BigFrameNode, - execution_spec: execution_spec.ExecutionSpec, + ordered: bool, + peek: Optional[int] = None, ) -> Optional[executor.ExecuteResult]: - if execution_spec.destination_spec is not None: - return None - - adapt_result = self._try_adapt_plan(plan, execution_spec.ordered) + adapt_result = self._try_adapt_plan(plan, ordered) if not adapt_result: return None node, limit = adapt_result - if node.explicitly_ordered and execution_spec.ordered: - return None - - if not isinstance(node.source.table, bq_data.GbqNativeTable): - return None - - if not node.source.table.is_physically_stored: + if node.explicitly_ordered and ordered: return None - peek = execution_spec.peek 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 - ], + import google.cloud.bigquery_storage_v1.types as bq_storage_types + from google.protobuf import timestamp_pb2 + + bq_table = node.source.table.get_table_ref() + read_options: dict[str, Any] = { + "selected_fields": [item.source_id for item in node.scan_list.items] + } + if node.source.sql_predicate: + read_options["row_restriction"] = node.source.sql_predicate + read_options = bq_storage_types.ReadSession.TableReadOptions(**read_options) + + table_mod_options = {} + if node.source.at_time: + snapshot_time = timestamp_pb2.Timestamp() + snapshot_time.FromDatetime(node.source.at_time) + table_mod_options["snapshot_time"] = snapshot_time = snapshot_time + table_mods = bq_storage_types.ReadSession.TableModifiers(**table_mod_options) + + requested_session = bq_storage_types.stream.ReadSession( + table=bq_table.to_bqstorage(), + data_format=bq_storage_types.DataFormat.ARROW, + read_options=read_options, + table_modifiers=table_mods, + ) + # Single stream to maintain ordering + request = bq_storage_types.CreateReadSessionRequest( + parent=f"projects/{self.project}", + read_session=requested_session, + max_stream_count=1, + ) + session = self.bqstoragereadclient.create_read_session(request=request) + + if not session.streams: + batches: Iterator[pa.RecordBatch] = iter([]) + else: + reader = self.bqstoragereadclient.read_rows(session.streams[0].name) + rowstream = reader.rows() + + def process_page(page): + pa_batch = page.to_arrow() + pa_batch = pa_batch.select( + [item.source_id for item in node.scan_list.items] + ) + return pa.RecordBatch.from_arrays( + pa_batch.columns, names=[id.sql for id in node.ids] + ) + + batches = map(process_page, rowstream.pages) + + if peek: + batches = pyarrow_utils.truncate_pyarrow_iterable(batches, max_results=peek) + + rows = node.source.n_rows + if peek and rows: + rows = min(peek, rows) + + return executor.ExecuteResult( + _arrow_batches=batches, + schema=plan.schema, + query_job=None, + total_bytes=None, + total_rows=rows, ) def _try_adapt_plan( diff --git a/bigframes/session/semi_executor.py b/bigframes/session/semi_executor.py index 1f827ce9d93..c41d7c96d3e 100644 --- a/bigframes/session/semi_executor.py +++ b/bigframes/session/semi_executor.py @@ -15,7 +15,7 @@ from typing import Optional from bigframes.core import bigframe_node -from bigframes.session import execution_spec, executor +from bigframes.session import executor # Unstable interface, in development @@ -24,9 +24,10 @@ class SemiExecutor(abc.ABC): A semi executor executes a subset of possible plans, returns None for unsupported plans. """ - async def execute( + def execute( self, plan: bigframe_node.BigFrameNode, - execution_spec: execution_spec.ExecutionSpec, + ordered: bool, + peek: Optional[int] = None, ) -> Optional[executor.ExecuteResult]: raise NotImplementedError("execute not implemented for this executor") diff --git a/bigframes/session/temporary_storage.py b/bigframes/session/temporary_storage.py index 42617c8f6c1..0c2a36f3fed 100644 --- a/bigframes/session/temporary_storage.py +++ b/bigframes/session/temporary_storage.py @@ -19,11 +19,14 @@ class TemporaryStorageManager(Protocol): @property - def location(self) -> str: ... + def location(self) -> str: + ... def create_temp_table( self, schema: Sequence[bigquery.SchemaField], cluster_cols: Sequence[str] = [] - ) -> bigquery.TableReference: ... + ) -> bigquery.TableReference: + ... # implementations should be robust to repeatedly closing - def close(self) -> None: ... + def close(self) -> None: + ... diff --git a/bigframes/session/time.py b/bigframes/session/time.py index 1452b2952dc..bef4bbc17f5 100644 --- a/bigframes/session/time.py +++ b/bigframes/session/time.py @@ -15,7 +15,7 @@ import datetime import threading import time -from typing import Optional, cast +from typing import cast, Optional import google.cloud.bigquery as bigquery diff --git a/bigframes/streaming/__init__.py b/bigframes/streaming/__init__.py index 49687090fe6..d439d622a2f 100644 --- a/bigframes/streaming/__init__.py +++ b/bigframes/streaming/__init__.py @@ -12,16 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - import inspect -import sys import bigframes.core.global_session as global_session +from bigframes.pandas.io.api import _set_default_session_location_if_possible import bigframes.session import bigframes.streaming.dataframe as streaming_dataframe -from bigframes.core.logging import log_adapter -from bigframes.pandas.io.api import _set_default_session_location_if_possible def read_gbq_table(table: str) -> streaming_dataframe.StreamingDataFrame: @@ -36,12 +32,3 @@ 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 98d6da45399..69247879d12 100644 --- a/bigframes/streaming/dataframe.py +++ b/bigframes/streaming/dataframe.py @@ -13,25 +13,20 @@ # limitations under the License. """Module for bigquery continuous queries""" - from __future__ import annotations import functools import inspect import json +from typing import Optional import warnings -from abc import abstractmethod -from datetime import date, datetime -from typing import Optional, Union -import pandas as pd from google.cloud import bigquery +from bigframes import dataframe +from bigframes.core import log_adapter, nodes import bigframes.exceptions as bfe import bigframes.session -from bigframes import dataframe -from bigframes.core import nodes -from bigframes.core.logging import log_adapter def _return_type_wrapper(method, cls): @@ -59,14 +54,9 @@ 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, *, @@ -80,8 +70,6 @@ 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 @@ -127,8 +115,7 @@ 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 @@ -136,15 +123,8 @@ 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(start_timestamp), + self._appends_sql, instance=instance, table=table, service_account_email=service_account_email, @@ -165,7 +145,6 @@ 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 @@ -193,8 +172,6 @@ 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: @@ -203,15 +180,8 @@ 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(start_timestamp), + self._appends_sql, topic=topic, service_account_email=service_account_email, session=self._session, @@ -252,7 +222,7 @@ def _from_table_df(cls, df: dataframe.DataFrame) -> StreamingDataFrame: def _original_table(self): def traverse(node: nodes.BigFrameNode): if isinstance(node, nodes.ReadTableNode): - return node.source.table.get_full_id(quoted=False) + return f"{node.source.table.project_id}.{node.source.table.dataset_id}.{node.source.table.table_id}" for child in node.child_nodes: original_table = traverse(child) if original_table: @@ -293,13 +263,13 @@ def __repr__(self, *args, **kwargs): __repr__.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.__repr__)) - def _repr_mimebundle_(self, *args, **kwargs): - return _return_type_wrapper(self._df._repr_mimebundle_, StreamingDataFrame)( + def _repr_html_(self, *args, **kwargs): + return _return_type_wrapper(self._df._repr_html_, StreamingDataFrame)( *args, **kwargs ) - _repr_mimebundle_.__doc__ = _curate_df_doc( - inspect.getdoc(dataframe.DataFrame._repr_mimebundle_) + _repr_html_.__doc__ = _curate_df_doc( + inspect.getdoc(dataframe.DataFrame._repr_html_) ) @property @@ -310,21 +280,14 @@ def sql(self): sql.__doc__ = _curate_df_doc(inspect.getdoc(dataframe.DataFrame.sql)) # Patch for the required APPENDS clause - def _appends_sql( - self, start_timestamp: Optional[Union[int, float, str, datetime, date]] - ) -> str: + @property + def _appends_sql(self): sql_str = self.sql original_table = self._original_table assert original_table is not None # 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})" + appends_clause = f"APPENDS(TABLE `{original_table}`, CURRENT_TIMESTAMP() - (INTERVAL 7 DAY - INTERVAL 5 MINUTE))" sql_str = sql_str.replace(f"`{original_table}`", appends_clause) return sql_str diff --git a/bigframes/testing/__init__.py b/bigframes/testing/__init__.py index 098a67bddf3..529c08241d7 100644 --- a/bigframes/testing/__init__.py +++ b/bigframes/testing/__init__.py @@ -17,5 +17,3 @@ These modules are provided for testing the BigQuery DataFrames package. The interface is not considered stable. """ - -# Do not import modules contains pytest. (b/490160312) diff --git a/bigframes/testing/compiler_session.py b/bigframes/testing/compiler_session.py index b248f37cfc8..35114d95d0d 100644 --- a/bigframes/testing/compiler_session.py +++ b/bigframes/testing/compiler_session.py @@ -16,7 +16,7 @@ import typing import bigframes.core -import bigframes.core.compile as compile +import bigframes.core.compile.sqlglot as sqlglot import bigframes.session.executor @@ -24,7 +24,7 @@ class SQLCompilerExecutor(bigframes.session.executor.Executor): """Executor for SQL compilation using sqlglot.""" - compiler = compile.sqlglot + compiler = sqlglot def to_sql( self, @@ -38,13 +38,6 @@ def to_sql( # 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") + return self.compiler.SQLGlotCompiler().compile( + array_value.node, ordered=ordered + ) diff --git a/bigframes/testing/engine_utils.py b/bigframes/testing/engine_utils.py index 385ca7e45cc..625d1727ee3 100644 --- a/bigframes/testing/engine_utils.py +++ b/bigframes/testing/engine_utils.py @@ -12,16 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio - import pandas.testing from bigframes.core import nodes -from bigframes.session import execution_spec, semi_executor - -SPEC = execution_spec.ExecutionSpec( - ordered=True, -) +from bigframes.session import semi_executor def assert_equivalence_execution( @@ -29,12 +23,12 @@ def assert_equivalence_execution( engine1: semi_executor.SemiExecutor, engine2: semi_executor.SemiExecutor, ): - e1_result = asyncio.run(engine1.execute(node, SPEC)) - e2_result = asyncio.run(engine2.execute(node, SPEC)) + 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() + e1_table = e1_result.to_pandas() + e2_table = e2_result.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 index f8ad43dd664..8d9997b1dfb 100644 --- a/bigframes/testing/mocks.py +++ b/bigframes/testing/mocks.py @@ -14,15 +14,15 @@ import copy import datetime -import unittest.mock as mock 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 -from bigframes_vendored.google_cloud_bigquery import _pandas_helpers import bigframes import bigframes.clients @@ -143,12 +143,10 @@ def query_and_wait_mock(query, *args, job_config=None, **kwargs): 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 - clients_provider.project = anonymous_dataset.project bqoptions = bigframes.BigQueryOptions( credentials=credentials, diff --git a/bigframes/testing/polars_session.py b/bigframes/testing/polars_session.py index 2806dab53f9..3710c40eae1 100644 --- a/bigframes/testing/polars_session.py +++ b/bigframes/testing/polars_session.py @@ -13,8 +13,8 @@ # limitations under the License. import dataclasses +from typing import Optional, Union import weakref -from typing import Union import pandas import polars @@ -23,38 +23,56 @@ import bigframes.core.blocks import bigframes.core.compile.polars import bigframes.dataframe -import bigframes.session.execution_spec import bigframes.session.executor import bigframes.session.metrics -from bigframes.functions import _utils, function, udf_def -# Does not support to_sql, dry_run, peek, cached +# Does not support to_sql, export_gbq, export_gcs, dry_run, peek, head, get_row_count, cached @dataclasses.dataclass class TestExecutor(bigframes.session.executor.Executor): compiler = bigframes.core.compile.polars.PolarsCompiler() + def peek( + self, + array_value: bigframes.core.ArrayValue, + n_rows: int, + use_explicit_destination: Optional[bool] = False, + ): + """ + A 'peek' efficiently accesses a small number of rows in the dataframe. + """ + lazy_frame: polars.LazyFrame = self.compiler.compile(array_value.node) + pa_table = lazy_frame.collect().limit(n_rows).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=pa_table.to_batches(), + schema=array_value.schema, + total_bytes=pa_table.nbytes, + total_rows=pa_table.num_rows, + ) + def execute( self, array_value: bigframes.core.ArrayValue, - execution_spec: bigframes.session.execution_spec.ExecutionSpec, + *, + ordered: bool = True, + use_explicit_destination: Optional[bool] = False, + page_size: Optional[int] = None, + max_results: Optional[int] = None, ): """ Execute the ArrayValue, storing the result to a temporary session-owned table. """ - 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.LocalExecuteResult( - data=pa_table, - bf_schema=array_value.schema, + return bigframes.session.executor.ExecuteResult( + _arrow_batches=pa_table.to_batches(), + schema=array_value.schema, + total_bytes=pa_table.nbytes, + total_rows=pa_table.num_rows, ) def cached( @@ -93,47 +111,11 @@ 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)): + if isinstance(pandas_dataframe, pandas.Series): pandas_dataframe = pandas_dataframe.to_frame() - local_block = bigframes.core.blocks.Block.from_local(pandas_dataframe, self) - 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 - - def udf( - self, - *, - input_types=None, - output_type=None, - **kwargs, - ): - def wrapper(func): - udf_sig = _utils.get_func_signature( - func, - input_types, - output_type, - ) - - code_def = udf_def.CodeDef.from_func(func) - udf_definition = udf_def.PythonUdf( - signature=udf_sig, - code=code_def, - ) - return function.UdfRoutine(func=func, _udf_def=udf_definition) - - return wrapper + return bigframes.dataframe.DataFrame(local_block) @property def bqclient(self): diff --git a/bigframes/testing/utils.py b/bigframes/testing/utils.py index 79e99968f58..5da24c5b9bf 100644 --- a/bigframes/testing/utils.py +++ b/bigframes/testing/utils.py @@ -14,23 +14,19 @@ import base64 import decimal -import re -from typing import Iterable, Optional, Sequence, Set, TypeVar, Union +from typing import Iterable, Optional, Set, Union import geopandas as gpd # type: ignore import google.api_core.operation +from google.cloud import bigquery, functions_v2 +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 google.cloud import bigquery, functions_v2 -from google.cloud.functions_v2.types import functions import bigframes.functions._utils as bff_utils -import bigframes.pandas as bpd -from bigframes import operations as ops -from bigframes.core import expression as ex +import bigframes.pandas ML_REGRESSION_METRICS = [ "mean_absolute_error", @@ -68,98 +64,43 @@ "content", ] -SeriesOrIndexT = TypeVar("SeriesOrIndexT", pd.Series, pd.Index) - - -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: bpd.DataFrame, **kwargs): +def assert_dfs_equivalent( + pd_df: pd.DataFrame, bf_df: bigframes.pandas.DataFrame, **kwargs +): bf_df_local = bf_df.to_pandas() ignore_order = not bf_df._session._strictly_ordered - assert_frame_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) + assert_pandas_df_equal(bf_df_local, pd_df, ignore_order=ignore_order, **kwargs) -def assert_series_equivalent(pd_series: pd.Series, bf_series: bpd.Series, **kwargs): +def assert_series_equivalent( + pd_series: pd.Series, bf_series: bigframes.pandas.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 _normalize_all_nulls(col: pd.Series) -> pd.Series: - if pd_types.is_float_dtype(col.dtype): - col = col.astype("float64").astype("Float64") - elif col.dtype == "object": - if any(isinstance(x, decimal.Decimal) for x in col): - pass - else: - try: - col = col.astype("Float64") - except (TypeError, ValueError, SystemError): - pass - return col - - -def _normalize_index_nulls(idx: pd.Index) -> pd.Index: - if isinstance(idx, pd.MultiIndex): - new_levels = [ - _normalize_index_nulls(idx.get_level_values(i)) for i in range(idx.nlevels) - ] - return pd.MultiIndex.from_arrays(new_levels, names=idx.names) - if idx.hasnans: - if pd_types.is_float_dtype(idx.dtype): - idx = idx.astype("float64").astype("Float64") - return idx - - -def assert_frame_equal( - left: pd.DataFrame, - right: pd.DataFrame, - *, - ignore_order: bool = False, - nulls_are_nan: bool = True, - downcast_object: bool = True, - **kwargs, -): +def assert_pandas_df_equal(df0, df1, ignore_order: bool = False, **kwargs): if ignore_order: # Sort by a column to get consistent results. - if left.index.name != "rowindex": - left = left.sort_values( - list(left.columns.drop("geography_col", errors="ignore")) + if df0.index.name != "rowindex": + df0 = df0.sort_values( + list(df0.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) - right = right.sort_values( - list(right.columns.drop("geography_col", errors="ignore")) + df1 = df1.sort_values( + list(df1.columns.drop("geography_col", errors="ignore")) ).reset_index(drop=True) else: - left = left.sort_index() - right = right.sort_index() + df0 = df0.sort_index() + df1 = df1.sort_index() - # Pandas sometimes likes to produce object dtype columns - # However, nan/None/Null inconsistency makes comparison futile, convert to typed column - if downcast_object: - left = left.apply(lambda x: x.infer_objects()) - right = right.apply(lambda x: x.infer_objects()) - - if nulls_are_nan: - left = left.apply(_normalize_all_nulls) - right = right.apply(_normalize_all_nulls) - left.index = _normalize_index_nulls(left.index) - right.index = _normalize_index_nulls(right.index) - - pd.testing.assert_frame_equal(left, right, **kwargs) + pd.testing.assert_frame_equal(df0, df1, **kwargs) def assert_series_equal( - left: pd.Series, - right: pd.Series, - *, - ignore_order: bool = False, - nulls_are_nan: bool = True, - **kwargs, + left: pd.Series, right: pd.Series, ignore_order: bool = False, **kwargs ): if ignore_order: if left.index.name is None: @@ -169,30 +110,9 @@ 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.infer_objects()) - right = _normalize_all_nulls(right.infer_objects()) - left.index = _normalize_index_nulls(left.index) - right.index = _normalize_index_nulls(right.index) - left.name = pd.NA if pd.isna(left.name) else left.name # type: ignore - right.name = pd.NA if pd.isna(right.name) else right.name # type: ignore - pd.testing.assert_series_equal(left, right, **kwargs) -def assert_index_equal(left, right, **kwargs): - pd.testing.assert_index_equal(left, right, **kwargs) - - def _standardize_index(idx): return pd.Index(list(idx), name=idx.name) @@ -436,11 +356,11 @@ def get_cloud_functions( ) -> Iterable[functions.ListFunctionsResponse]: """Get the cloud functions in the given project and location.""" - assert not name or not name_prefix, ( - "Either 'name' or 'name_prefix' can be passed but not both." - ) + assert ( + not name or not name_prefix + ), "Either 'name' or 'name_prefix' can be passed but not both." - location = bff_utils.gcf_location_from_bq_location(location) + _, location = bff_utils.get_remote_function_locations(location) parent = f"projects/{project}/locations/{location}" request = functions_v2.ListFunctionsRequest(parent=parent) page_result = functions_client.list_functions(request=request) @@ -516,43 +436,15 @@ def cleanup_function_assets( pass -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 +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}" diff --git a/bigframes/version.py b/bigframes/version.py index 3eecebee5a1..b9aa5d1855f 100644 --- a/bigframes/version.py +++ b/bigframes/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.48.0" +__version__ = "2.17.0" # {x-release-please-start-date} -__release_date__ = "2026-06-12" +__release_date__ = "2025-08-22" # {x-release-please-end} diff --git a/biome.json b/biome.json deleted file mode 100644 index d30c8687a4c..00000000000 --- a/biome.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "formatter": { - "indentStyle": "space", - "indentWidth": 2 - }, - "javascript": { - "formatter": { - "quoteStyle": "single" - } - }, - "css": { - "formatter": { - "quoteStyle": "single" - } - } -} diff --git a/conftest.py b/conftest.py deleted file mode 100644 index 5d3f116b521..00000000000 --- a/conftest.py +++ /dev/null @@ -1,62 +0,0 @@ -# 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() -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/README.rst b/docs/README.rst deleted file mode 100644 index a3aef5380bb..00000000000 --- a/docs/README.rst +++ /dev/null @@ -1,94 +0,0 @@ -BigQuery DataFrames (BigFrames) -=============================== - - -|GA| |pypi| |versions| - -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 `_ - 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 `_. - -.. |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 -.. |pypi| image:: https://img.shields.io/pypi/v/bigframes.svg - :target: https://pypi.org/project/bigframes/ -.. |versions| image:: https://img.shields.io/pypi/pyversions/bigframes.svg - :target: https://pypi.org/project/bigframes/ - -Getting started with BigQuery DataFrames ----------------------------------------- - -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, - -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 -------- - -BigQuery DataFrames is distributed with the `Apache-2.0 license -`_. - -It also contains code derived from the following third-party packages: - -* `Ibis `_ -* `pandas `_ -* `Python `_ -* `scikit-learn `_ -* `XGBoost `_ -* `SQLGlot `_ - -For details, see the `third_party -`_ -directory. - - -Contact Us ----------- - -For further help and provide feedback, you can email us at `bigframes-feedback@google.com `_. diff --git a/docs/README.rst b/docs/README.rst new file mode 120000 index 00000000000..89a0106941f --- /dev/null +++ b/docs/README.rst @@ -0,0 +1 @@ +../README.rst \ No newline at end of file diff --git a/docs/_templates/autosummary/class.rst b/docs/_templates/autosummary/class.rst deleted file mode 120000 index bd84850996f..00000000000 --- a/docs/_templates/autosummary/class.rst +++ /dev/null @@ -1 +0,0 @@ -../../../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 deleted file mode 120000 index f330261ac5c..00000000000 --- a/docs/_templates/autosummary/module.rst +++ /dev/null @@ -1 +0,0 @@ -../../../third_party/sphinx/ext/autosummary/templates/autosummary/module.rst \ No newline at end of file diff --git a/docs/CHANGELOG.md b/docs/changelog.md similarity index 100% rename from docs/CHANGELOG.md rename to docs/changelog.md diff --git a/docs/conf.py b/docs/conf.py index 2cc3ffa130d..23ec7a6b36a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,11 +24,9 @@ # 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 @@ -58,19 +56,14 @@ "sphinx.ext.napoleon", "sphinx.ext.todo", "sphinx.ext.viewcode", - "sphinx_sitemap", - "myst_nb", + "recommonmark", ] -# myst-nb configuration -nb_execution_mode = "off" - # 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"] @@ -105,7 +98,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 = "en-US" +language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -121,7 +114,6 @@ "samples/AUTHORING_GUIDE.md", "samples/CONTRIBUTING.md", "samples/snippets/README.rst", - "README.rst", # used for include in overview.rst only ] # The reST default role (used for this markup: `text`) to use for all @@ -156,20 +148,19 @@ # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = "pydata_sphinx_theme" +html_theme = "alabaster" # 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 = { - "github_url": "https://github.com/googleapis/google-cloud-python", - "logo": { - "text": "BigQuery DataFrames (BigFrames)", - }, - "analytics": { - "google_analytics_id": "G-XVSRMCJ37X", - }, + "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", } # Add any paths that contain custom themes here, relative to this directory. @@ -259,34 +250,21 @@ # 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/" -sitemap_locales = [None] - -# We don't have any immediate plans to translate the API reference, so omit the -# language from the URLs. -# https://sphinx-sitemap.readthedocs.io/en/latest/advanced-configuration.html#configuration-customizing-url-scheme -sitemap_url_scheme = "{link}" - # -- Options for warnings ------------------------------------------------------ suppress_warnings = [ - # Allow unknown mimetype so we can use widgets in tutorial notebooks. - "mystnb.unknown_mime_type", # Temporarily suppress this to avoid "more than one target found for # cross-reference" warning, which are intractable for us to avoid while in # a mono-repo. # See https://github.com/sphinx-doc/sphinx/blob # /2a65ffeef5c107c19084fabdd706cdff3f52d93c/sphinx/domains/python.py#L843 - "ref.python", - # Allow external websites to be down occasionally. - "intersphinx.external", + "ref.python" ] # -- Options for LaTeX output --------------------------------------------- -latex_elements: dict[str, Any] = { +latex_elements = { # The paper size ('letterpaper' or 'a4paper'). #'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). @@ -304,7 +282,7 @@ ( root_doc, "bigframes.tex", - "BigQuery DataFrames (BigFrames)", + "bigframes Documentation", author, "manual", ) @@ -339,7 +317,7 @@ ( root_doc, "bigframes", - "BigQuery DataFrames (BigFrames)", + "bigframes Documentation", [author], 1, ) @@ -358,7 +336,7 @@ ( root_doc, "bigframes", - "BigQuery DataFrames (BigFrames)", + "bigframes Documentation", author, "bigframes", "bigframes Library", @@ -381,7 +359,7 @@ # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - "python": ("https://docs.python.org/3/", None), + "python": ("https://python.readthedocs.org/en/latest/", None), "google-auth": ("https://googleapis.dev/python/google-auth/latest/", None), "google.api_core": ( "https://googleapis.dev/python/google-api-core/latest/", @@ -390,8 +368,7 @@ "grpc": ("https://grpc.github.io/grpc/python/", None), "proto-plus": ("https://proto-plus-python.readthedocs.io/en/latest/", None), "protobuf": ("https://googleapis.dev/python/protobuf/latest/", None), - # TODO(tswast): re-enable if we can get temporary failures to be ignored. - # "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), + "pandas": ("https://pandas.pydata.org/pandas-docs/stable/", None), "pydata-google-auth": ( "https://pydata-google-auth.readthedocs.io/en/latest/", None, diff --git a/docs/index.rst b/docs/index.rst index 51d05e7d368..b17ac7cbd9c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,85 +1,20 @@ -.. BigQuery DataFrames documentation main file +.. include:: README.rst -Scalable Python Data Analysis with BigQuery DataFrames (BigFrames) -================================================================== - -.. meta:: - :description: BigQuery DataFrames (BigFrames) provides a scalable, pandas-compatible Python API for data analysis and machine learning on petabyte-scale datasets using the BigQuery engine. - -**BigQuery DataFrames** (``bigframes``) is an open-source Python library that brings the power of **distributed computing** to your data science workflow. By providing a familiar **pandas** and **scikit-learn** compatible API, BigFrames allows you to analyze and model massive datasets where they live—directly in **BigQuery**. - -Why Choose BigQuery DataFrames? -------------------------------- - -BigFrames eliminates the "data movement bottleneck." Instead of downloading large datasets to a local environment, BigFrames translates your Python code into optimized SQL, executing complex transformations across the BigQuery fleet. - -* **Petabyte-Scale Scalability:** Effortlessly process datasets that far exceed local memory limits. -* **Familiar Python Ecosystem:** Use the same ``read_gbq``, ``groupby``, ``merge``, and ``pivot_table`` functions you already know from pandas. -* **Integrated Machine Learning:** Access BigQuery ML's powerful algorithms via a scikit-learn-like interface (``bigframes.ml``), including seamless **Gemini AI** integration. -* **Enterprise-Grade Security:** Maintain data governance and security by keeping your data within the BigQuery perimeter. -* **Hybrid Flexibility:** Easily move between distributed BigQuery processing and local pandas analysis with ``to_pandas()``. - -Core Components of BigFrames ----------------------------- - -BigQuery DataFrames is organized into specialized modules designed for the modern data stack: - -1. :mod:`bigframes.pandas`: A high-performance, pandas-compatible API for scalable data exploration, cleaning, and transformation. -2. :mod:`bigframes.bigquery`: Specialized utilities for direct BigQuery resource management, including integrations with Gemini and other AI models in the :mod:`bigframes.bigquery.ai` submodule. - - -Quickstart: Scalable Data Analysis in Seconds ---------------------------------------------- - -Install BigQuery DataFrames via pip: - -.. code-block:: bash - - pip install --upgrade bigframes - -The following example demonstrates how to perform a distributed aggregation on a public dataset with millions of rows using just a few lines of Python: - -.. code-block:: python - - import bigframes.pandas as bpd - - # If running in your local environment or Colab, uncomment these lines and add your GCP project ID - # PROJECT_ID = "bigframes-dev" - # bpd.options.bigquery.project = PROJECT_ID - - # Initialize BigFrames and load a public dataset - df = bpd.read_gbq("bigquery-public-data.usa_names.usa_1910_2013") - - # Perform familiar pandas operations that execute in the cloud - top_names = ( - df.groupby("name") - .agg({"number": "sum"}) - .sort_values("number", ascending=False) - .head(10) - ) - - # Bring the final, aggregated results back to local memory if needed - print(top_names.to_pandas()) - - -Explore the Documentation -------------------------- +API reference +------------- .. toctree:: - :maxdepth: 2 - :caption: User Documentation - - user_guide/index - -.. toctree:: - :maxdepth: 2 - :caption: API Reference + :maxdepth: 3 reference/index supported_pandas_apis +Changelog +--------- + +For a list of all BigQuery DataFrames releases: + .. toctree:: - :maxdepth: 1 - :caption: Community & Updates + :maxdepth: 2 - CHANGELOG + changelog diff --git a/docs/notebooks b/docs/notebooks deleted file mode 120000 index 8f9a5b2e6d2..00000000000 --- a/docs/notebooks +++ /dev/null @@ -1 +0,0 @@ -../notebooks \ No newline at end of file diff --git a/docs/reference/.gitignore b/docs/reference/.gitignore deleted file mode 100644 index 3f127954839..00000000000 --- a/docs/reference/.gitignore +++ /dev/null @@ -1 +0,0 @@ -api/* diff --git a/docs/reference/bigframes.bigquery/index.rst b/docs/reference/bigframes.bigquery/index.rst new file mode 100644 index 00000000000..03e9bb48a42 --- /dev/null +++ b/docs/reference/bigframes.bigquery/index.rst @@ -0,0 +1,9 @@ + +=========================== +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 new file mode 100644 index 00000000000..481eb73b9d4 --- /dev/null +++ b/docs/reference/bigframes.geopandas/geoseries.rst @@ -0,0 +1,17 @@ + +========= +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 new file mode 100644 index 00000000000..e33946461c6 --- /dev/null +++ b/docs/reference/bigframes.geopandas/index.rst @@ -0,0 +1,9 @@ + +=============================== +BigQuery DataFrames (geopandas) +=============================== + +.. toctree:: + :maxdepth: 2 + + geoseries diff --git a/docs/reference/bigframes.ml/README.rst b/docs/reference/bigframes.ml/README.rst new file mode 100644 index 00000000000..80a1fe97b73 --- /dev/null +++ b/docs/reference/bigframes.ml/README.rst @@ -0,0 +1,125 @@ +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 new file mode 100644 index 00000000000..e91a28c0511 --- /dev/null +++ b/docs/reference/bigframes.ml/cluster.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..9992728362f --- /dev/null +++ b/docs/reference/bigframes.ml/compose.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..ec804ac8cdc --- /dev/null +++ b/docs/reference/bigframes.ml/decomposition.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..2652ab5aa4d --- /dev/null +++ b/docs/reference/bigframes.ml/ensemble.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..04015c99117 --- /dev/null +++ b/docs/reference/bigframes.ml/forecasting.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..c151cbda6f1 --- /dev/null +++ b/docs/reference/bigframes.ml/imported.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..3796e287ef9 --- /dev/null +++ b/docs/reference/bigframes.ml/impute.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..c14efaede67 --- /dev/null +++ b/docs/reference/bigframes.ml/index.rst @@ -0,0 +1,38 @@ +.. _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 new file mode 100644 index 00000000000..8c6c2765b12 --- /dev/null +++ b/docs/reference/bigframes.ml/linear_model.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..20ae7793e73 --- /dev/null +++ b/docs/reference/bigframes.ml/llm.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..c20772ef07f --- /dev/null +++ b/docs/reference/bigframes.ml/metrics.pairwise.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..aca11f7e9fc --- /dev/null +++ b/docs/reference/bigframes.ml/metrics.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..d662285f990 --- /dev/null +++ b/docs/reference/bigframes.ml/model_selection.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..22e877dc5b3 --- /dev/null +++ b/docs/reference/bigframes.ml/pipeline.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..eac72da1730 --- /dev/null +++ b/docs/reference/bigframes.ml/preprocessing.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..7827acfe923 --- /dev/null +++ b/docs/reference/bigframes.ml/remote.rst @@ -0,0 +1,7 @@ +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 new file mode 100644 index 00000000000..ea4c6dec1c9 --- /dev/null +++ b/docs/reference/bigframes.pandas/frame.rst @@ -0,0 +1,44 @@ + +========= +DataFrame +========= + +.. contents:: Table of Contents + :depth: 2 + :local: + :backlinks: none + +DataFrame +--------- + +.. autoclass:: bigframes.dataframe.DataFrame + :members: + :inherited-members: + :undoc-members: + +Accessors +--------- + +Plotting handling +^^^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.plotting.PlotAccessor + :members: + :inherited-members: + :undoc-members: + +Struct handling +^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.structs.StructFrameAccessor + :members: + :inherited-members: + :undoc-members: + +AI operators +^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.ai.AIAccessor + :members: + :inherited-members: + :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.pandas/general_functions.rst b/docs/reference/bigframes.pandas/general_functions.rst new file mode 100644 index 00000000000..fff1a9ef59f --- /dev/null +++ b/docs/reference/bigframes.pandas/general_functions.rst @@ -0,0 +1,9 @@ + +================= +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 new file mode 100644 index 00000000000..483340f3487 --- /dev/null +++ b/docs/reference/bigframes.pandas/groupby.rst @@ -0,0 +1,20 @@ + +======= +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 new file mode 100644 index 00000000000..3492f236ee9 --- /dev/null +++ b/docs/reference/bigframes.pandas/index.rst @@ -0,0 +1,16 @@ + +============================ +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 new file mode 100644 index 00000000000..602b6de8372 --- /dev/null +++ b/docs/reference/bigframes.pandas/indexers.rst @@ -0,0 +1,60 @@ + +========= +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 new file mode 100644 index 00000000000..e25e8652ec1 --- /dev/null +++ b/docs/reference/bigframes.pandas/indexing.rst @@ -0,0 +1,21 @@ + +============= +Index objects +============= + +.. autoclass:: bigframes.core.indexes.base.Index + :members: + :inherited-members: + :undoc-members: + + +.. autoclass:: bigframes.core.indexes.multi.MultiIndex + :members: + :inherited-members: + :undoc-members: + + +.. autoclass:: bigframes.core.indexes.datetimes.DatetimeIndex + :members: + :inherited-members: + :undoc-members: \ No newline at end of file diff --git a/docs/reference/bigframes.pandas/options.rst b/docs/reference/bigframes.pandas/options.rst new file mode 100644 index 00000000000..60af8c826a4 --- /dev/null +++ b/docs/reference/bigframes.pandas/options.rst @@ -0,0 +1,6 @@ + +==================== +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 new file mode 100644 index 00000000000..41b1529b0c6 --- /dev/null +++ b/docs/reference/bigframes.pandas/series.rst @@ -0,0 +1,69 @@ + +====== +Series +====== + +.. contents:: Table of Contents + :depth: 2 + :local: + :backlinks: none + +Series +------ + +.. autoclass:: bigframes.series.Series + :members: + :inherited-members: + :undoc-members: + +Accessors +--------- + +Datetime properties +^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.datetimes.DatetimeMethods + :members: + :inherited-members: + :undoc-members: + +String handling +^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.strings.StringMethods + :members: + :inherited-members: + :undoc-members: + +List handling +^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.lists.ListAccessor + :members: + :inherited-members: + :undoc-members: + +Struct handling +^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.structs.StructAccessor + :members: + :inherited-members: + :undoc-members: + +Blob handling +^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.blob.BlobAccessor + :members: + :inherited-members: + :undoc-members: + +Plotting handling +^^^^^^^^^^^^^^^^^ + +.. autoclass:: bigframes.operations.plotting.PlotAccessor + :members: + :inherited-members: + :undoc-members: + :noindex: diff --git a/docs/reference/bigframes.pandas/window.rst b/docs/reference/bigframes.pandas/window.rst new file mode 100644 index 00000000000..55d911ecf4f --- /dev/null +++ b/docs/reference/bigframes.pandas/window.rst @@ -0,0 +1,9 @@ + +====== +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 new file mode 100644 index 00000000000..79ec64961c5 --- /dev/null +++ b/docs/reference/bigframes.streaming/dataframe.rst @@ -0,0 +1,6 @@ +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 new file mode 100644 index 00000000000..20a22072e5a --- /dev/null +++ b/docs/reference/bigframes.streaming/index.rst @@ -0,0 +1,13 @@ + +============================ +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 new file mode 100644 index 00000000000..b0a198e1842 --- /dev/null +++ b/docs/reference/bigframes/enums.rst @@ -0,0 +1,8 @@ + +===== +Enums +===== + +.. automodule:: bigframes.enums + :members: + :undoc-members: diff --git a/docs/reference/bigframes/exceptions.rst b/docs/reference/bigframes/exceptions.rst new file mode 100644 index 00000000000..c471aecdf75 --- /dev/null +++ b/docs/reference/bigframes/exceptions.rst @@ -0,0 +1,8 @@ + +======================= +Exceptions and Warnings +======================= + +.. automodule:: bigframes.exceptions + :members: + :undoc-members: diff --git a/docs/reference/bigframes/index.rst b/docs/reference/bigframes/index.rst new file mode 100644 index 00000000000..f56883dc8e3 --- /dev/null +++ b/docs/reference/bigframes/index.rst @@ -0,0 +1,22 @@ + +============ +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 new file mode 100644 index 00000000000..991399eb886 --- /dev/null +++ b/docs/reference/bigframes/options.rst @@ -0,0 +1,16 @@ +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 99228010b24..a0f96f751a4 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -4,57 +4,12 @@ API Reference Refer to these pages for details about the public objects in the ``bigframes`` packages. -.. autosummary:: - :toctree: api - - bigframes._config - bigframes.bigquery - bigframes.bigquery.aead - bigframes.bigquery.ai - bigframes.bigquery.ml - bigframes.bigquery.obj - bigframes.enums - bigframes.exceptions - bigframes.geopandas - bigframes.pandas - bigframes.pandas.api.typing - bigframes.streaming - -Pandas Extensions -~~~~~~~~~~~~~~~~~ - -BigQuery DataFrames provides extensions to pandas DataFrame and Series objects. - -.. autosummary:: - :toctree: api - - bigframes.extensions.core.dataframe_accessor.BigQueryDataFrameAccessor - bigframes.extensions.core.dataframe_accessor.AIAccessor - bigframes.extensions.core.series_accessor.BigQuerySeriesAccessor - bigframes.extensions.core.series_accessor.AeadSeriesAccessor - -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 +.. toctree:: + :maxdepth: 2 + + bigframes/index + bigframes.bigquery/index + bigframes.geopandas/index + bigframes.ml/index + bigframes.pandas/index + bigframes.streaming/index diff --git a/docs/templates/toc.yml b/docs/templates/toc.yml index 394f2a7d3cc..a27f162a9a7 100644 --- a/docs/templates/toc.yml +++ b/docs/templates/toc.yml @@ -42,9 +42,12 @@ - name: DataFrame uid: bigframes.dataframe.DataFrame - name: PlotAccessor - uid: bigframes.pandas.api.typing.PlotAccessor + uid: bigframes.operations.plotting.PlotAccessor - name: StructAccessor uid: bigframes.operations.structs.StructFrameAccessor + - name: AI + uid: bigframes.operations.ai.AIAccessor + status: beta name: DataFrame - items: - name: DataFrameGroupBy @@ -86,7 +89,10 @@ - name: ListAccessor uid: bigframes.operations.lists.ListAccessor - name: PlotAccessor - uid: bigframes.pandas.api.typing.PlotAccessor + uid: bigframes.operations.plotting.PlotAccessor + - name: BlobAccessor + uid: bigframes.operations.blob.BlobAccessor + status: beta name: Series - name: Window uid: bigframes.core.window.Window @@ -212,9 +218,6 @@ - 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/docs/user_guide/index.rst b/docs/user_guide/index.rst deleted file mode 100644 index 0c0935ac40a..00000000000 --- a/docs/user_guide/index.rst +++ /dev/null @@ -1,127 +0,0 @@ -User Guide -********** - -.. include:: ../README.rst - -.. toctree:: - :caption: Guides - :maxdepth: 1 - - Getting Started - Cloud Docs User Guides - -.. toctree:: - :caption: Getting Started - :maxdepth: 1 - - Quickstart Template <../notebooks/getting_started/bq_dataframes_template.ipynb> - Getting Started <../notebooks/getting_started/getting_started_bq_dataframes.ipynb> - Magics <../notebooks/getting_started/magics.ipynb> - ML Fundamentals <../notebooks/getting_started/ml_fundamentals_bq_dataframes.ipynb> - Pandas Extensions <../notebooks/getting_started/pandas_extensions.ipynb> - -.. toctree:: - :caption: DataFrames - :maxdepth: 1 - - Anywidget Mode <../notebooks/dataframes/anywidget_mode.ipynb> - Dataframe <../notebooks/dataframes/dataframe.ipynb> - Index Col Null <../notebooks/dataframes/index_col_null.ipynb> - Integrations <../notebooks/dataframes/integrations.ipynb> - Magics for Python and SQL Interoperability <../notebooks/dataframes/magics_with_local_data.ipynb> - Pypi <../notebooks/dataframes/pypi.ipynb> - -.. toctree:: - :caption: Data Types - :maxdepth: 1 - - Array <../notebooks/data_types/array.ipynb> - Json <../notebooks/data_types/json.ipynb> - Struct <../notebooks/data_types/struct.ipynb> - Timedelta <../notebooks/data_types/timedelta.ipynb> - -.. toctree:: - :caption: Generative AI - :maxdepth: 1 - - AI Functions <../notebooks/generative_ai/ai_functions.ipynb> - AI Functions for Poster Analysis <../notebooks/generative_ai/ai_movie_poster.ipynb> - AI Forecast <../notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb> - LLM Code Generation <../notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb> - LLM KMeans <../notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb> - LLM Output Schema <../notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb> - LLM Vector Search <../notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb> - Drug Name Generation <../notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb> - Large Language Models <../notebooks/generative_ai/large_language_models.ipynb> - -.. toctree:: - :caption: Machine Learning - :maxdepth: 1 - - ML Cross Validation <../notebooks/ml/bq_dataframes_ml_cross_validation.ipynb> - Linear Regression <../notebooks/ml/bq_dataframes_ml_linear_regression.ipynb> - Linear Regression BBQ <../notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb> - Linear Regression Big <../notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb> - Easy Linear Regression <../notebooks/ml/easy_linear_regression.ipynb> - Sklearn Linear Regression <../notebooks/ml/sklearn_linear_regression.ipynb> - Timeseries Analysis <../notebooks/ml/timeseries_analysis.ipynb> - -.. toctree:: - :caption: Visualization - :maxdepth: 1 - - COVID Line Graphs <../notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb> - Tutorial <../notebooks/visualization/tutorial.ipynb> - -.. toctree:: - :caption: Geospatial Data - :maxdepth: 1 - - Geoseries <../notebooks/geo/geoseries.ipynb> - -.. toctree:: - :caption: Regionalized BigQuery - :maxdepth: 1 - - Regionalized <../notebooks/location/regionalized.ipynb> - -.. toctree:: - :caption: Multimodal - :maxdepth: 1 - - Multimodal Dataframe <../notebooks/multimodal/multimodal_dataframe.ipynb> - -.. toctree:: - :caption: Remote Functions - :maxdepth: 1 - - Remote Function <../notebooks/remote_functions/remote_function.ipynb> - Remote Function Usecases <../notebooks/remote_functions/remote_function_usecases.ipynb> - Remote Function Vertex Claude Model <../notebooks/remote_functions/remote_function_vertex_claude_model.ipynb> - -.. toctree:: - :caption: Streaming - :maxdepth: 1 - - Streaming Dataframe <../notebooks/streaming/streaming_dataframe.ipynb> - -.. toctree:: - :caption: Experimental - :maxdepth: 1 - - AI Operators <../notebooks/experimental/ai_operators.ipynb> - Semantic Operators <../notebooks/experimental/semantic_operators.ipynb> - -.. toctree:: - :caption: Apps - :maxdepth: 1 - - Synthetic Data Generation <../notebooks/apps/synthetic_data_generation.ipynb> - -.. toctree:: - :caption: Kaggle - :maxdepth: 1 - - AI Forecast <../notebooks/kaggle/bq_dataframes_ai_forecast.ipynb> - Describe Product Images <../notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb> - Vector Search Over National Jukebox <../notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb> diff --git a/mypy.ini b/mypy.ini index e3f44c262ac..7709eb200a3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -44,6 +44,3 @@ ignore_missing_imports = True [mypy-anywidget] ignore_missing_imports = True - -[mypy-bigframes_vendored.*] -ignore_errors = True diff --git a/notebooks/.gitignore b/notebooks/.gitignore index d9acee9f51d..87620ac7e74 100644 --- a/notebooks/.gitignore +++ b/notebooks/.gitignore @@ -1,5 +1 @@ .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 00d30fc8a8a..b59777a5da3 100644 --- a/notebooks/apps/synthetic_data_generation.ipynb +++ b/notebooks/apps/synthetic_data_generation.ipynb @@ -98,7 +98,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": { "colab": { "base_uri": "https://localhost:8080/", @@ -111,7 +111,7 @@ "source": [ "from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")" + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { diff --git a/notebooks/data_types/struct.ipynb b/notebooks/data_types/struct.ipynb index 9df0780e307..74bf69d239f 100644 --- a/notebooks/data_types/struct.ipynb +++ b/notebooks/data_types/struct.ipynb @@ -211,11 +211,11 @@ { "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", + "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]" ] }, @@ -267,7 +267,7 @@ } ], "source": [ - "df['Address'].struct.dtypes" + "df['Address'].struct.dtypes()" ] }, { @@ -461,7 +461,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv", + "display_name": "Python 3", "language": "python", "name": "python3" }, @@ -475,7 +475,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.12.9" + "version": "3.12.1" } }, "nbformat": 4, diff --git a/notebooks/data_types/timedelta.ipynb b/notebooks/data_types/timedelta.ipynb deleted file mode 100644 index d65c812d83e..00000000000 --- a/notebooks/data_types/timedelta.ipynb +++ /dev/null @@ -1,571 +0,0 @@ -{ - "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 index 9cae55b26dc..617329ba659 100644 --- a/notebooks/dataframes/anywidget_mode.ipynb +++ b/notebooks/dataframes/anywidget_mode.ipynb @@ -45,14 +45,7 @@ "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. Use **Shift + Click** to sort by multiple columns.\n", - "- **Column Resizing:** Drag the dividers between column headers to adjust their width.\n", - "- **Max Columns Control:** Limit the number of displayed columns to improve performance and readability for wide datasets." + "Set the display option to use anywidget" ] }, { @@ -63,7 +56,7 @@ "outputs": [], "source": [ "bpd.options.bigquery.ordering_mode = \"partial\"\n", - "bpd.options.display.render_mode = \"anywidget\"" + "bpd.options.display.repr_mode = \"anywidget\"" ] }, { @@ -74,15 +67,6 @@ "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, @@ -92,9 +76,7 @@ { "data": { "text/html": [ - "\n", - " Query processed 171.4 MB in 19 seconds of slot time. [Job bigframes-dev:US.50efe672-74c6-4292-98d9-520cba9ca516 details]\n", - " " + "Query job a643d120-4af9-44fc-ba3c-ed461cf1092b is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -107,20 +89,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "state gender year name number\n", - " AL F 1910 Annie 482\n", - " AL F 1910 Myrtle 104\n", - " AR F 1910 Lillian 56\n", - " CT F 1910 Anne 38\n", - " CT F 1910 Frances 45\n", - " FL F 1910 Margaret 53\n", - " GA F 1910 Mae 73\n", - " GA F 1910 Beatrice 96\n", - " GA F 1910 Lola 47\n", - " IA F 1910 Viola 49\n", - "...\n", - "\n", - "[5552452 rows x 5 columns]\n" + "Computation deferred. Computation will process 171.4 MB\n" ] } ], @@ -129,210 +98,30 @@ "print(df)" ] }, - { - "cell_type": "code", - "execution_count": 5, - "id": "220340b0", - "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "b1080dddbe4140d2b88ef85566e52955", - "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
0ALF1910Lillian99
1ALF1910Ruby204
2ALF1910Helen76
3ALF1910Eunice41
4ARF1910Dora42
5CAF1910Edna62
6CAF1910Helen239
7COF1910Alice46
8FLF1910Willie71
9FLF1910Thelma65
\n", - "

10 rows × 5 columns

\n", - "
[5552452 rows x 5 columns in total]" - ], - "text/plain": [ - "state gender year name number\n", - " AL F 1910 Lillian 99\n", - " AL F 1910 Ruby 204\n", - " AL F 1910 Helen 76\n", - " AL F 1910 Eunice 41\n", - " AR F 1910 Dora 42\n", - " CA F 1910 Edna 62\n", - " CA F 1910 Helen 239\n", - " CO F 1910 Alice 46\n", - " FL F 1910 Willie 71\n", - " FL F 1910 Thelma 65\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." + "Display Series in anywidget mode" ] }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 5, "id": "42bb02ab", "metadata": {}, "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 44.4 MB in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, { "name": "stdout", "output_type": "stream", "text": [ - "1967\n", - "1981\n", - "2009\n", - "1956\n", - "1960\n", - "2001\n", - "2009\n", - "2003\n", - "1985\n", - "1993\n", - "Name: year, dtype: Int64\n", - "...\n", - "\n", - "[5552452 rows]\n" + "Computation deferred. Computation will process 44.4 MB\n" ] } ], "source": [ "test_series = df[\"year\"]\n", - "# Displaying the series triggers the interactive widget\n", "print(test_series)" ] }, @@ -346,86 +135,38 @@ }, { "cell_type": "code", - "execution_count": 7, - "id": "da23e0f3", + "execution_count": 6, + "id": "ce250157", "metadata": {}, "outputs": [ { "data": { "application/vnd.jupyter.widget-view+json": { - "model_id": "46e836f10d9e47afb4d82b5c7da69660", + "model_id": "d2d4ef22ea9f414b89ea5bd85f0e6635", "version_major": 2, "version_minor": 1 }, - "text/html": [ - "
0    1910\n",
-       "1    1912\n",
-       "2    1912\n",
-       "3    1911\n",
-       "4    1912\n",
-       "5    1910\n",
-       "6    1913\n",
-       "7    1912\n",
-       "8    1913\n",
-       "9    1913

[5552452 rows]

" - ], "text/plain": [ - "1910\n", - "1912\n", - "1912\n", - "1911\n", - "1912\n", - "1910\n", - "1913\n", - "1912\n", - "1913\n", - "1913\n", - "Name: year, dtype: Int64\n", - "...\n", - "\n", - "[5552452 rows]" + "TableWidget(page_size=10, row_count=5552452, table_html='" + "TableWidget(page_size=10, row_count=5552452, table_html='
" + "TableWidget(page_size=10, row_count=5, table_html='
Job bigframes-dev:US.job_cpfa9oehjApkQgrbTrKRxTpEtuQX details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "d5bf0a9438954c6890b5d8cd16bff7cd", - "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\":\"18157874.1\",\"class_inte...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\":\"18165514.3\",\"class_inte...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
2{\"application_number\":\"18157347.8\",\"class_inte...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\":\"18166536.5\",\"class_inte...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
4{\"application_number\":\"18171005.4\",\"class_inte...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
\n", - "

5 rows × 15 columns

\n", - "[5 rows x 15 columns in total]" - ], - "text/plain": [ - " result \\\n", - "{\"application_number\":\"18157874.1\",\"class_inter... \n", - "{\"application_number\":\"18165514.3\",\"class_inter... \n", - "{\"application_number\":\"18157347.8\",\"class_inter... \n", - "{\"application_number\":\"18166536.5\",\"class_inter... \n", - "{\"application_number\":\"18171005.4\",\"class_inter... \n", - "\n", - " gcs_path issuer language \\\n", - "gs://gcs-public-data--labeled-patents/espacenet... EU DE \n", - "gs://gcs-public-data--labeled-patents/espacenet... EU DE \n", - "gs://gcs-public-data--labeled-patents/espacenet... EU DE \n", - "gs://gcs-public-data--labeled-patents/espacenet... EU DE \n", - "gs://gcs-public-data--labeled-patents/espacenet... EU DE \n", - "\n", - "publication_date class_international class_us application_number filing_date \\\n", - " 29.08.018 E04H 6/12 18157874.1 21.02.2018 \n", - " 03.10.2018 H05B 6/12 18165514.3 03.04.2018 \n", - " 03.10.2018 G06F 11/30 18157347.8 19.02.2018 \n", - " 03.10.2018 H01L 21/20 18166536.5 16.02.2016 \n", - " 03.10.2018 A01K 31/00 18171005.4 05.02.2015 \n", - "\n", - "priority_date_eu representative_line_1_eu applicant_line_1 \\\n", - " 22.02.2017 Liedtke & Partner Patentanwälte SHB Hebezeugbau GmbH \n", - " 30.03.2017 BSH Hausgeräte GmbH \n", - " 31.03.2017 Hoffmann Eitle FUJITSU LIMITED \n", - " Scheider, Sascha et al EV Group E. Thallner GmbH \n", - " 05.02.2014 Stork Bamberger Patentanwälte Linco Food Systems A/S \n", - "\n", - " inventor_line_1 title_line_1 \\\n", - " VOLGER, Alexander STEUERUNGSSYSTEM FÜR AUTOMATISCHE PARKHÄUSER \n", - "Acero Acero, Jesus VORRICHTUNG ZUR INDUKTIVEN ENERGIEÜBERTRAGUNG \n", - " Kukihara, Kensuke METHOD EXECUTED BY A COMPUTER, INFORMATION PROC... \n", - " Kurz, Florian VORRICHTUNG ZUM BONDEN VON SUBSTRATEN \n", - " Thrane, Uffe MASTHÄHNCHENCONTAINER ALS BESTANDTEIL EINER EI... \n", - "\n", - " number \n", - "EP 3 366 869 A1 \n", - "EP 3 383 141 A2 \n", - "EP 3 382 553 A1 \n", - "EP 3 382 744 A1 \n", - "EP 3 381 276 A1 \n", - "\n", - "[5 rows x 15 columns]" - ] - }, - "execution_count": 11, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "bpd.read_gbq(\"\"\"\n", - " SELECT\n", - " AI.GENERATE(\n", - " prompt=>(\"Extract the values.\", OBJ.GET_ACCESS_URL(OBJ.FETCH_METADATA(OBJ.MAKE_REF(gcs_path, \"us.bigframes-default-connection\")), \"r\")),\n", - " connection_id=>\"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", - "\"\"\")" - ] + "outputs": [], + "source": [] } ], "metadata": { "kernelspec": { - "display_name": ".venv", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -828,7 +333,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.13.0" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/dataframes/dataframe.ipynb b/notebooks/dataframes/dataframe.ipynb index f26b4ff1cf1..de9bb1d04f4 100644 --- a/notebooks/dataframes/dataframe.ipynb +++ b/notebooks/dataframes/dataframe.ipynb @@ -49,7 +49,7 @@ "id": "13861abc-120c-4db6-ad0c-e414b85d3443", "metadata": {}, "source": [ - "## Select a subset of the DF" + "### Select a subset of the DF" ] }, { diff --git a/notebooks/dataframes/index_col_null.ipynb b/notebooks/dataframes/index_col_null.ipynb index f77051e553b..655745dd2be 100644 --- a/notebooks/dataframes/index_col_null.ipynb +++ b/notebooks/dataframes/index_col_null.ipynb @@ -358,7 +358,7 @@ "id": "13861abc-120c-4db6-ad0c-e414b85d3443", "metadata": {}, "source": [ - "## Select a subset of the DataFrame", + "### Select a subset of the DataFrame\n", "\n", "Filter columns by selecting a list of columns from the DataFrame.\n", "\n", diff --git a/notebooks/dataframes/magics_with_local_data.ipynb b/notebooks/dataframes/magics_with_local_data.ipynb deleted file mode 100644 index 675ac83988b..00000000000 --- a/notebooks/dataframes/magics_with_local_data.ipynb +++ /dev/null @@ -1,2488 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "c5f9e86e", - "metadata": {}, - "outputs": [], - "source": [ - "# Copyright 2026 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": "71383fa0", - "metadata": {}, - "source": [ - "# Unlock SQL and Python interoperability for BigQuery with %%bqsql magic\n", - "\n", - "In this tutorial, you will learn how to seamlessly chain data processing across\n", - "SQL and Python code cells using `%%bqsql` IPython magic and BigQuery DataFrames\n", - "(BigFrames). This interoperability is now available to all Jupyter users,\n", - "whether you're in Colab, JupyterLab, or VS Code. \n", - "\n", - "While we begin by loading a local Excel dataset into a local Pandas DataFrame,\n", - "the main focus is on how you can transition between Pandas' Python-centric API\n", - "and BigQuery's SQL-centric engine. This hybrid workflow combines the best of\n", - "both worlds: the expressive power of SQL for complex transformations and the\n", - "versatile Python ecosystem for visualization and further analysis.\n", - "\n", - "Thanks to open-source packages like Jupyter, Pandas, BigFrames, and the\n", - "[BigQuery sandbox](https://docs.cloud.google.com/bigquery/docs/sandbox), you can\n", - "follow all steps in this guide for free\\* and without a credit card.\n", - "\n", - "_\\*See the [BigQuery sandbox](https://docs.cloud.google.com/bigquery/docs/sandbox) documentation for limitations._\n", - "\n", - "## The %%bqsql Magic\n", - "\n", - "Last year, Google introduced [SQL cells in Colab Enterprise\n", - "notebooks](https://docs.cloud.google.com/colab/docs/sql-cells). Now, with the\n", - "[%%bqsql cell\n", - "magics](https://dataframes.bigquery.dev/notebooks/getting_started/magics.html)\n", - "in BigQuery DataFrames, this same powerful interoperability is available to all\n", - "Jupyter users, whether you're in Colab, JupyterLab, or VS Code. These magics\n", - "allow you to write SQL queries that run directly on local pandas DataFrames,\n", - "BigFrames DataFrames, or BigQuery tables.\n", - "\n", - "\n", - "## Getting Started\n", - "\n", - "To get started,\n", - "\n", - "1. Enable the [BigQuery\n", - " sandbox](https://docs.cloud.google.com/bigquery/docs/sandbox). Make note of your\n", - " Google Cloud project ID.\n", - "\n", - "2. Set up a local Python development environment (see: [Setting up a Python\n", - " development environment](https://docs.cloud.google.com/python/docs/setup)) for\n", - " Google Cloud.\n", - "\n", - "3. Create and activate a venv to isolate Python dependencies.\n", - " On Linux or macOS, use these commands (update to your preferred Python\n", - " version):\n", - "\n", - " ```\n", - " python3.12 -m venv ~/venv\n", - " . ~/venv/bin/activate\n", - " ```\n", - "\n", - "4. Install the Jupyter, bigframes, and python-calamine packages:\n", - "\n", - " ```\n", - " pip install --upgrade jupyterlab bigframes python-calamine\n", - " ```\n", - "\n", - "5. Start Jupyter Lab.\n", - "\n", - " ```\n", - " jupyter lab\n", - " ```\n", - "\n", - "6. Open a web browser to the URL listed in the output. It will be something like\n", - " `http://localhost:8888/lab?token=somesupersecretvaluehere`.\n", - "\n", - "7. Create a new notebook using the Jupyter Lab UI.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d00aeb28", - "metadata": {}, - "outputs": [], - "source": [ - "%pip install python-calamine pandas bigframes" - ] - }, - { - "cell_type": "markdown", - "id": "5ba39d0d", - "metadata": {}, - "source": [ - "## Accessing the Dataset\n", - "\n", - "In this tutorial, you'll analyze the [USDA wheat\n", - "data](https://www.ers.usda.gov/data-products/wheat-data). Use the standard\n", - "`requests` package to download the data to a temporary file, mimicking a typical\n", - "local data analysis workflow.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "fb1dfdc2", - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "0" - ] - }, - "execution_count": 1, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "\n", - "import tempfile\n", - "\n", - "import requests\n", - "\n", - "url = \"https://www.ers.usda.gov/media/5706/wheat-data-all-years.xlsx?v=52690\"\n", - "\n", - "tmp = tempfile.NamedTemporaryFile(delete=True)\n", - "\n", - "with requests.get(url, stream=True) as r:\n", - " r.raise_for_status()\n", - " for chunk in r.iter_content(chunk_size=8192):\n", - " tmp.write(chunk)\n", - "\n", - "tmp.flush()\n", - "tmp.seek(0)" - ] - }, - { - "cell_type": "markdown", - "id": "50f896bb", - "metadata": {}, - "source": [ - "Use the `pyarrow` `dtype_backend` when preparing local Pandas data for SQL\n", - "processing. This ensures more consistent handling of NULL values and seamless\n", - "schema mapping when you hand off the data to the BigQuery SQL engine. For this\n", - "example, read the 'Table05' sheet, which contains annual wheat supply and\n", - "disappearance data:\n" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "8a8a137b", - "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", - "
Marketing year 1/Time periodBeginning stocksProductionImports 2/Total supply 3/Food useSeed useFeed and residual useTotal domestic use 3/Exports 2/Total disappearance 3/Ending stocks
01950/51MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.0
11951/52MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.0
21952/53MY Jun-May330.01306.024.01660.0578.0--78.0656.0332.0988.0672.0
31953/54MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.0
41954/55MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.0
..........................................
2811/ June–May. Latest data may be preliminary or...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
2822/ Includes flour and selected other products ...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
2833/ Totals may not add due to rounding.<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
284Source: USDA, Economic Research Service, based...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
285Updated: May 12, 2026<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
\n", - "

286 rows × 13 columns

\n", - "
" - ], - "text/plain": [ - " Marketing year 1/ Time period \\\n", - "0 1950/51 MY Jun-May \n", - "1 1951/52 MY Jun-May \n", - "2 1952/53 MY Jun-May \n", - "3 1953/54 MY Jun-May \n", - "4 1954/55 MY Jun-May \n", - ".. ... ... \n", - "281 1/ June–May. Latest data may be preliminary or... \n", - "282 2/ Includes flour and selected other products ... \n", - "283 3/ Totals may not add due to rounding. \n", - "284 Source: USDA, Economic Research Service, based... \n", - "285 Updated: May 12, 2026 \n", - "\n", - " Beginning stocks Production Imports 2/ Total supply 3/ Food use \\\n", - "0 496.0 1019.0 11.0 1526.0 580.0 \n", - "1 492.0 988.0 30.0 1510.0 585.0 \n", - "2 330.0 1306.0 24.0 1660.0 578.0 \n", - "3 672.0 1173.0 6.0 1851.0 556.0 \n", - "4 994.0 984.0 3.0 1981.0 552.0 \n", - ".. ... ... ... ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - " Seed use Feed and residual use Total domestic use 3/ Exports 2/ \\\n", - "0 -- 109.0 689.0 345.0 \n", - "1 -- 110.0 695.0 485.0 \n", - "2 -- 78.0 656.0 332.0 \n", - "3 -- 87.0 643.0 214.0 \n", - "4 -- 53.0 605.0 267.0 \n", - ".. ... ... ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - " Total disappearance 3/ Ending stocks \n", - "0 1034.0 492.0 \n", - "1 1180.0 330.0 \n", - "2 988.0 672.0 \n", - "3 857.0 994.0 \n", - "4 872.0 1109.0 \n", - ".. ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - "[286 rows x 13 columns]" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "\n", - "import pandas as pd\n", - "\n", - "df = pd.read_excel(\n", - " tmp,\n", - " sheet_name=\"Table05\",\n", - " dtype_backend=\"pyarrow\",\n", - " engine=\"calamine\",\n", - " header=1, # Skip the first row.\n", - ")\n", - "tmp.close()\n", - "df" - ] - }, - { - "cell_type": "markdown", - "id": "1a7ec573", - "metadata": {}, - "source": [ - "## Preparing the data\n", - "\n", - "Before querying the local DataFrame with SQL, ensure that the column names are\n", - "SQL-friendly. BigQuery supports [flexible column\n", - "names](https://docs.cloud.google.com/bigquery/docs/schemas#flexible-column-names),\n", - "allowing most unicode characters, but special characters like \"/\" and \"\\\" must\n", - "be removed or replaced.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "d5674020", - "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", - "
Marketing year 1Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocks
01950/51MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.0
11951/52MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.0
21952/53MY Jun-May330.01306.024.01660.0578.0--78.0656.0332.0988.0672.0
31953/54MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.0
41954/55MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.0
..........................................
2811/ June–May. Latest data may be preliminary or...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
2822/ Includes flour and selected other products ...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
2833/ Totals may not add due to rounding.<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
284Source: USDA, Economic Research Service, based...<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
285Updated: May 12, 2026<NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA><NA>
\n", - "

286 rows × 13 columns

\n", - "
" - ], - "text/plain": [ - " Marketing year 1 Time period \\\n", - "0 1950/51 MY Jun-May \n", - "1 1951/52 MY Jun-May \n", - "2 1952/53 MY Jun-May \n", - "3 1953/54 MY Jun-May \n", - "4 1954/55 MY Jun-May \n", - ".. ... ... \n", - "281 1/ June–May. Latest data may be preliminary or... \n", - "282 2/ Includes flour and selected other products ... \n", - "283 3/ Totals may not add due to rounding. \n", - "284 Source: USDA, Economic Research Service, based... \n", - "285 Updated: May 12, 2026 \n", - "\n", - " Beginning stocks Production Imports 2 Total supply 3 Food use \\\n", - "0 496.0 1019.0 11.0 1526.0 580.0 \n", - "1 492.0 988.0 30.0 1510.0 585.0 \n", - "2 330.0 1306.0 24.0 1660.0 578.0 \n", - "3 672.0 1173.0 6.0 1851.0 556.0 \n", - "4 994.0 984.0 3.0 1981.0 552.0 \n", - ".. ... ... ... ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - " Seed use Feed and residual use Total domestic use 3 Exports 2 \\\n", - "0 -- 109.0 689.0 345.0 \n", - "1 -- 110.0 695.0 485.0 \n", - "2 -- 78.0 656.0 332.0 \n", - "3 -- 87.0 643.0 214.0 \n", - "4 -- 53.0 605.0 267.0 \n", - ".. ... ... ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - " Total disappearance 3 Ending stocks \n", - "0 1034.0 492.0 \n", - "1 1180.0 330.0 \n", - "2 988.0 672.0 \n", - "3 857.0 994.0 \n", - "4 872.0 1109.0 \n", - ".. ... ... \n", - "281 \n", - "282 \n", - "283 \n", - "284 \n", - "285 \n", - "\n", - "[286 rows x 13 columns]" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df.columns = [name.replace(\"/\", \"\") for name in df.columns]\n", - "df" - ] - }, - { - "cell_type": "markdown", - "id": "b50c5798", - "metadata": {}, - "source": [ - "## Filtering with Pandas\n", - "\n", - "Perform a basic filter using standard Python/Pandas syntax to remove rows with missing data. This represents the initial Python-only stage of a processing chain.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "1dbad481", - "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", - "
Marketing year 1Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocks
01950/51MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.0
11951/52MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.0
21952/53MY Jun-May330.01306.024.01660.0578.0--78.0656.0332.0988.0672.0
31953/54MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.0
41954/55MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.0
..........................................
2752025/26MY Jun-May854.7341984.537125.02964.271960.059.7100.01119.7910.02029.7934.571
2762025/26Q1 Jun-Aug854.7341984.53730.5932869.864241.0912.653239.522483.266252.58735.8462134.018
2772025/26Q2 Sep-Nov2134.0180.030.0782164.096245.5839.658-54.047231.191255.802486.9931677.103
2782025/26Q3 Dec-Feb1677.1030.032.3631709.466230.9751.75-24.747207.978201.291409.2691300.197
2792026/27MY Jun-May934.5711561.322140.02635.893960.05980.01099.0775.01874.0761.893
\n", - "

280 rows × 13 columns

\n", - "
" - ], - "text/plain": [ - " Marketing year 1 Time period Beginning stocks Production Imports 2 \\\n", - "0 1950/51 MY Jun-May 496.0 1019.0 11.0 \n", - "1 1951/52 MY Jun-May 492.0 988.0 30.0 \n", - "2 1952/53 MY Jun-May 330.0 1306.0 24.0 \n", - "3 1953/54 MY Jun-May 672.0 1173.0 6.0 \n", - "4 1954/55 MY Jun-May 994.0 984.0 3.0 \n", - ".. ... ... ... ... ... \n", - "275 2025/26 MY Jun-May 854.734 1984.537 125.0 \n", - "276 2025/26 Q1 Jun-Aug 854.734 1984.537 30.593 \n", - "277 2025/26 Q2 Sep-Nov 2134.018 0.0 30.078 \n", - "278 2025/26 Q3 Dec-Feb 1677.103 0.0 32.363 \n", - "279 2026/27 MY Jun-May 934.571 1561.322 140.0 \n", - "\n", - " Total supply 3 Food use Seed use Feed and residual use \\\n", - "0 1526.0 580.0 -- 109.0 \n", - "1 1510.0 585.0 -- 110.0 \n", - "2 1660.0 578.0 -- 78.0 \n", - "3 1851.0 556.0 -- 87.0 \n", - "4 1981.0 552.0 -- 53.0 \n", - ".. ... ... ... ... \n", - "275 2964.271 960.0 59.7 100.0 \n", - "276 2869.864 241.091 2.653 239.522 \n", - "277 2164.096 245.58 39.658 -54.047 \n", - "278 1709.466 230.975 1.75 -24.747 \n", - "279 2635.893 960.0 59 80.0 \n", - "\n", - " Total domestic use 3 Exports 2 Total disappearance 3 Ending stocks \n", - "0 689.0 345.0 1034.0 492.0 \n", - "1 695.0 485.0 1180.0 330.0 \n", - "2 656.0 332.0 988.0 672.0 \n", - "3 643.0 214.0 857.0 994.0 \n", - "4 605.0 267.0 872.0 1109.0 \n", - ".. ... ... ... ... \n", - "275 1119.7 910.0 2029.7 934.571 \n", - "276 483.266 252.58 735.846 2134.018 \n", - "277 231.191 255.802 486.993 1677.103 \n", - "278 207.978 201.291 409.269 1300.197 \n", - "279 1099.0 775.0 1874.0 761.893 \n", - "\n", - "[280 rows x 13 columns]" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "full_rows = df[~df['Beginning stocks'].isna()]\n", - "full_rows" - ] - }, - { - "cell_type": "markdown", - "id": "e914ce69", - "metadata": {}, - "source": [ - "## Interoperate with SQL using the BigQuery SQL magics (%%bqsql)\n", - "\n", - "The BigQuery DataFrames library provides the `%%bqsql` magic, which acts as the bridge between your Python and SQL environments. It allows the BigQuery query engine to directly reference and query your local Pandas DataFrames (by implicitly uploading them as temporary tables) as well as actual BigQuery tables and external tables in GCS (Parquet, Iceberg, CSV).\n", - "\n", - "To enable this integration in your notebook, load the `bigframes` extension. This is already completed in BigQuery Studio, Colab Enterprise, and Colab notebooks. For other environments, such as VS Code and Jupyter Lab, run the following cell:\n" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "3d837a5e", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext bigframes\n" - ] - }, - { - "cell_type": "markdown", - "id": "315a53b5", - "metadata": {}, - "source": [ - "To ensure the correct Google Cloud project is billed for query usage, including free tier usage, configure the project ID used by the magics. Even in the free sandbox tier, a project ID is required to allocate query resources. If you don't set it explicitly, BigFrames will try to discover it from your environment (e.g., your Application Default Credentials).\n" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "ffe5757c", - "metadata": {}, - "outputs": [], - "source": [ - "import bigframes.pandas as bpd\n", - "\n", - "PROJECT_ID = \"\" # @param {type:\"string\"}\n", - "bpd.options.bigquery.project = PROJECT_ID\n" - ] - }, - { - "cell_type": "markdown", - "id": "fe174ed2", - "metadata": {}, - "source": [ - "### Querying Local Pandas DataFrames with SQL\n", - "\n", - "With the project configured, you can now run SQL queries directly against your local Pandas DataFrame (`full_rows`) as if it were a table in BigQuery. Simply reference the variable name inside braces `{full_rows}` in your SQL query.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "fbbf52d6", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes. [Job bigframes-dev:US.c3c67902-6a45-492a-9491-a91daddaada1 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Load job c22ec1ce-09da-4ea1-b0a0-f28eee65aa20 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 30.0 kB in a moment of slot time.\n", - " " - ], - "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", - "
Marketing year 1Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocks
01980/81Q2 Sep-Nov2714.00.00.62714.6162.1764.865242.965379.335622.32092.3
11987/88Q2 Sep-Nov2976.4620.04.5252980.987193.04858-79.082171.966308.453480.4192500.568
22014/15Q2 Sep-Nov1907.220.034.5511941.771248.18748.802-92.585204.404207.737412.1411529.63
31976/77Q2 Sep-Nov2385.20.00.52385.7153.064-2.795214.205277.295491.51894.2
41994/95Q2 Sep-Nov2069.4940.021.4232090.917229.29760.954-28.64261.611338.202599.8131491.104
52002/03Q2 Sep-Nov1748.9870.023.0871772.074237.75454.599-74.678217.675234.53452.2051319.869
62007/08Q2 Sep-Nov1716.9270.021.4861738.413245.02659.915-119.882185.059421.416606.4751131.938
72025/26Q2 Sep-Nov2134.0180.030.0782164.096245.5839.658-54.047231.191255.802486.9931677.103
81995/96Q2 Sep-Nov1881.0990.016.2521897.351232.15164.356-98.182198.325360.759559.0841338.267
92001/02Q2 Sep-Nov2155.8140.029.042184.854245.08851.601-23.073273.616287.783561.3991623.455
\n", - "

10 rows × 13 columns

\n", - "
[280 rows x 13 columns in total]" - ], - "text/plain": [ - " Marketing year 1 Time period Beginning stocks Production Imports 2 \\\n", - "0 1980/81 Q2 Sep-Nov 2714.0 0.0 0.6 \n", - "1 1987/88 Q2 Sep-Nov 2976.462 0.0 4.525 \n", - "2 2014/15 Q2 Sep-Nov 1907.22 0.0 34.551 \n", - "3 1976/77 Q2 Sep-Nov 2385.2 0.0 0.5 \n", - "4 1994/95 Q2 Sep-Nov 2069.494 0.0 21.423 \n", - "5 2002/03 Q2 Sep-Nov 1748.987 0.0 23.087 \n", - "6 2007/08 Q2 Sep-Nov 1716.927 0.0 21.486 \n", - "7 2025/26 Q2 Sep-Nov 2134.018 0.0 30.078 \n", - "8 1995/96 Q2 Sep-Nov 1881.099 0.0 16.252 \n", - "9 2001/02 Q2 Sep-Nov 2155.814 0.0 29.04 \n", - "\n", - " Total supply 3 Food use Seed use Feed and residual use \\\n", - "0 2714.6 162.1 76 4.865 \n", - "1 2980.987 193.048 58 -79.082 \n", - "2 1941.771 248.187 48.802 -92.585 \n", - "3 2385.7 153.0 64 -2.795 \n", - "4 2090.917 229.297 60.954 -28.64 \n", - "5 1772.074 237.754 54.599 -74.678 \n", - "6 1738.413 245.026 59.915 -119.882 \n", - "7 2164.096 245.58 39.658 -54.047 \n", - "8 1897.351 232.151 64.356 -98.182 \n", - "9 2184.854 245.088 51.601 -23.073 \n", - "\n", - " Total domestic use 3 Exports 2 Total disappearance 3 Ending stocks \n", - "0 242.965 379.335 622.3 2092.3 \n", - "1 171.966 308.453 480.419 2500.568 \n", - "2 204.404 207.737 412.141 1529.63 \n", - "3 214.205 277.295 491.5 1894.2 \n", - "4 261.611 338.202 599.813 1491.104 \n", - "5 217.675 234.53 452.205 1319.869 \n", - "6 185.059 421.416 606.475 1131.938 \n", - "7 231.191 255.802 486.993 1677.103 \n", - "8 198.325 360.759 559.084 1338.267 \n", - "9 273.616 287.783 561.399 1623.455 \n", - "...\n", - "\n", - "[280 rows x 13 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql\n", - "SELECT * FROM {full_rows}\n" - ] - }, - { - "cell_type": "markdown", - "id": "2fcd5284", - "metadata": {}, - "source": [ - "You should see the results from full_rows.\n", - "\n", - "\n", - "## Chaining SQL and Python: Saving SQL Results\n", - "\n", - "The true power of the `%%bqsql` magic lies in chaining. By providing a destination variable name as an argument to `%%bqsql` (e.g., `%%bqsql destination_var`), the query result is saved as a BigQuery DataFrame (a.k.a. BigFrames DataFrame) to that variable. \n", - "\n", - "This DataFrame lives on the BigQuery engine but behaves like a Pandas DataFrame in Python. You can immediately use it in subsequent Python cells, or reference it again in another SQL cell. This allows you to build a multi-step, hybrid processing pipeline.\n", - "\n", - "Filter the data to only yearly entries using SQL, and save the result into a new BigFrames DataFrame named `yearly`:\n" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "75fe0e10", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time. [Job bigframes-dev:US.71850fc1-147f-44f7-b4c0-b94592f55639 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Load job aaa74c26-b3ff-422f-a670-93222188fe9f is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 30.0 kB in a moment of slot time.\n", - " " - ], - "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", - "
Marketing year 1Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocks
01955/56MY Jun-May1109.0937.010.02056.0553.0--51.0604.0322.0926.01130.0
11957/58MY Jun-May1004.0956.010.01970.0547.0--43.0590.0418.01008.0962.0
21954/55MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.0
31951/52MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.0
41956/57MY Jun-May1130.01005.08.02143.0541.0--57.0598.0541.01139.01004.0
51950/51MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.0
61962/63MY Jun-May1420.61092.05.32517.9502.761.434.7598.8649.41248.21269.7
71959/60MY Jun-May1368.01118.07.02493.0558.0--49.0607.0502.01109.01384.0
81963/64MY Jun-May1269.71146.84.02420.5487.964.928.6581.4845.61427.0993.5
91953/54MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.0
\n", - "

10 rows × 13 columns

\n", - "
[77 rows x 13 columns in total]" - ], - "text/plain": [ - " Marketing year 1 Time period Beginning stocks Production Imports 2 \\\n", - "0 1955/56 MY Jun-May 1109.0 937.0 10.0 \n", - "1 1957/58 MY Jun-May 1004.0 956.0 10.0 \n", - "2 1954/55 MY Jun-May 994.0 984.0 3.0 \n", - "3 1951/52 MY Jun-May 492.0 988.0 30.0 \n", - "4 1956/57 MY Jun-May 1130.0 1005.0 8.0 \n", - "5 1950/51 MY Jun-May 496.0 1019.0 11.0 \n", - "6 1962/63 MY Jun-May 1420.6 1092.0 5.3 \n", - "7 1959/60 MY Jun-May 1368.0 1118.0 7.0 \n", - "8 1963/64 MY Jun-May 1269.7 1146.8 4.0 \n", - "9 1953/54 MY Jun-May 672.0 1173.0 6.0 \n", - "\n", - " Total supply 3 Food use Seed use Feed and residual use \\\n", - "0 2056.0 553.0 -- 51.0 \n", - "1 1970.0 547.0 -- 43.0 \n", - "2 1981.0 552.0 -- 53.0 \n", - "3 1510.0 585.0 -- 110.0 \n", - "4 2143.0 541.0 -- 57.0 \n", - "5 1526.0 580.0 -- 109.0 \n", - "6 2517.9 502.7 61.4 34.7 \n", - "7 2493.0 558.0 -- 49.0 \n", - "8 2420.5 487.9 64.9 28.6 \n", - "9 1851.0 556.0 -- 87.0 \n", - "\n", - " Total domestic use 3 Exports 2 Total disappearance 3 Ending stocks \n", - "0 604.0 322.0 926.0 1130.0 \n", - "1 590.0 418.0 1008.0 962.0 \n", - "2 605.0 267.0 872.0 1109.0 \n", - "3 695.0 485.0 1180.0 330.0 \n", - "4 598.0 541.0 1139.0 1004.0 \n", - "5 689.0 345.0 1034.0 492.0 \n", - "6 598.8 649.4 1248.2 1269.7 \n", - "7 607.0 502.0 1109.0 1384.0 \n", - "8 581.4 845.6 1427.0 993.5 \n", - "9 643.0 214.0 857.0 994.0 \n", - "...\n", - "\n", - "[77 rows x 13 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql yearly\n", - "SELECT *\n", - "FROM {full_rows}\n", - "WHERE STARTS_WITH(`Time period`, 'MY')\n" - ] - }, - { - "cell_type": "markdown", - "id": "19a70e9e", - "metadata": {}, - "source": [ - "### Chaining Step 2: Complex SQL Transformation on the BigFrames DataFrame\n", - "\n", - "Now, you can chain another SQL operation. Reference the `yearly` BigFrames DataFrame that you just created, extract the year using SQL regular expressions, cast it to a timestamp, and save the results into a new BigFrames DataFrame named `timeseries`.\n", - "\n", - "Notice how you are building a chain: Local Pandas -> [SQL filter] -> BigFrames `yearly` -> [SQL transform] -> BigFrames `timeseries`.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "8fbb5224", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time. [Job bigframes-dev:US.fdcdabc9-e1a3-47e6-ab27-9e19d9aaa106 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Load job ec6be16a-722d-4151-b7a6-5e410557577d is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 8.3 kB in a moment of slot time.\n", - " " - ], - "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", - "
Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocksyear
0MY Jun-May1004.0956.010.01970.0547.0--43.0590.0418.01008.0962.01957-01-01 00:00:00+00:00
1MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.01953-01-01 00:00:00+00:00
2MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.01950-01-01 00:00:00+00:00
3MY Jun-May330.01306.024.01660.0578.0--78.0656.0332.0988.0672.01952-01-01 00:00:00+00:00
4MY Jun-May962.01457.08.02427.0561.0--48.0609.0450.01059.01368.01958-01-01 00:00:00+00:00
5MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.01954-01-01 00:00:00+00:00
6MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.01951-01-01 00:00:00+00:00
7MY Jun-May1130.01005.08.02143.0541.0--57.0598.0541.01139.01004.01956-01-01 00:00:00+00:00
8MY Jun-May1109.0937.010.02056.0553.0--51.0604.0322.0926.01130.01955-01-01 00:00:00+00:00
9MY Jun-May1368.01118.07.02493.0558.0--49.0607.0502.01109.01384.01959-01-01 00:00:00+00:00
\n", - "

10 rows × 13 columns

\n", - "
[77 rows x 13 columns in total]" - ], - "text/plain": [ - " Time period Beginning stocks Production Imports 2 Total supply 3 \\\n", - "0 MY Jun-May 1004.0 956.0 10.0 1970.0 \n", - "1 MY Jun-May 672.0 1173.0 6.0 1851.0 \n", - "2 MY Jun-May 496.0 1019.0 11.0 1526.0 \n", - "3 MY Jun-May 330.0 1306.0 24.0 1660.0 \n", - "4 MY Jun-May 962.0 1457.0 8.0 2427.0 \n", - "5 MY Jun-May 994.0 984.0 3.0 1981.0 \n", - "6 MY Jun-May 492.0 988.0 30.0 1510.0 \n", - "7 MY Jun-May 1130.0 1005.0 8.0 2143.0 \n", - "8 MY Jun-May 1109.0 937.0 10.0 2056.0 \n", - "9 MY Jun-May 1368.0 1118.0 7.0 2493.0 \n", - "\n", - " Food use Seed use Feed and residual use Total domestic use 3 Exports 2 \\\n", - "0 547.0 -- 43.0 590.0 418.0 \n", - "1 556.0 -- 87.0 643.0 214.0 \n", - "2 580.0 -- 109.0 689.0 345.0 \n", - "3 578.0 -- 78.0 656.0 332.0 \n", - "4 561.0 -- 48.0 609.0 450.0 \n", - "5 552.0 -- 53.0 605.0 267.0 \n", - "6 585.0 -- 110.0 695.0 485.0 \n", - "7 541.0 -- 57.0 598.0 541.0 \n", - "8 553.0 -- 51.0 604.0 322.0 \n", - "9 558.0 -- 49.0 607.0 502.0 \n", - "\n", - " Total disappearance 3 Ending stocks year \n", - "0 1008.0 962.0 1957-01-01 00:00:00+00:00 \n", - "1 857.0 994.0 1953-01-01 00:00:00+00:00 \n", - "2 1034.0 492.0 1950-01-01 00:00:00+00:00 \n", - "3 988.0 672.0 1952-01-01 00:00:00+00:00 \n", - "4 1059.0 1368.0 1958-01-01 00:00:00+00:00 \n", - "5 872.0 1109.0 1954-01-01 00:00:00+00:00 \n", - "6 1180.0 330.0 1951-01-01 00:00:00+00:00 \n", - "7 1139.0 1004.0 1956-01-01 00:00:00+00:00 \n", - "8 926.0 1130.0 1955-01-01 00:00:00+00:00 \n", - "9 1109.0 1384.0 1959-01-01 00:00:00+00:00 \n", - "...\n", - "\n", - "[77 rows x 13 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql timeseries\n", - "SELECT\n", - " * EXCEPT (`Marketing year 1`),\n", - " TIMESTAMP(CONCAT(\n", - " REGEXP_EXTRACT(`Marketing year 1`, r'([0-9]+)\\/'),\n", - " '-01-01')) AS `year`\n", - "FROM {yearly}\n" - ] - }, - { - "cell_type": "markdown", - "id": "76ba8a7d", - "metadata": {}, - "source": [ - "## Chaining Back to Python: Visualizing BigFrames Data\n", - "\n", - "Now that you've completed some SQL transformations, you can chain back to Python for visualization. Because BigFrames DataFrames implement the Pandas API, you can call standard visualization methods (like `.plot.line()`) directly on the `timeseries` DataFrame without downloading the full dataset first. The computations happen in BigQuery, and only the summarized chart data is sent back to the notebook.\n" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "d3ff4eec", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 8.8 kB in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "Load job c6b4d65a-4555-4efc-9f8b-7156f4c62835 is DONE. Open Job" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjAAAAGwCAYAAAC3qV8qAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjksIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvJkbTWQAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnXd4FGXXh+/Zmk3Z9JCEBAiE3psSUKlSBARBxPJRFPRFUcQCigVFVCxgVywooIgKFlREigioFEGQIqGGQAIklPS2fb4/Zneym56QkATmvq65ksw8M/PMbnbnzDm/c44giqKIgoKCgoKCgkI9QlXbE1BQUFBQUFBQqCyKAaOgoKCgoKBQ71AMGAUFBQUFBYV6h2LAKCgoKCgoKNQ7FANGQUFBQUFBod6hGDAKCgoKCgoK9Q7FgFFQUFBQUFCod2hqewI1hcPh4OzZs/j5+SEIQm1PR0FBQUFBQaECiKJITk4OkZGRqFSl+1muWAPm7NmzREdH1/Y0FBQUFBQUFKpAcnIyUVFRpW6/Yg0YPz8/QHoBjEZjLc9GQUFBQUFBoSJkZ2cTHR0t38dL44o1YFxhI6PRqBgwCgoKCgoK9Yzy5B+KiFdBQUFBQUGh3qEYMAoKCgoKCgr1DsWAUVBQUFBQUKh3XLEamIpit9uxWq21PQ0FhTqHVqtFrVbX9jQUFBQUSuSqNWBEUSQ1NZXMzMzanoqCQp0lICCA8PBwpZaSgoJCneOqNWBcxktYWBje3t7KF7SCghuiKJKfn8/58+cBiIiIqOUZKSgoKHhyVRowdrtdNl6Cg4NrezoKCnUSg8EAwPnz5wkLC1PCSQoKCnWKq1LE69K8eHt71/JMFBTqNq7PiKITU1BQqGtclQaMCyVspKBQNspnREFBoa5yVRswCgoKCgoKCvUTxYBRUFBQUFBQqHcoBoxCifTp04fp06dX6zGXLFlCQEBAtR6zrlATr5eCgoKCQulckgHzyiuvIAiCxxe3yWRi6tSpBAcH4+vry+jRozl37pzHfklJSQwdOhRvb2/CwsKYMWMGNpvNY8zmzZvp0qULer2e2NhYlixZcilTvSKYOHEigiDIS3BwMIMHD2b//v3Vfq7vv/+euXPnVusxx44dy9GjR6v1mJXlSjai6jOixYLDYqntaSgoKNQjqmzA7Nq1i48++ogOHTp4rH/kkUf4+eefWblyJVu2bOHs2bOMGjVK3m632xk6dCgWi4Vt27axdOlSlixZwuzZs+UxiYmJDB06lL59+7J3716mT5/O5MmTWbduXVWne8UwePBgUlJSSElJYePGjWg0GoYNG1bt5wkKCiq3lXllMRgMhIWFVesxFeo/jrw8EobcxInBQ7AkJdX2dBQUFOoLYhXIyckRmzdvLm7YsEHs3bu3+PDDD4uiKIqZmZmiVqsVV65cKY89dOiQCIjbt28XRVEU16xZI6pUKjE1NVUes3DhQtFoNIpms1kURVGcOXOm2LZtW49zjh07Vhw0aFCpczKZTGJWVpa8JCcni4CYlZVVbGxBQYEYHx8vFhQUiKIoig6HQ8wzW2tlcTgcFX7dJ0yYII4YMcJj3Z9//ikC4vnz5+V1SUlJ4pgxY0R/f38xMDBQvPnmm8XExER5u9VqFR966CHR399fDAoKEmfOnCmOHz/e49ju76soimLjxo3Fl156Sbz77rtFX19fMTo6Wvzoo4/k7YmJiSIgfvfdd2KfPn1Eg8EgdujQQdy2bZs8ZvHixaK/v7/893PPPSd27NhR/Pzzz8XGjRuLRqNRHDt2rJidnS2Pyc7OFu+8807R29tbDA8PF994441icyvK3r17xT59+oi+vr6in5+f2KVLF3HXrl3ipk2bRMBjee6550RRFMX09HRx3LhxYkBAgGgwGMTBgweLR48e9TjuX3/9Jfbu3Vs0GAxiQECAOHDgQDE9Pb3E12v16tWi0WgUly1bJoqiKG7atEns3r276O3tLfr7+4s9e/YUT548Weo11BWKflYqgi07W0x+eLp4cvwE0ZaTU+74i4sXi/EtW4nxLVuJR/v0Fc3JyZcyZQUFhXpOVlZWqfdvd6pUyG7q1KkMHTqUAQMG8OKLL8rrd+/ejdVqZcCAAfK6Vq1a0ahRI7Zv306PHj3Yvn077du3p0GDBvKYQYMGcf/993Pw4EE6d+7M9u3bPY7hGlOWxmDevHnMmTOnKpdDgdVOm9m1492Jf2EQ3rqq1RPMzc1l2bJlxMbGygX5rFYrgwYNIi4ujj///BONRsOLL74oh5p0Oh2vvvoqX375JYsXL6Z169a8/fbbrFq1ir59+5Z5vgULFjB37lyeeuopvv32W+6//3569+5Ny5Yt5TFPP/008+fPp3nz5jz99NPccccdHD9+HI2m5GtMSEhg1apVrF69moyMDG677TZeeeUVXnrpJQAeffRRtm7dyk8//USDBg2YPXs2e/bsoVOnTqXO86677qJz584sXLgQtVrN3r170Wq19OzZk7feeovZs2dz5MgRAHx9fQEpPHfs2DF++uknjEYjTzzxBDfddBPx8fFotVr27t1L//79ueeee3j77bfRaDRs2rQJu91e7PzLly9nypQpLF++nGHDhmGz2Rg5ciT33nsvX331FRaLhZ07d16RKcrW8+dJvu9/mA8fBiB96VJCp04tdbxosZC+eAkAKh8fbCkpJE2YSOPPl6Jt2PByTFlBQaGeUuk759dff82ePXvYtWtXsW2pqanodLpiGoMGDRqQmpoqj3E3XlzbXdvKGpOdnU1BQYFcIdSdWbNm8eijj8p/Z2dnEx0dXdnLq/OsXr1avunm5eURERHB6tWrUamkaOA333yDw+Fg0aJF8g1y8eLFBAQEsHnzZgYOHMi7777LrFmzuOWWWwB47733WLNmTbnnvummm3jggQcAeOKJJ3jzzTfZtGmThwHz+OOPM3ToUADmzJlD27ZtOX78OK1atSrxmA6HgyVLlsjhqnHjxrFx40ZeeuklcnJyWLp0KcuXL6d///7ytURGRpY5z6SkJGbMmCGfs3nz5vI2f39/BEEgPDxcXucyXLZu3UrPnj0B+PLLL4mOjmbVqlWMGTOG1157jW7duvHBBx/I+7Vt27bYud9//32efvppfv75Z3r37g1I/4tZWVkMGzaMZs2aAdC6desyr6E+Yjl5kqRJk7GeOYNgMCAWFJC+eAlBd92FuhTdUdbPq7GdO4cmNJTGX31F8qRJWE6d4tTEu2n8xedo3d4nBQUFBXcqZcAkJyfz8MMPs2HDBry8vGpqTlVCr9ej1+urtK9Bqyb+hUHVPKOKn7sy9O3bl4ULFwKQkZHBBx98wJAhQ9i5cyeNGzdm3759HD9+vJh+xWQykZCQQFZWFufOneOaa66Rt6nVarp27YrD4Sjz3O56J5cR4OqVU9IYV/+c8+fPl2rANGnSxGOuERER8jFPnDiB1Wr1mKu/v7+HwVQSjz76KJMnT+aLL75gwIABjBkzRjYcSuLQoUNoNBquvfZaeV1wcDAtW7bk0KFDAOzdu5cxY8aUed5vv/2W8+fPs3XrVrp37y6vDwoKYuLEiQwaNIgbb7yRAQMGcNttt11R/YUKDvxH8v/+hz09HW2jRjT65GNOPzwd8+HDpH36GWGPPVpsH9HhIG3RIgCCJk5EF9WQRkuXcGrceKzJyZyaMIHGn3+BtoGim1JQUChOpUS8u3fv5vz583Tp0gWNRoNGo2HLli288847aDQaGjRogMViKdbh+dy5c/ITb3h4eLGsJNff5Y0xGo0lel8uFUEQ8NZpamWpbBjBx8eH2NhYYmNj6d69O4sWLSIvL49PPvkEkMJKXbt2Ze/evR7L0aNHufPOOy/pddJqtcVet6JGj/sY17WVZRhV5JiV5fnnn+fgwYMMHTqU33//nTZt2vDDDz9c0jEr8n/XuXNnQkND+eyzzxBF0WPb4sWL2b59Oz179uSbb76hRYsW7Nix45LmVFfI3bqVUxMmYE9Px6tNG5os/xJd48aETpsGQPqyZdguXiy2X87GjVgSE1EZjQSMvQ0AbXg4jZcuQduwIdZTSSRNmIC1iJGsoKCgAJU0YPr378+BAwc8bozdunXjrrvukn/XarVs3LhR3ufIkSMkJSURFxcHQFxcHAcOHPB4ct+wYQNGo5E2bdrIY9yP4RrjOoZCIYIgoFKpKCgoAKBLly4cO3aMsLAw2dBxLf7+/vj7+9OgQQOPEKDdbmfPnj21dQml0rRpU7Rarcdcs7KyKpSK3aJFCx555BHWr1/PqFGjWLx4MQA6na6YbqV169bYbDb+/vtveV1aWhpHjhyR/yc7dOhQ7H+yKM2aNWPTpk38+OOPPPTQQ8W2d+7cmVmzZrFt2zbatWvH8uXLy72OuoQ9Nw9zYiL5u3aRvWYN6Z9/zrl580iecj9ifj7ecT1o9PlSNCEhAPj27YNXhw6IBQVc/Phjj2OJokjaJ5L3JfDOO1A7w6IA2shIGi1diiYyAsvJk5x5pLj3RkFBQaFSISQ/Pz/atWvnsc7Hx4fg4GB5/aRJk3j00UcJCgrCaDTy0EMPERcXR48ePQAYOHAgbdq0Ydy4cbz22mukpqbyzDPPMHXqVDkENGXKFN577z1mzpzJPffcw++//86KFSv45ZdfquOa6zVms1nWCmVkZPDee++Rm5vL8OHDAUnA+vrrrzNixAheeOEFoqKiOHXqFN9//z0zZ84kKiqKhx56iHnz5hEbG0urVq149913ycjIqHOiUj8/PyZMmMCMGTMICgoiLCyM5557DpVKVepcCwoKmDFjBrfeeisxMTGcPn2aXbt2MXr0aEAKWeXm5rJx40Y6duyIt7c3zZs3Z8SIEdx777189NFH+Pn58eSTT9KwYUNGjBgBSBqr9u3b88ADDzBlyhR0Oh2bNm1izJgxhDhv2CAZTps2baJPnz5oNBreeustEhMT+fjjj7n55puJjIzkyJEjHDt2jPHjx9f8i1hNXFj4IbkffVTqdr8hg4l89VVUOp28ThAEwqY/TNI9k8j86muC774brTNslv/3Tkz79yPo9QSNG1fseLqohkR/+CGJN4+gYM8eRLsdQemGraCg4EbV0l/K4M0330SlUjF69GjMZjODBg3yED6q1WpWr17N/fffT1xcHD4+PkyYMIEXXnhBHhMTE8Mvv/zCI488wttvv01UVBSLFi1i0KDa0anUJdauXStrJ/z8/GjVqhUrV66kT58+gNQ9+I8//uCJJ55g1KhR5OTk0LBhQ/r374/RaAQkAW5qairjx49HrVZz3333MWjQINR18AbxxhtvMGXKFIYNG4bRaGTmzJkkJyeXqsFSq9WkpaUxfvx4zp07R0hICKNGjZIz1Hr27MmUKVMYO3YsaWlpPPfcczz//PMsXryYhx9+mGHDhmGxWLjhhhtYs2aNHOJq0aIF69ev56mnnuKaa67BYDBw7bXXcscddxSbQ8uWLfn999/p06cParWamTNncvjwYZYuXUpaWhoRERFMnTqV//3vfzX3wlUjos1G9urVqACVtzfq0BA0oaHSEhKKV6tW+N8yEkFV3KHrHReH9zXXkL9zJxcXfkjEC9L7kOb0yASMHo3GmUFXFH1MjHMCIvbMzFLHKSgoXJ0IYtFg/RVCdnY2/v7+ZGVlyTduFyaTicTERGJiYuqcGLk2cDgctG7dmttuu63aq+9WN3l5eTRs2JAFCxYwadKk2p7OFY/JZOL4v/8iPPU0fs2a0WjRJ5U+Rv6ePZy68y7QaGi25hfs2TmcvPVWUKtptm4tuqioUvc9em0P7FlZNP35J/Ru2WQKCgpXLmXdv92pdg+MQt3n1KlTrF+/nt69e2M2m3nvvfdITEy8ZJFvTfDvv/9y+PBhrrnmGrKysmRPnSu0o1CziDYbjoIC1EDw5KoZjN5duuBzw/Xk/fEnF99/H4dZahlgvOmmMo0XAHVQEPasLGzpGVQtx1BBQeFKRTFgrkJUKhVLlizh8ccfRxRF2rVrx2+//VZna5PMnz+fI0eOoNPp6Nq1K3/++aeH7kSh5rBlZYEooo+NxdstzbyyhE57mLw//iTrp5/ldcGTJ5e7nzooCBITsaenVfncCgoKVyaKAXMVEh0dzdatW2t7GhWic+fO7N69u7ancVUiOhw4nCUR/EePuiSRt6FdW/xuvJGcDRsA8O3dG6+WLcrdTxMUBIAtPb3K51ZQULgyuaRu1AoKClcu9owMRLsd1Gp8r7vuko8XOu0hcBpBwffdW6F91E4Dxp6eccnnV1BQuLJQPDAKCgrFEEURW5oUtlH7+CCU0suqMuibN6fhGwuw5+bi3bVrhfbRBLs8MEoISUFBwRPFgFFQUCiGIzsb0WJBUKsRvL2r7bjGIUMqNV4dqHhgFBQUSkYJISkoKHggiqJc+l/l719ifZfLhTooEAB7muKBUVBQ8EQxYBQUFDxw5OfjKCgAQUBTShfpy4WreJ0tQ/HAKCgoeKIYMAolMnHiREaOHFnj52nSpAlvvfVWjZ9HoeLYnd4XdUBgtWhfLoXCEJKShaSgoOCJYsDUMyZOnIggCAiCgE6nIzY2lhdeeAGbzVbbUyuTJUuWEFDC0/yuXbu47777Lv+EFErEYTJhz8kBQBNS+6X7Na4QUmamlBGloKCg4EQxYOohgwcPJiUlhWPHjvHYY4/x/PPP8/rrrxcbZ7FYamF2lSM0NBTvahSJKlwatjTJ06E2GlHpa7/2rTpQMmBc/ZAUFBQUXCgGTD1Er9cTHh5O48aNuf/++xkwYAA//fSTHPZ56aWXiIyMpGXLlgAcOHCAfv36YTAYCA4O5r777iM3N1c+nt1u59FHHyUgIIDg4GBmzpxJ0RZZJYV6OnXqxPPPPy//nZmZyf/+9z8aNGiAl5cX7dq1Y/Xq1WzevJm7776brKws2Xvk2q/ocZOSkhgxYgS+vr4YjUZuu+02zp07J29//vnn6dSpE1988QVNmjTB39+f22+/nRyn10Dh0hAL8gFQ17L2xYWg0aD29weUMJKCgoInigEDIIpgyaudpRp6aRoMBtnbsnHjRo4cOcKGDRtYvXo1eXl5DBo0iMDAQHbt2sXKlSv57bffePDBB+X9FyxYwJIlS/jss8/466+/SE9P54cffqjUHBwOB0OGDGHr1q0sW7aM+Ph4XnnlFdRqNT179uStt97CaDSSkpJCSkoKjz/+eInHGDFiBOnp6WzZsoUNGzZw4sQJxo4d6zEuISGBVatWsXr1alavXs2WLVt45ZVXqvDKKbgjiiIO5/+RUAe8Ly7ULiFvmmLAKCgoFKLUgQGw5sPLkbVz7qfOgs6nSruKosjGjRtZt24dDz30EBcuXMDHx4dFixah0+kA+OSTTzCZTHz++ef4+Ejnee+99xg+fDivvvoqDRo04K233mLWrFmMGjUKgA8//JB169ZVai6//fYbO3fu5NChQ7RoIZWIb9q0qbzd398fQRAIDw8v9RgbN27kwIEDJCYmEh0dDcDnn39O27Zt2bVrF927dwckQ2fJkiX4+fkBMG7cODZu3MhLL71UqTkreCLabOBwAAKCVlvb05FRBwXCCbBnKAaMgoJCIYoHph6yevVqfH198fLyYsiQIYwdO1YOybRv3142XgAOHTpEx44dZeMFoFevXjgcDo4cOUJWVhYpKSlc69aoT6PR0K1bt0rNae/evURFRcnGS1U4dOgQ0dHRsvEC0KZNGwICAjh06JC8rkmTJrLxAhAREcH58+erfF4FCdHlfdFpa7X2S1E0gUo/JAUFheIoHhgArbfkCamtc1eSvn37snDhQnQ6HZGRkWjcUl3dDZXqRKVSFdPFWK1W+XeDwVAj5y0JbRHvgCAIOByOy3b+KxXRbAZAcDOA6wJqZzsBuxJCUlBQcKPuPGbVJoIghXFqY6lCh18fHx9iY2Np1KiRh/FSEq1bt2bfvn3k5eXJ67Zu3YpKpaJly5b4+/sTERHB33//LW+32WzFOkCHhoaSkpIi/52dnU1iYqL8d4cOHTh9+jRHjx4tcR46nQ57OWmwrVu3Jjk5meTkZHldfHw8mZmZtGnTpsx9FS4dlwdGpas7+hdw60ithJAUFBTcUAyYK5y77roLLy8vJkyYwH///cemTZt46KGHGDduHA0aNADg4Ycf5pVXXmHVqlUcPnyYBx54gMwiKav9+vXjiy++4M8//+TAgQNMmDABtVotb+/duzc33HADo0ePZsOGDSQmJvLrr7+ydu1aQAr75ObmsnHjRi5evEh+fn6xuQ4YMID27dtz1113sWfPHnbu3Mn48ePp3bt3pUNaCpVHDiHp65gHJlDxwCgoKBRHMWCucLy9vVm3bh3p6el0796dW2+9lf79+/Pee+/JYx577DHGjRvHhAkTiIuLw8/Pj1tuucXjOLNmzaJ3794MGzaMoUOHMnLkSJo1a+Yx5rvvvqN79+7ccccdtGnThpkzZ8pel549ezJlyhTGjh1LaGgor732WrG5CoLAjz/+SGBgIDfccAMDBgygadOmfPPNNzXwyigUpa6GkFwdqZU0agUFBXcEsaiw4QohOzsbf39/srKyMBqNHttMJhOJiYnExMTg5eVVSzNUUKg7iKKIKT4eRBF98+ZyEbu68FnJ27GDpIl3o2vWjGa/rK6VOSgoKFw+yrp/u6N4YBQUFBCtVqkmkSDUOQ9MYQhJ6UitoKBQiGLAKCgoFOpftFqEKgjLaxI5hJSVpfRDUlBQkFEMGAUFhcIMpDpUgdeF3NZA6YekoKDghmLAKCgoIJpdRezqVvgInP2QnEaMTQkjKSgoOFEMGAUFBURL3cxAcqEOcmUiZdTyTBQUFOoKigGjoKBQ2MSxjhWxc6EOCgSUfkgKCgqFKAaMgsJVjiiKdbaInQtNkNKRWkFBwRPFgFFQuMrxSKGuQ12o3ZE9MEoxOwUFBSeKAaOgcJVT2IVaV+dSqF0o/ZAUFBSKohgwCgpXOa4WAqo6KuAFUDtDSEo/JAUFBReKAVOPmDhxIiNHjqztaZTKyZMnEQSBvXv3XtIxJk2aRExMDAaDgWbNmvHcc89hcXoJFKofdw9MXUWjhJAUFBSKUCkDZuHChXTo0AGj0YjRaCQuLo5ff/1V3t6nTx8EQfBYpkyZ4nGMpKQkhg4dire3N2FhYcyYMQObzeYxZvPmzXTp0gW9Xk9sbCxLliyp+hUqXBaqy8A4fPgwDoeDjz76iIMHD/Lmm2/y4Ycf8tRTT1XL8RWKUyjgrZsZSFDogbEpBoyCgoKTShkwUVFRvPLKK+zevZt//vmHfv36MWLECA4ePCiPuffee0lJSZEX967DdrudoUOHYrFY2LZtG0uXLmXJkiXMnj1bHpOYmMjQoUPp27cve/fuZfr06UyePJl169ZVw+VeWfTp04eHHnqI6dOnExgYSIMGDfjkk0/Iy8vj7rvvxs/Pj9jYWA8jc/PmzQiCwC+//EKHDh3w8vKiR48e/Pfffx7H/u6772jbti16vZ4mTZqwYMECj+1NmjRh7ty5jB8/HqPRyH333UdMTAwAnTt3RhAE+vTpI5/zmmuuwcfHh4CAAHr16sWpU6dKvKbBgwezePFiBg4cSNOmTbn55pt5/PHH+f7776vxlVNwx1FHu1C7o4h4FRQUiiFeIoGBgeKiRYtEURTF3r17iw8//HCpY9esWSOqVCoxNTVVXrdw4ULRaDSKZrNZFEVRnDlzpti2bVuP/caOHSsOGjSozHmYTCYxKytLXpKTk0VAzMrKKja2oKBAjI+PFwsKCkRRFEWHwyHmWfJqZXE4HBV6nUVRFCdMmCCOGDFC/rt3796in5+fOHfuXPHo0aPi3LlzRbVaLQ4ZMkT8+OOPxaNHj4r333+/GBwcLObl5YmiKIqbNm0SAbF169bi+vXrxf3794vDhg0TmzRpIlosFlEURfGff/4RVSqV+MILL4hHjhwRFy9eLBoMBnHx4sXyuRs3biwajUZx/vz54vHjx8Xjx4+LO3fuFAHxt99+E1NSUsS0tDTRarWK/v7+4uOPPy4eP35cjI+PF5csWSKeOnWqwtf99NNPi127dq3weIWK43A4xPz//hPzDxwQ7c7PoDtFPyu1hfXCBTG+ZSsxvlVr0WG11upcFBQUapasrKxS79/uaKpq+NjtdlauXEleXh5xcXHy+i+//JJly5YRHh7O8OHDefbZZ/H29gZg+/bttG/fngYNGsjjBw0axP3338/Bgwfp3Lkz27dvZ8CAAR7nGjRoENOnTy9zPvPmzWPOnDlVupYCWwHXLr+2SvteKn/f+TfeWu8q79+xY0eeeeYZAGbNmsUrr7xCSEgI9957LwCzZ89m4cKF7N+/nx49esj7Pffcc9x4440ALF26lKioKH744Qduu+023njjDfr378+zzz4LQIsWLYiPj+f1119n4sSJ8jH69evHY489Jv+tVqsBCA4OJjw8HID09HSysrIYNmwYzZo1A6B169YVvr7jx4/z7rvvMn/+/Mq+NAoVQLRY6nwKNTj7IQmC3A9JExJS21NSUFCoZSot4j1w4AC+vr7o9XqmTJnCDz/8QJs2bQC48847WbZsGZs2bWLWrFl88cUX/N///Z+8b2pqqofxAsh/p6amljkmOzubgoKCUuc1a9YssrKy5CU5Obmyl1Yv6dChg/y7Wq0mODiY9u3by+tcr+X58+c99nM3OoOCgmjZsiWHDh0C4NChQ/Tq1ctjfK9evTh27Bh2t27A3bp1K3d+QUFBTJw4kUGDBjF8+HDefvttUlJSKnRtZ86cYfDgwYwZM0Y2yBSqF7mJYx1OoQZnPyR/f0DRwSgoKEhU2gPTsmVL9u7dS1ZWFt9++y0TJkxgy5YttGnThvvuu08e1759eyIiIujfvz8JCQny03dNodfr0VdRhGjQGPj7zr+reUYVP/eloC3y1CwIgsc6103J4XBc0nlKwsfHp0LjFi9ezLRp01i7di3ffPMNzzzzDBs2bPDwCBXl7Nmz9O3bl549e/Lxxx9X15QVilAfBLwu1EFB2DMzlX5ICgoKQBUMGJ1OR2xsLABdu3Zl165dvP3223z00UfFxl57rRSWOX78OM2aNSM8PJydO3d6jDl37hyAHHIIDw+X17mPMRqNGAyXdrMvDUEQLimMUx/ZsWMHjRo1AiAjI4OjR4/KoZ3WrVuzdetWj/Fbt26lRYsWcpioJHROEai7l8ZF586d6dy5M7NmzSIuLo7ly5eXasCcOXOGvn370rVrVxYvXoxKpWT71xR1uQt1UTRBQVhOnMCernSkVlBQqIY6MA6HA7Mzi6EornogERERgBS2OHDggEc4Y8OGDRiNRjkMFRcXx8aNGz2Os2HDBo+Qh8Kl88ILL7Bx40b+++8/Jk6cSEhIiFxj5rHHHmPjxo3MnTuXo0ePsnTpUt577z0ef/zxMo8ZFhaGwWBg7dq1nDt3jqysLBITE5k1axbbt2/n1KlTrF+/nmPHjpWqgzlz5gx9+vShUaNGzJ8/nwsXLpCamiqHGBWqF0cd70LtjqsjtU3xwCgoKFBJD8ysWbMYMmQIjRo1Iicnh+XLl7N582bWrVtHQkICy5cv56abbiI4OJj9+/fzyCOPcMMNN8g6jYEDB9KmTRvGjRvHa6+9RmpqKs888wxTp06Vwz9TpkzhvffeY+bMmdxzzz38/vvvrFixgl9++aX6r/4q5pVXXuHhhx/m2LFjdOrUiZ9//ln2oHTp0oUVK1Ywe/Zs5s6dS0REBC+88IKHgLckNBoN77zzDi+88AKzZ8/m+uuv55tvvuHw4cMsXbqUtLQ0IiIimDp1Kv/73/9KPMaGDRs4fvw4x48fJyoqymObKIrVcu0KhYh1vAu1O+pgyYBRUqkVFBSAyqVR33PPPWLjxo1FnU4nhoaGiv379xfXr18viqIoJiUliTfccIMYFBQk6vV6MTY2VpwxY0axNKiTJ0+KQ4YMEQ0GgxgSEiI+9thjorVIWuSmTZvETp06iTqdTmzatKlH+m5FKSsNq66khtYGrjTqjIyM2p6KQi3jsNvF/APOFGpnCn1R6tJn5fzb74jxLVuJZ597rranoqCgUIPUSBr1p59+Wuq26OhotmzZUu4xGjduzJo1a8oc06dPH/7999/KTE1BQaGSiFYrIIJKhaCpckWFy4YrhKSIeBXqKumff45gMBA4ZkxtT+WqoO5/aykoKNQI9SWF2oUm2KWBUUS8CnUPa2oq516eB4KA8cYbpdpFCjWKkt5xldGnTx9EUSRA+XBd9Yj1oIWAO+rAq9MD48jPx1qkjpNC3cNy4oT0iyhSsH9/7U7mKkExYBQUrlLqQxdqd67WfkinH5rG8Rt6c/qhhzAdPVrb01EoBfPJk/LvBXv31d5EriIUA0ZB4SrFUY+K2AFogqWO1PbMTMQiHeyvVBz5+eRt3w5AzobfSBwxkjOPz8BSSjNUhdrD4mHA7K21eVxNKAaMgsJVSr0LIbn6ISEZMVcDpsNHwOFAHRSE3+DBIIpkr15Nwk1DSXn2WawVbMuhUPN4GDD79yPWQPVzBU8UA0ZB4SpEdDicWUiSiLc+IKjVV10/JNN//wFg6NCBqLfeJOb77/Dt3RvsdjJXfkvC0GHk7dhRy7NUALAknpR/d+TmFmpiFGoMxYBRULgKkfUvKhXUgxRqF2pXGOlqMWAOHgTAq1076WebNkR/9CGNly/H0KkTYn4+yf+bQu4ff9TmNK96HBYL1jNnANDFxABKGOlyoBgwCgpXIe4C3vqQQu1CE1h5Ia8oiuTt2IEto/5lLxUclDwwXm3beKz37tKZRp8vxbdfP0SzmeSpD5K9YUNtTFEBsCYng8OByscHvwH9ASjYpwh5axrFgFEoE0EQWLVqVW1Po0xOnjyJIAhy7y2F8qlPXajdcXlgKtMPKX/HDpIm3k3KM8/W1LRqBEd+PpYTiQB4tW1bbLtKpyPq7bckbYzVypnpj5CltFypFVz6F12TJhg6dQKUTKTLgWLA1BMEQShzef7550vdV7nBl8/3339Pt27dCAgIwMfHh06dOvHFF1/U9rRqDEc96kLtTmEqdcWL2ZkOHQbq3xOx6fBhcDjQhIWhDQsrcYyg1dJw/uv4j7gZ7HbOzphJ5vc/XOaZKngYMM7ef+bjx7Hn5NTirK586k/w+yonxS3b4JtvvmH27NkcOXJEXufr61sb07piCAoK4umnn6ZVq1bodDpWr17N3XffTVhYGIMGDart6VU7jrw8AFReXrU8k8qhCXRV4614CMl6+jQA9osXsaWno3G2JKjruAS8JXlf3BE0GiLmzUPQe5G5YgUpTz2FaLEQePvYyzFNBcCcKHnKdE2aoAkNRduwIdYzZzAdOIBPz561PLsrF8UDU08IDw+XF39/fwRBkP8OCwvjjTfeICoqCr1eT6dOnVi7dq28b4xTVNa5c2cEQaBPnz4A7Nq1ixtvvJGQkBD8/f3p3bs3e/bsqdS8vv32W9q3b4/BYCA4OJgBAwaQ57w59unTh+nTp3uMHzlypEdX6yZNmjB37lzuuOMOfHx8aNiwIe+//77HPoIgsHDhQoYMGYLBYKBp06Z8++23Jc5HFEViY2OZP3++x/q9e/ciCALHjx8vcb8+ffpwyy230Lp1a5o1a8bDDz9Mhw4d+Ouvvyr1etQHHGYzosUMgoCqnhm+ckfqtIobMJbTyfLv5mMlv/91kUIBb9kGDEhi7PA5zxM4bhwAqS++WC81P/UVdw8MUBhGqmdev/qGYsAg3fQc+fm1soiieMnzf/vtt1mwYAHz589n//79DBo0iJtvvpljx44BsHPnTgB+++03UlJS+P777wHIyclhwoQJ/PXXX+zYsYPmzZtz0003kVNBt2dKSgp33HEH99xzD4cOHWLz5s2MGjWq0tf0+uuv07FjR/7991+efPJJHn74YTYUESQ+++yzjB49mn379nHXXXdx++23c+jQoWLHEgSBe+65h8WLF3usX7x4MTfccAOxsbHlzkcURTZu3MiRI0e44YYbKnUt9QF7djYAKh8fBLW6lmdTOVzeE1tGZTwwZ+Tfzc7PRH2g4D+nAVOOB8aFIAg0eGqWdBO12TAp5ewvG5aTUmFB2YDp2BGAfCVsX6MoISRALCjgSJeutXLulnt2I3h7X9Ix5s+fzxNPPMHtt98OwKuvvsqmTZt46623eP/99wkNDQUgODiY8PBweb9+/fp5HOfjjz8mICCALVu2MGzYsHLPm5KSgs1mY9SoUTRu3BiA9u3bV3r+vXr14sknnwSgRYsWbN26lTfffJMbb7xRHjNmzBgmT54MwNy5c9mwYQPvvvsuH3zwQbHjTZw4kdmzZ7Nz506uueYarFYry5cvL+aVKUpWVhYNGzbEbDajVqv54IMPPOZwpeBwGjBqo7GWZ1J5KtsPSXQ45BAS1B8DxpGXJ9cRMVTQgAHJiDF07Ijl5EkK9h+QasZcweTv2UPGV1/TYNaTtRYatOfkYL94EQBdTBMADJ07AWDauw9RFOtVpl99QvHA1HOys7M5e/YsvXr18ljfq1evEj0U7pw7d457772X5s2b4+/vj9FoJDc3l6SkpAqdu2PHjvTv35/27dszZswYPvnkEzKq4LaOi4sr9nfRuVdkjIvIyEiGDh3KZ599BsDPP/+M2WxmTDkt7v38/Ni7dy+7du3ipZde4tFHH2Xz5s2VvJq6jcNqxVFQAIDaz6+WZ1N5NHIIqWIiXtuFi3LGFdQfA8Z0+DCIIpoGDdA4H0AqilcH6SGi4MCV74E5//p8sn/+mYxly2ptDi7vizo0BLUzJOvVsiWCToc9Kwur0vahxlA8MIBgMNByz+5aO3dtMWHCBNLS0nj77bdp3Lgxer2euLg4LG5f+GWhVqvZsGED27ZtY/369bz77rs8/fTT/P3338TExKBSqYqFk6zO6q81zeTJkxk3bhxvvvkmixcvZuzYsXiX4+lSqVRyiKlTp04cOnSIefPmyZqhKwGX90Xl7Y2g1dbybCqP2vmUbc/KQrTZEMopwmd16V/UarDbMR87Vi+eiCsq4C0JVxaMaf+BenGtVcWelSVrTPK2bSd02rRamYflpCTg1TduIq8TdDq82ral4N9/yd+7Vw4tKVQvigcGye2q8vauleVSv1yMRiORkZFs3brVY/3WrVtp00YqfqVzpsra7fZiY6ZNm8ZNN91E27Zt0ev1XHS6QiuKIAj06tWLOXPm8O+//6LT6fjhBymNMzQ01CN7ym6385/zi9mdHUVKoe/YsYPWrVtXeow7N910Ez4+PixcuJC1a9dyzz33VOq6ABwOB2Znv6ArBVdaZ330vkDl+yG5wkeGTp1Ao8GRk4Pt3Lmam2A1UVAJAW9R9C1bImi12DMzPcJnVxp527aBs99QwYEDtZay7Goh4AofuVCEvDWP4oG5ApgxYwbPPfcczZo1o1OnTixevJi9e/fy5ZdfAhAWFobBYGDt2rVERUXh5eWFv78/zZs354svvqBbt25kZ2czY8YMDJXwCP39999s3LiRgQMHEhYWxt9//82FCxdkw6Jfv348+uij/PLLLzRr1ow33niDzBJuOlu3buW1115j5MiRbNiwgZUrV/JLkYJcK1eupFu3blx33XV8+eWX7Ny5k08//bTUuanVaiZOnMisWbNo3rx5sRBUUebNm0e3bt1o1qwZZrOZNWvW8MUXX7Bw4cIKvx51HdFmK0yfrof6F3D2QwoIwJ6RgS0tHU1ISJnjLcnSDVwX0wR7ZiaWhATMx46hddOC1UVMTgFvZfQvLlQ6HfrWrTHt30/B/v3ooqOre3p1gtw/3TIE7Xbyd+3Cr4iu73JQNAPJhUvIqxgwNYfigbkCmDZtGo8++iiPPfYY7du3Z+3atfz00080b94cAI1GwzvvvMNHH31EZGQkI0aMAODTTz8lIyODLl26MG7cOKZNm0ZYKQWzSsJoNPLHH39w00030aJFC5555hkWLFjAkCFDALjnnnuYMGEC48ePp3fv3jRt2pS+ffsWO85jjz3GP//8Q+fOnXnxxRd54403itVemTNnDl9//TUdOnTg888/56uvvpI9TKUxadIkLBYLd999d7nXkpeXxwMPPEDbtm3p1asX3333HcuWLZOFw1cC9txcEEVUej2qelaB1x05jFSBTCSXB0IXFY3e+Xmo66nU9tw8LImlV+CtCAanmP5KzUQSRZG8P/8EQNe0KSCFkWqDUg2YTpIBYz5yFEd+/mWe1dWB4oGph0ycONGjlopKpeK5557jueeeK3WfyZMnF7sZd+7cmV27dnmsu/XWWz3+LislunXr1h71Zoqi1Wr54IMPSswUcsdoNLJixYoyx0RGRrJ+/foStzVp0qTEeZ45cwatVsv48ePLPDbAiy++yIsvvljuuNpGdDiwZ2aiNhrL1X8URda/1FPviwtNUBCWhIQK9UNy1YDRRkUh2m3krK37Ql7zoXhJwBseXq6HqTQMHdqT8SUU7D9QzbOrG5iPHsV24QKCwUDI/fdzdsYM8rZffgNGFMVCA8ZZb8uFNjwcTXg4ttRUCv77D59rrrns87vSUTwwClccZrOZ06dP8/zzzzNmzBgaNGhQ21OqNuzp6VjPnsXqpi2qCKLDIXlgqJ/p0+64PDC2ChSzc9WA0UVHuXlg6rYBcyn6Fxde7Z1C3vh4xMsknL+cuLwvPtdcg+8N14MgYElIwHqZ9U22Cxck74pKhS4qqth2JYxUsygGjMIVx1dffUXjxo3JzMzktddeq+3pVCsuV7Q9OxuxiCi7zP1yc8HhQNBqEepZ+4CiyP2QygkhOSwWWbCrjXIzYI4fR3SKP+sipoPxQNX0Ly50TRqj8vNDNJvrvMFWFVz6F5/rr0ft749Xu3bA5Q8juQS82qioEvuKyQaM0tixRlAMGIVa5eTJk8XaDRRFFEVGjhxZ4WNOnDgRu93O7t27adiw4aVNsI7hquGCKFYq68JVfVftZ6z3abWaIGdH6nI8MNYzZ0AUEby9UQcFoWvUCEGnQzSZ6nR2zqWkULsQVCoM7aWb+pUWRrLn5pHvbHnie/11APg4Rfp527dd1rkU6l8al7jdPROpOqquK3iiGDAKCvUE0Wr1CAfYs7Iqtp8o4nAaOypj/UyfdqewI3U5BoxLwNuwodS1Xa1GF9sMqLthJHturnxTvBQDBgrDSFdaQbv8v3eA1Yq2USN0zgrgPj1dBsz2Mg2F1Bde4NgNvaVCgdVAaQJeF15tWoNWi/3iRaxnzsrrRZuNjBUrONavH4c7duJojziO9+tPwrBhJI65jVMT7yb711+rZY5XMooBo6BQT3B5X1z9ixy5uYg2W/n75eUj2u0IajUqH58anePlQBPs9MCUE0JyGTBaN22CVx3XwZjinQLeiAj5OquKoYMrE+nK8sDkOvUvvtdfL68zdO6M4OWF/cJFLKU0bDUnJJCx/Cts589z5uHpsibsUijPgFF5eeHVqhUABXv3IooiuX/8QeItt5A6+zlsZ1MQzWapZs/Zs1iOJ2A6cID8HTtIee55JXupHJQsJAWFeoLLgFH5+SGaTDhMJuzZ2eX2gHHkZMv71ffwEbj1Q7pYdjsBVw0YbXShAaNzVlo2H62jBoxL/3IJAl4XXs5UavPx49hz81D71n/jVUqfdulfrpPXq/R6vLt2JW/rVvK2b5f1Tu6kfbJI/t1y6hSps2cTuWDBJX0mXAaMvkgGkjuGTp0wHThA9i+/kPX9d7JOR+3vT8jUB/Dt2xdHQQFiQQGOggIc+fmce3ke1tOnyfp5NYFjb6vy/K50FA+MgkI9QTZgDAZU/v5A+WEkURQL9S/1PPvIhc5pkFhOn8ZhMpU6rrAGTKEB4y7krYuYDlauA3VZaMPC0ISHgyhiij94ycerC1gST2I9cwZBqy2WliyHkUoQ8lrPniVr9WoAGjw1CzQastf8SsZXX1V5LqLViiVZStMvq1WAS8ibu2kTedu2I2i1BN1zD83WryNo/Hh00dF4tWiBoWNHfHr0wK9fPwLvuguAjC+/VLQzZaAYMAoK9QBRFBHdDBi104Bx5OXhKCNNVjSZJN2MoELlbDRX39FERKAOCQGbDVMZDUsLa8AUVqKVQ0iJiXUyvbg6BLzuyAXtDlwZYaS8v6TwkXf3bqiK9DZzCXnzd+4s9t6mfbYYbDa8e/QgaPx4wh57DIDz816h4EDx9iYVwXrmDNhsCF5eaMoo1eDdrSuCs3Ck8aabaPrrGhrMnCF/hksiYNQtCAYD5qNHKfjnnyrN72pAMWAUFOoBotUqpU0LAoKXFyqdDpVB+gJ3ZGWXup+rX5DazxdBdWV83AVBqNCN2VUDRhtVmImmiYyUbnxWK5Y61iW4OgW8LuTO1FeIDkZOn77u+mLb9K1aoQ4IwJGfT4Hb/4UtPZ3Mb78FIOS+ewEImjgB3/79Ea1WzjzyiOylrAxml/6lceMyP1va8HCafPM1MT/9SMM3FpRYL6Yoan9//IcPByB92ZeVntvVwpXxjaZQY2zevBlBEErsYaRw+ZC9L3q9/GWpLieM5MgvkFON1QEBNT/Jy4hLoFqwr+QMG3tWllx52P2GIQjCJRW0q0ztncri0r9oIiPK1TVVFMMVlInkMJnI37kTKEyfdkdQqfCO6wFA3tbCdOr0L75ANJnwatcOb6eXRhAEIl9+CW1UFNbTpzn71FOVDtWUJ+B1x6tVK7xatKjU8QPvuhOAnN9+w5qaWql9rxYqZcAsXLiQDh06YDQaMRqNxMXF8atbqpfJZGLq1KkEBwfj6+vL6NGjOVekMmJSUhJDhw7F29ubsLAwZsyYga1IJsXmzZvp0qULer2e2NhYlixZUvUrvIKYOHGilA5aZDleR+P5CtWHnIFkKHSbq/yNzm35OCwWj/Giw4HlzGlARO3vf8XoX1x4dXDdmEv2LFic+hd1cHCxUIO+ReUMGOu586R//gUn77iTwx06kvrSy4hFXu/qwKV/MbRtV23H9GrXFgQB29kUbBcuVNtxa4P8Xf8gms1owsNlMXZRCuvBSDoYe24uGV8uByD4vns9BLtqf38avvkmglZL7m8bSV+6tFLzqYwBUxW8WrbEu1s3sNvJ+OabGjlHfadSBkxUVBSvvPIKu3fv5p9//qFfv36MGDGCg84P3iOPPMLPP//MypUr2bJlC2fPnmXUqFHy/na7naFDh2KxWNi2bRtLly5lyZIlzJ49Wx6TmJjI0KFD6du3L3v37mX69OlMnjyZdevWVdMl128GDx5MSkqKxxJThgJe4cqgUMBbWEVXpdXKadFFvTC28+cRzWYEjQZtRMTlm+hlwuCsvGpNSsKWkVFsu9xCoAR3fUU8MLa0NNKXL+fUuPEc79OHcy+/TMG//0o3ky++4NS48ZVu51Ae1SngdaH29UXvrH1TVa1HXSH3zz8AyftSWuaQT89egFQ4zp6bS+Y33+DIzkYXE4PfgAHFxhvatyNs1pMAnJ+/QDZ8K4KrCq8upkklrqJyBP7f/wGQuWJlsYcUhUoaMMOHD+emm26iefPmtGjRgpdeeglfX1927NhBVlYWn376KW+88Qb9+vWja9euLF68mG3btrFjxw4A1q9fT3x8PMuWLaNTp04MGTKEuXPn8v7772NxvjkffvghMTExLFiwgNatW/Pggw9y66238uabb1b/1ddD9Ho94eHhHovaWRdky5YtXHPNNej1eiIiInjyySc9vFtms1nuOO3l5cV1111XrJnjmjVraNGiBQaDgb59+3LS+ZRRGidPnkQQBPbu3Suvy8zMRBAENm/eDEBGRgZ33XUXoaGhGAwGmjdvzuLFi+XxycnJ3HbbbQQEBBAUFMSIESPKPe/VhIeAt4g3QRbzuhkwjvx8bBcvAqCNjKx008f6gNrfX37ydQlf3bG6NXEsimzAlJJKnbNpE8f79OXcC3PJ37ULRBFDp040eGoWka+/jspopGDfPhJHjSZ369ZquR5RFCUDCeSy+NXFlVLQLs+tfUBp6KIaom3UCOx28v7aSprTex88eXKpOpXAO+7A0LUr2Gzkbtpc4fnIKdQ15IEB8OvfD02DBtjT0sgpo3Hu1UqVNTB2u52vv/6avLw84uLi2L17N1arlQFuVm6rVq1o1KgR253uvO3bt9O+fXuP5nqDBg0iOztb9uJs377d4xiuMdvL6TRqNpvJzs72WCqKKIpYzfZaWaorRe7MmTPcdNNNdO/enX379rFw4UI+/fRTjw7LM2fO5LvvvmPp0qXs2bOH2NhYBg0aRLqzomlycjKjRo1i+PDh7N27l8mTJ/Pkk09e8tyeffZZ4uPj+fXXXzl06BALFy4kxNll12q1MmjQIPz8/Pjzzz/ZunUrvr6+DB48WDZqr3ZEs1nq3SOo5GwGF2qjEQQBh8mEwzlODp8EBFxxoSN3DB2dN+YSdDCu18C9BowLvTP8YElKKpaG7SgoIHXOC4hWK/pWrQibMYPY3zfS5OuvCBo/Hv/hw4j57lu82rTBnpFB8uR7ufD++5fcW8l6+jTWs2dBo8G7S+dLOlZR5IJ2peiF6gOW06exJCaCWi2HiUrDtf3cvHnYL1xEEx6O//BhpY4XBAG/fn0ByPvrrwrNx5GXJ/fZqqkQEoCg1RJ4+1gA0r9UxLxFqfSj2YEDB4iLi8NkMuHr68sPP/xAmzZt2Lt3LzqdjoAiYsEGDRqQ6hQgpaamFusM7Pq7vDHZ2dkUFBRgMBhKnNe8efOYM2dOZS8HAJvFwccPb6nSvpfKfW/3RqtXV3j86tWr8XVLhx0yZAgrV67kgw8+IDo6mvfeew9BEGjVqhVnz57liSeeYPbs2RQUFLBw4UKWLFnCkCFDAPjkk0/YsGEDn376KTNmzGDhwoU0a9aMBQsWANCyZUsOHDjAq6++eknXmJSUROfOnenWrRsATdw+8N988w0Oh4NFixbJbuHFixcTEBDA5s2bGThw4CWd+0rAPXxU1HUuaDSofH1x5ORgz8wChx3RYpFCR+HhtTHdy4ZX+w5k/fhTiZ4Fa3LxGjAu1CEhqAMCsGdmYk5I8GiamPbpZ9hSU9FERtDk669QldD4UhcdTeOvlnPuxZfIXLmSi+++R8G/e2n41puoq5iq7tJsGDp2LOZlu1RcBe0K/vsP0eGod9looiiS9f33ABg6d0LtV3Y7DJ+4ODK/+UY2MILvubvERose+/TqBa/PJ2/nThwWC6pyxluSkgDnQ0INC+QDbruNix8sxLRvPwUHDsgZeApV8MC0bNmSvXv38vfff3P//fczYcIE4uPja2JulWLWrFlkZWXJS7KzwNCVhksb5FreeecdAA4dOkRcXJzHDa5Xr17k5uZy+vRpEhISsFqt9OrVS96u1Wq55pprOOSspXHo0CGuvfZaj/PFlfO0UxHuv/9+vv76azp16sTMmTPZtq0wQ2Dfvn0cP34cPz8/fH198fX1JSgoCJPJREJCwiWf+0rAvf5LScjZSOlp2NKk6rTahg2vyNCRO+6l8ot6MgvbCEQX2889E8m97Lw1JYW0RVK11gYzZ5ZovLhQ6fVEzH2BiHnzELy8yPvrL9I+/bTK15K/428AfHr0qPIxSsOrRQsEnQ5HdnadSx0vj/zduzl5++1c/GAhQIk6lqJ4X3sNOL8H1QEBBNx6a7n76Fu2RB0SglhQQMGef8sdX9MCXnc0wcH4DRkMQEYtpVTX1WJ6lf6G0+l0xDpdsF27dmXXrl28/fbbjB07FovFQmZmpocX5ty5c4Q7nwTDw8PZ6UyDc9/u2ub6WTRz6dy5cxiNxlK9LyBpQ/RF3OsVRaNTcd/bvau076Wi0VXOhvTx8ZFf/7qAyvk05/4Pbi1SRGrIkCGcOnWKNWvWsGHDBvr378/UqVOZP38+ubm5dO3alS9LcI+GhobW7OTrCYUZSKUYMH5+WAVBTvFVBwaW+5R6JaBv1QpBq8WekYH19Gl00ZKxIjocUpExStbAgKSDyd+1y0PIe37+AkSTCUO3rvgNGlShOQTcMhJBJXD2iSfJWb+BsIcfrvR1iKJI3t8uA+backZXHkGrxatNGwr27sV04ECZZe/rCubERC688QY5G34DQPD2JnjSPQQ5Ra1loQkMxKtdO0wHDhA4flyFPFqCIODbqydZP/5E3tat5b4P5sRE4PIYMABBd91F9k8/k71mDWFPzKy2NPuKkLZoERc+WEjjpUvqnPfnkn2JDocDs9lM165d0Wq1bNy4Ud525MgRkpKS5Kf4uLg4Dhw4wPnz5+UxGzZswGg00qZNG3mM+zFcY6rDE1AagiCg1atrZamu3jStW7dme5FOrFu3bsXPz4+oqCiaNWuGTqdjq5vo0Gq1smvXLvm1b926dTED0yXALg2XkZHilpHhLuh1HzdhwgSWLVvGW2+9xccffwxAly5dOHbsGGFhYcTGxnos/mVUqrxaEB0OWadRmgdGUKtlg0XQaq/40JELlU6HvnVrAAr2F4aRbOfPS5VYNRq04SVXSHWlUpucBkz+nj1k//ILCALhTz1Vqc+lb79+oNViSUjAfCKx0tdhPnYMe1oagpcXXs6y89VNfSlo5zCZSH3xJU4Mv1kyXlQqAm67jdh1awmdOrXCXsWIOc8TOv1hgidNqvC5fZze6dyt5etgLqcHBqSyAV7t2iFarWSuWHlZzglgPX+eC+++h5ifT/aautcdu1IGzKxZs/jjjz84efIkBw4cYNasWWzevJm77roLf39/Jk2axKOPPsqmTZvYvXs3d999N3FxcfRwukUHDhxImzZtGDduHPv27WPdunU888wzTJ06VfaeTJkyhRMnTjBz5kwOHz7MBx98wIoVK3jkkUeq/+qvIB544AGSk5N56KGHOHz4MD/++CPPPfccjz76KCqVCh8fH+6//35mzJjB2rVriY+P59577yU/P59Jzg/5lClTOHbsGDNmzODIkSMsX7683Bo8BoOBHj168Morr3Do0CG2bNnCM8884zFm9uzZ/Pjjjxw/fpyDBw+yevVqWjtvPHfddRchISGMGDGCP//8k8TERDZv3sy0adM4XYmUxisV0WwGUURQqcuM42vCwlD7+aGLjpa7VV8NyBV53W7MVmf4WBsRUeoNzz2VWnQ4OPfSywAE3HorXk6DvqKo/fzwcYZec377rXIXQGH4yLtr13K1F1WlvhS0S/tkERnLloHNhk/vG2j64yoiXpiDppLeWK82bQiZMgVVJbzyPj17AmCOPySHYkvDclIKxekukzdLEAQC77gdgJwiD/g1SdqiRdJ3EFJqel2jUgbM+fPnGT9+PC1btqR///7s2rWLdevWceONNwLw5ptvMmzYMEaPHs0NN9xAeHg43zvFVwBqtZrVq1ejVquJi4vj//7v/xg/fjwvvPCCPCYmJoZffvmFDRs20LFjRxYsWMCiRYsYVEGX7tVKw4YNWbNmDTt37qRjx45MmTKFSZMmeRgTr7zyCqNHj2bcuHF06dKF48ePs27dOgIDAwFo1KgR3333HatWraJjx458+OGHvPzyy+We+7PPPsNms9G1a1emT5/ukfkEUthx1qxZdOjQgRtuuAG1Ws3XX38NgLe3N3/88QeNGjVi1KhRtG7dmkmTJmEymTBewRk0FaUwfFRcwOuOyssLXePG1S4ArevIFXndCtpZXDVgSshAcuHKRLKdTSFj2TJMBw+i8vUldHrlQ0BQqM2oigHjCh9510D4yIXrdTLHH6qTPaBc5Dk9vmEzZ9Loo49K7CpdU2hCQmSPXkkNIV2IDoeUEcXl88AAUlE7wHzkyGV5D63nzpH5dWEBPdPBg3Xuf0cQ66o65xLJzs7G39+frKysYjdCk8lEYmIiMTExeJUh1FNQqG0sZ85gz8hAExJSK6Ghuv5ZMScmcmLITQh6PS3/2YWg1XLhnXe5+MEHBIwZQ8TcF0rd91jvPlKmilYLVithM2cSfM/dVZqH7cIFjt3QG0SR2C2b0ZbR3M8d0WbjaFxPHDk5NFm5osY0BqLDwZEuXRFNJpr+uqZO6mBEq5Uj3bojms00XfML+qZNL/sczi9YQNoni/AfMYLIV18pcUzuli0k/28KKl9fmm/9q1JenktBFEWOXnMtjpwcYlb9gFerVjV6vtQX5pKxfDmGbl0xHz2GIzubJt9965G1V1OUdf92p37l0ykoXGWUl4F0taNr3BiV0YhoNsuCXOsZVw2Y4hlI7ri8MFit6Bo3Juj/7qryPDShoRg6dQIq54UxHTqEIycHlZ9fpUNXlUFQqdA1bgwU6jfKQhRFMr76iry/d5Y7trowHTmKaDajMhovq2fDHVkHs21rqZk3ruJ4AWPGXDbjBaQwkpfTQ+Sq2lxTWFNSyFwpaW1CH5rmFqqtWyFIxYBRUKijSAJeKf5cWgbS1Y6gUsltBVwCVYtcA6ZhqfsBHuGJsFlPllsrpDyqEkbK2y6FTLyvuabGtUsuvYarBH5ZmP77j9Q5L5A8ZQrWIlmhNUXBfkljYejQodZq1Ri6dEEwGLBfuIj56NFi202HD5O/fQeo1Zdk8FYVV5sJV+PPmuLiRx8hWq14X3stPtdeU1g0cm/d0sEoBoyCQh3FUWACRASNBkGrre3p1Fm8XF+uzqdDuQZMOR4Y7+6SpsC3d298e196GQW/GyUDJn/nLuwV7N6e79R8+Fxbc/oXF66ePRXxwJgOHwYkD+CFN9+quUm5n9MpEjXUUCZWRVDpdHhf0x2AvL+Kt4lIXyI1fDQOGoi2YdkGck3g8tKZarD2muX0GTK/k7SroQ89KJ23g+dnrK6gGDAKCnUUsSAfkMJH1ZVufyXiyrAxHdiPw2TC5izTUFoNGBe+/frR+KvlNHzn7Wp5fXWNGqFv0QLsdnKcfcDKwmGxkL9nDwA+cdVfwK4orp49LgFqWVgSTsi/Z61aRcF/NRuygMKne0On2jNgAHx7XQdAXpF0auv582T98gsAQRMnXu5pAW4emMOHEd363FUnaR99CFYrPj3jZOGwy6i0JCYWaxxbmygGjIJCHUXywCjho/KQM2yOJ8g6GJWPT7kl3gVBwLtz52rVMVQmjFSwdy+iyYQ6OBjdZShOKYeQKuCBMZ+QqmCrnLWYzr0yr0arsdoyMuQqwbVdLM3nOkkHk//PbjkLECBj+XKwWjF07ozB6ZG43OiaSJmGoslUIUO0sliSk8n8/gcAQh58SF6vCQyUmmRSt7qaKwaMgkIdxeHmgVEoHU1ICNrISBBFsp0de7VRUbXitXKFkfL+2upx8ysJuX3Atddelrm6hLG2Cxew5+aWOdZyXDJgwp95BsHLi4J/dpOzbn2Nzc0lDtU1aVLjvYXKQxcTgyYiAtFiIf+ffwCpnEHmV1Lph9ryvoCk+ZKLN9aAkPfiwg/Bbsfn+uuLNRV1GW0F+/ZW+3mrimLAKCjUQUS71JQRFAOmIrhi9Nm/StVCS+pCfTnQt2qFtmFDRJOJ3HI6G8v1Xy5D+AikzuXq4GCgsBBbSTjy86XO2EjeiOB77gHg/Pz5OJxFzaqbgjqgf3EhCAK+Ti+MSweT9eOP2LOy0EZF4Tegf21OD6+2NaODsZw8SdaPPwKF2hd3XO9NXdLBKAaMgkIdRC5gp9Ve8U0ZqwNX2MF2VmppoWtYOwaMIAhyGCm3jDCSIy9PvmnXRAPH0tBVQAfjaoegDgxEExhI8ORJaMLCsJ4+TcYXX9TIvAr2STfF2ta/uHClU+dt24rocJC+9HMAgsaPq/VK17KQt5ozkdK/WAZ2O769e5cYInNlIpn27a8zzR0VA0ZBoQ7iyHOGj66yyrpVxaWDcVGegLcmcYWRcjZtLrVyaf6ePWCzoY2MvKxzrUgmksWpf9E3awZI/4OhzlYuFxd+WG6Z/coiOhzyU31d8MCA06hUqTAfO07mipVYEhNR+friP2p0bU9NLiRnOnQI0eGotuPm79oFgP/oUSVul5unZmbK7TpqG8WAUSiT559/nk7OAl2XmyZNmvDWW29d1nNW5HonTpzIyJEjq/W8S5Ys8eji7sjNASQx6mWnjjxdVQavtm3BrXZIbYWQAAydO6MOCsKRnS3fFIriKpnvHdfjsmp1KpKJZHZmIOmcBgyA/4ib8WrbFkdeHhfeebda52RJTMSRk4Pg5SVlcdUB1AEBeLWX6gude0WqyBtw222ofWvh81gEXUwMgpcXYn5+hQTZFcGekyML4L07dy5xjEqnQ9/Gqb+pI32RFAOmHjFx4kQEQSi2HD9+vLandsXw+OOPF+uGfrkRbTY5hKRydpm+bGSnQMpeuHBE+t2SXy8MGpW3t0dhOl0temAEtRrffn2B0rOR8p0F7C5n+AgqlolkTpC+T/TNCkv5CyoVDWY9CUDmypWYjhQv8lZVXOnTXu3a1qlwqSudWjSZaq1wXUkIGo3cRqC6wkgF+/aDKKKNji6zcaasg9lXN3QwigFTzxg8eDApKSkeS0wd7GtSG1icotdLwdfXl2Cn0LG2cOTlAaDS61Fd7gJ2pgzppzUfclMh8yRkn4X1z0L8T3XamHEPI9VGkTF3CtOpNxZz89szMzEdOgSA9zU1X8DOHVkDc/JkqToGSwkeGJCaCfoNHAgOB2cefRTTkSPVMqe6JOB1x5VODc7CdZGRtTgbTwp1MNWTiVTw778AGDp3KnOcoUPdEvIqBgxS3w+ryVQrS2XFUHq9nvDwcI9F7RSV/fjjj3Tp0gUvLy+aNm3KnDlzsLkVO8rMzGTy5MmEhoZiNBrp168f+4q4Al955RUaNGiAn5+f3BW6LOx2O5MmTSImJgaDwUDLli15++23Pca4Qi7z588nIiKC4OBgpk6ditVNH3D+/HmGDx+OwWAgJiaGL7/8stzXwnXcl156icjISFq2bAlAcnIyt912GwEBAQQFBTFixAhOuj1xbt68mWuuuQYfHx8CAgLo1asXp5w1KIqGkOx2O48++igBAQEEBwczc+bMYu9ZSaGuTp068fzzz8t/v/HGG7Rv3x4fHx+io6N54IEHyC0lldWe4wwf+RZ6XzZv3owgCGS6VXjdu3cvgiDI13bq1CmGDx9OYGAgPj4+tG3bljVr1sjj//vvP4YMGYKvry8NGjRg3LhxXLx4sfDEDgfYnFkmxobgFQCoQbTD0V9hxTg4WXZmTW3iykRSh4bUeuaWT1wcKm9vbOfPkzJ7Nhlff03+rl3Y0tPJ27kTRBFds2ZoG4Rd1nnpoqNBpcKRn4/t/IVi20WLBUtSElCogXEnbOYM1MHBWBISOHnrGNIWLUK02y9pTnXVgDF06IA6KAio3dTpkpAL2lVTJpLLgCktfOTCJeQ1HzqEoxoeGC+VuuOvq0VsZjPvTLi1Vs49bem3aKuhy++ff/7J+PHjeeedd7j++utJSEjgvvvuA+C5554DYMyYMRgMBn799Vf8/f356KOP6N+/P0ePHiUoKIgVK1bw/PPP8/7773Pdddfx+ZIlvPvuu8RER2M+eRJBrUHQqCU3r1aL2tcXhygSFRXFypUrCQ4OZtu2bdx3331ERERw2223yfPbtGkTERERbNq0iePHjzN27Fg6derEvffeC0jGyNmzZ9m0aRNarZZp06Zx3llRtSw2btyI0Whkw4YNAFitVgYNGkRcXBx//vknGo2GF198kcGDB7N//35UKhUjR47k3nvv5auvvsJisbBz585SdQgLFixgyZIlfPbZZ7Ru3ZoFCxbwww8/0K9fv0q9PyqVinfeeYeYmBhOnDjBAw88wMyZM/nggw88xomiiMNp2Kj8fCt1jqlTp2KxWPjjjz/w8fEhPj4eX1/pGJmZmfTr14/Jkyfz5ptvUlBQwBNPPMFtt93G77//Lh3A5jRWBTX4hIJvGBjyIUuEBh0gNxmSdkDM9ZWa1+XCt3dvNJERGG+8sbangkqvx3dAf7J/+pmsb78j69vvCjc6vWqXo31AUQSdDm1UFNakJCyJicUMKMupU2C3o/LxQVNCR21dVBRNf1xFyrOzyd20ifPzF5CzaTORr8yTjKNK4sjLk7UXho6dqnRNNYWg0dDos0+xZ2TUWuG60nBPpRYdjkvqHSXa7YVGZDkGjDYqCnVgIPaMDMzx8XID09pCMWDqGatXr5ZvSgBDhgxh5cqVzJkzhyeffJIJEyYA0LRpU+bOncvMmTN57rnn+Ouvv9i5cyfnz59H76w8On/+fFatWsW3337Lfffdx1tvvcWkSZOYNGkSAM8/+igb1qzBZDbLN1V3HEYjukaNmDNnjrwuJiaG7du3s2LFCg8DJjAwkPfeew+1Wk2rVq0YOnQoGzdu5N577+Xo0aP8+uuv7Ny5k+7dpT4kn376Ka2dBZvKwsfHh0WLFqFzNuJbtmwZDoeDRYsWyUbJ4sWLCQgIYPPmzXTr1o2srCyGDRtGM+cTZlnneeutt5g1axajRknK/A8//JB169aVO6+iTJ8+Xf69SZMmvPjii0yZMqW4AWM2SyXCVapKZyAlJSUxevRo2jtTips2LdQwvPfee3Tu3JmXX35ZXvfZZ58RHR3N0aNHadGiBdichde0BnAZdIIKNHqI7Q8Jv8DZPZWa0+VEGxZGc5cx5o7DAYd+gqju4H/5Qkvhzz6Lz7U9MCckYE44jiXhhNSnyel59O1fOSO4utDFNJEMmJMn8enhaUS5C3hLM+o1ISFEffA+Wd9/z7mXXqZg924SR4wkbNaTBNx6a6VEyQX/HQSHA01ExGX3RlUEl9akrqFv1gxBp8ORm4s1OVnuNF4VzMeP48jLK6YjKwlBEDB07Eju5s0U7N+vGDB1AY1ez7Sl39bauStD3759Wbhwofy3jzNLZd++fWzdupWXXnpJ3ma32zGZTOTn57Nv3z5yc3OL6TsKCgpISJDSJg8dOsSUKVPkbY7cXK7t0IE/9uyRNAU2G6LNjmizYs/Kwp6Tg2i388GHH/LZZ5+RlJREQUEBFoulWCZP27Zt5VAXQEREBAcOHJDPq9Fo6Nq1q7y9VatWHlk5pdG+fXvZeHG9DsePH8eviPjVZDKRkJDAwIEDmThxIoMGDeLGG29kwIAB3HbbbURERBQ7dlZWFikpKVzr9qSs0Wjo1q1bpUN/v/32G/PmzePw4cNkZ2djs9nk98bbzVBxOMNHah+fSj9VTZs2jfvvv5/169czYMAARo8eTQfnk+O+ffvYtGmTh/HrIiEhQTJgrE4PjKYEj2CY9MTH2X8rNac6wYnfYeUEaDEY7vzmsp1W7edHQJGUVEdBAZbERES7vdZK5uubxJC35Y8SM5FcLQT0bsZvSQiCQMDo0Xhfey0pT84i/59/SH12NtakZMIee7TCc6mr4aO6jqDVom/ZEtOBA5ji4y/JgJH1L506VqjGjaFjB8mAqQNCXsWAQfowVkcY53Lg4+NDbAl9U3Jzc5kzZ47sKXDHy8uL3NxcIiIi2FxCk7mSDAXRbseRL9UiETQaNIGBhdtEEUdBAaLFwldLl/L444+zYMEC4uLi8PPz4/XXX+dvZ5VRF9oiYlRBEHBUQw0DnyJpxrm5uXTt2rVEDU2oU12/ePFipk2bxtq1a/nmm2945pln2LBhAz2qmBGiUqmKGTTu+p6TJ08ybNgw7r//fl566SWCgoL466+/mDRpEhaLxcOAcZV4VxUxNFROY8b9PNYiNUYmT57MoEGD+OWXX1i/fj3z5s1jwYIFPPTQQ+Tm5jJ8+HBeffXVYvOXjTd3D0xRQltK3picFCk7yVjc4KuzXHRm6aUl1O48kKoquwSYtUVZtWBcLQT0scX1LyUeKyqKRp8vJe3jj7nw1ttkfP01oQ89iOD2UFEWsgFTx0I09QGvNm0kA+bgQYxDhlT5OIUGTNnhI/m8dagztSLivULo0qULR44cITY2ttiiUqno0qULqampaDSaYttDQkIAKZTiMjwceXkgiuz877/CcIITQRBQG40A/PXnn/Ts2ZMHHniAzp07ExsbK3t0KkqrVq2w2Wzs3r1bXnfkyBEPwWplXodjx44RFhZW7Dr9nY3pADp37sysWbPYtm0b7dq1Y/ny5cWO5e/vT0REhIcxVnSeIBlGKSkp8t/Z2dkkuj3d7t69G4fDwYIFC+jRowctWrTgrLNUe1FcRmNRA8ZlfLmfZ+/evcX2j46OZsqUKXz//fc89thjfPLJJ/LrcvDgQZo0aVLsdZGNwLI8MDpvCHW60+ubFybH+Zrllq+puhrQNZGyFs0nS/LAOENITStmwICUYh18332oQ0Jw5OSQt7Pk2jdFEUWx0ICpIxV46xPV1VIg/9+9QPn6Fxcuz6E1ORlbevolnftSUQyYK4TZs2fz+eefM2fOHA4ePMihQ4f4+uuveeaZZwAYMGAAcXFxjBw5kvXr13Py5Em2bdvG008/zT/OhmUPP/wwn332GYsXL+bwvn3Mff99DpVSY0blNGCaRUTwzz//sG7dOo4ePcqzzz7LrlKKd5VGy5YtGTx4MP/73//4+++/2b17N5MnT8ZQhUySu+66i5CQEEaMGMGff/5JYmIimzdvZtq0aZw+fZrExERmzZrF9u3bOXXqFOvXr+fYsWOl6mAefvhhXnnlFVatWsXhw4d54IEHihlW/fr144svvuDPP//kwIEDTJgwwSNcFhsbi9Vq5d133+XEiRN88cUXfPjhhyVfgCgi6HTFnmBjY2OJjo7m+eef59ixY/zyyy8sWLDAY8z06dNZt24diYmJ7Nmzh02bNsnXNXXqVNLT07njjjvYtWsXCQkJrFu3jrvvvhu73Q4OGzicHp2SPDAAkV2kn5dRB5OSuoq0tD8v7SA5qdJPcxZYy26weDXg8sBYT5+R+22B5HW1OA0Y9xowFUFQqfBzCttznIL68rCeOYv94kXQaGrdK1Uf8WojZSIVHIyvcml/28WLWJOSQBDkDKPyUBuNcop9bRe0UwyYK4RBgwaxevVq1q9fT/fu3enRowdvvvkmjZ2xUUEQWLNmDTfccAN33303LVq04Pbbb+fUqVM0cGYbjB07lmeffZaZM2fSY/Bgks+e5X9OQW9RVAYDgkbDpFtv5Zbhwxk7dizXXnstaWlpPPDAA5We/+LFi4mMjKR3796MGjWK++67j7Cwyov6vL29+eOPP2jUqBGjRo2idevWcjq40WjE29ubw4cPM3r0aFq0aMF9993H1KlT+d///lfi8R577DHGjRvHhAkT5BDZLbfc4jFm1qxZ9O7dm2HDhjF06FBGjhwpC4QBOnbsyBtvvMGrr75Ku3bt+PLLL5k3b17xkzm/hNS+vsWEkFqtlq+++orDhw/ToUMHXn31VV588UWPMXa7nalTp9K6dWsGDx5MixYtZJFwZGQkW7duxW63M3DgQNq3b8/06dMJCAiQwlMu74taB6pS4uCRnaSfl8kDk59/ivj4x9h/4H7s9ktoIphT6LVSvDCgCQtD8PYGux3L6dPyeusZyaBxZSpVFj9n9lfO78Vr35SEab+zgF2rVqjqSQi/LqFv0Ry0WhxZWVjPlOzRLY8CpxdXHxsre9UrgqGOhJEEsa50ZapmsrOz8ff3JysrC2ORN8ZkMpGYmEhMTAxeygenGA6zWUptFAS8WrUqVdhlOXsWe3o66sBAdLVcOKy+I4oi5qPHEK0WdI0aVerLpFrIuwBZp0FvhOBC48vjs5J2ED7pB4YgmHmiWGixurlwcSP790ulALp0+ZrAgO5VO9B718BFZ9G1Sb9BdBWPcwVxYtQozPGHiPrg/ULPyaZNnL7/AfQtW9L0x1WVPqZosXC013U4cnJovHw53l3KDkmcmzeP9KWfE3jXXYQ/+0xVLuOqx/U+Nnz7bYyDBlZ6/3Ovv076p58RcNttRLwwp/wdnGR8/TWpz8/Bp2dPGn32aaXPWx5l3b/dUTwwCsWQ65B4e5epSnfdZB05OXWmO2l9RbRYEK0WEITa6X9kLUPA66JBO1BpoSAdMpNqfEoF+Sfl3zMz/i59YHl4eGBSq36cKwi9UwfjnolkSfBs4lhZBJ0O3969gdJbKLjjaiGg6F+qjlyRt4o6mIJK6l9cyC0F9u+v1oaSlUUxYBSK4SglE6YoLgNHtNlk8alC1aio0VhjlCXgdaHRQwMp7n45dDD5BSfl3zMzK6erkjHngjm78O/cc5c2qSsE95YCLgprwFRO/+JOYQuF38p8qHFYLPJNV0mhrjqGS6jI67BYMP33HwDe5bQQKIq+eXMEgwFHbm6ZjUFrGsWAUfBAdDiwO3vxqMsxYASVSm426MjOLnOsQtm4DJjyXvMaQRQLq/BqDWV70yKdT2qXQQeT7+aBycreg8NhLX1waRQ1WBQNDFDY1NHsdvORa8BU0QMD4Hv9dQg6HdakJMxHS2/4aD50CNFqRR0YiLYKFXwVJNx7IlXWC26Oj0e0WKT3oJJ1ZASNRs6Cqs16MIoBo+CBIz8fHA4EjQahAvogVxjJnp2thJGqiLvRWJ7Xq0awW6R+RwiY7dnk5sZjt5eSrdPQmYl0puY9MO4hJLs9n5yc/yp/EPfwESgeGCeFHhipB5goioU1YC7BgFH5+OBzndTFOWdD6WGk3D+kzDJDhw6Vqtyr4Im+ZUtQq7Gnp2M7V7n/bff06aq8BwGjbyV0+sMeTVQvN4oBo+CBe/ioIv/UKl9fEFSIVqvUdl6h0lTWaKx2XN4XjR6rLRNRdGCxpJU81uWBSdknlegv9ZgWsFQ9rGi3mzCZpcwKf3/JaMrM3Fn5A+UU0bwoHhigMJXafvEi9pwcbOfPS7Wf1OpLquoKnmGkkrCmpJD22WcAGIcNvaRzXe2ovLxkg7Oynakr2oG6NAJuGUnIlCnoSyiserlQDBgFDyqqf3EhqFSonU0H7UoYqUpU1misdpwCXofWC4dDqgtis2UhiiUYKKGtQWOQdCXppRQsdDhg8RB4u0OVDYaCAskzoNH4ERYqVRnNqJIB4/TA6JytJYoaNFcpal9f1KFSAUvLyZOYnfWedNHRFa6iWxq+ffuAWo358GGPNG0X5159DbGgAEOXLhiHDbukcym4daY+WHEdjCiK5P8reVHL60Bdl1EMGAUZh9WKw+lFqYwWw1XUTtHBVA25/1GR/k2XDaeA164pFA+LogObLaf4WLUGIpwFr0oJI1mPr+GEJp7jYXmI/yyp0pRcAl6DoQkBgdcAkJn5D6Jor9yBsp0GjGvOigdGxj0TyeIS8FawhUBZaAID8e7WDSgeRsrbto2ctWtBpSJ89rNK+Kga8GonGTA5mzZVOIxvPXMW+wVnEcF27WpyejWKYsAoyMieAC+pSF1FUfv6giDgMJtxmC+h4NhViMNqlV+zWkmfBrkHkl1VtJ9TVsnjSxHy2u0FnDz1EduSHiWxsTenor3Ji/8M7LZKT8mlf/H2boKfb2vUal/s9lxycg9V7kAuD0yEM9Ml95xcMPBqxyXktZw86dbE8dINGHAraudWlVe0WEidKxVfDLzzzjrb6bm+YRwyBMHbG/OhQ+Ru2lyhfVzhI682bep1EUHFgFGQkQ0Yv8oJSQWNRr75KmGkyuFwiXcNlTMaqw3RATbJgLIjZfnodFLHcps9u2SPR5GWAg6HldNnlrNtez8SEl7Dpircx2S9CEfWVHpargwkb0MTBEFNQID0RJ+ZUckwkitk5DJgHFYoyKj0fGoVe83M2SXkNScmVrqJY3n4DegPSDdK28WLAKR//jmWxETUwcGETnuoWs6jAJqgIILuuhOAi++9VyEvjMuAqWz6dF1DMWAUAGeH6RL0L4IgsGrVqnL3V7uFkfr06cP06dNrYpo1zuWeu+j0vtSKeBecxouIKKixO6S5aLVBqFR6EEWs1hLCSLKQdz/pF/9gx98DOXLkWSyW83jhR5vDOQTnSwXxTF5q2LWo0tPKd2pgDN5NAAgMcIWRKmvAOD0wAY3BK0D6vT5lImUmw8KesKB1tRcPLOxKfapKTRzLQhsejlf79iCK5Gz8HWtqKhc+WAhA2GOPXf5K01c4QXffjeDtjSk+vkJemPy9LgFv/dW/QCUNmHnz5tG9e3f8/PwICwtj5MiRHDlyxGNMnz59EATBY5kyZYrHmKSkJIYOHYq3tzdhYWHMmDEDm83Tzbx582a6dOmCXq8nNjaWJUuWVO0KrxCKvqZFl+eff77UfU+ePIkgCCV2L3YhFhQg2u1SbZcqNFGU68EUFNQLF/3mzZsRBKFYY8bvv/+euXPnlru/6HBgOX0G6/kLlzQPlwGj0uurfIybb76ZRo0a4eXlRUREBOPGjSu123UxXAJenQ5EB4KgQqXSo9UGAGC35xbfJzgWdH6YVSb2/3c/BQVJ6HQhtIh9lrj9NiLOm/EKlDQnZr0aErfAhdJrgpREfr5Un8TbWwpzBARcC0BG5q6SxcUlIYqFHhi/cGmB+mPAXDwGnw2Gi0elMN/hX6r18LIH5vhx7M6uwvqmMdV2fPdspPOvvYaYn4+hc2f8R46otnMoSFTGC+PIy8N8WLpvGzp1uhzTqzEqZcBs2bKFqVOnsmPHDjZs2IDVamXgwIHkOd3gLu69915SUlLk5bXXXpO32e12hg4disViYdu2bSxdupQlS5Ywe/ZseUxiYiJDhw6lb9++7N27l+nTpzN58mTWrVt3iZdbf3F/Pd966y2MRqPHuscff/ySjm93z4RRVd4xp9JqUXl7AyDaKq95qCsEBQXhV46YVhRFrGfPYs/MwHb+HA5rFQqsOXHpX4RLMGD69u3LihUrOHLkCN999x0JCQnceuutFdvZ5hLwSuErtdobQRDQav2l9fb84mEklQoiO5HQxAe7w4TRrwNxPX4n2hSJKjMZvALwiugJgCnU2RTwn4r3S7HZ8rBYJLGtt6EJAH5+bVGrvbHZMsnLO1axA5kyZX0PfuHg62wOWh+EvGf3wmeDIPu01GAT4Hj55fkrgy4qCjQacP7/aiMj5c9wdeB3o2TA5G3dSvaaXwuFu2V9v7iMznrwEFTXqKgXpuDAAXA40ERGoA0Pv3wTrAEqdadau3YtEydOpG3btnTs2JElS5aQlJTE7t27PcZ5e3sTHh4uL+7NmNavX098fDzLli2jU6dODBkyhLlz5/L+++9jcbZ2//DDD4mJiWHBggW0bt2aBx98kFtvvZU333yzGi65OKIo4rDYa2WpqGrc/fX09/dHEAT577CwMN544w2ioqLQ6/V06tSJtWvXyvvGOMV6nZ0Fi/r06QPArl27uPHGGwkJCSGkWTMGTpzI3oRSUmNLIS8vj/Hjx+Pr60uTuDjeXrpUqgnjViMkIyOD8ePHExgYiLe3N0OGDOHYscKb0JIlSwgICGD16tW0bNkSb29vbr31VvLz81m6dClNmjQhMDCQadOmYbcX3kzNZjOPP/44DRs2xMfHh2uvvZbNmzfL20+dOsXw4cMJDAzEx8eHtm3bsmbNGk6ePEnfvn0BCAwMRBAEJk6cCBQPIZnNZp544gmio6Nlb+Anb7+N3c1z4559VVLILSAgQPYgWiwWHnzwQSIiIvDy8qJF7968vmiRbMBkZmYyefJkQkNDMRqN9OvXj33ltKx/5JFH6NGjB40bN6Znz548+eST7NixA2tFDCurp4BXrZZuYCqVXv69pKJ22ZGNSGkgzblFi9loND7wj1Tbg0534uXdCABTgKSnYe9yqax/BXClUGu1gbIhpVJp8TdK2psKp1O7vC9eAVKPJ1+p63qd98Cc/AuWDIP8NIjoBON+KFxvLaXAYBUQtFrJiHGiu4QCdiWhb9pUOqbzuyDw9tvxat267J3+XQYLWsK/X1TrXK4GKuKFEW02Mr75BgDvTvU7fARwSarBrCwpSyEoKMhj/ZdffsmyZcsIDw9n+PDhPPvss3g7Lfvt27fTvn17GjRoII8fNGgQ999/PwcPHqRz585s376dAU73o/uYsrQJZrMZs1sGTHYlxKSi1cHZ2dsqPL46iXyhJ4Lu0nrfvP322yxYsICPPvqIzp0789lnn3HzzTdz8OBBmjdvzs6dO7nmmmv47bffaNu2LTpnnYecnBwmTJjAO2++ienECd5eupSb77iDY8eOleuFcDFjxgy2bNnCjz/+SGhICLMeeYS98fF0bNcOURRl4+DYsWP89NNPGI1GnnjiCW666Sbi4+PRarUA5Ofn88477/D111+Tk5PDqFGjuOWWWwgICGDNmjWcOHGC0aNH06tXL8aOHQvAgw8+SHx8PF9//TWRkZH88MMPDB48mAMHDtC8eXOmTp2KxWLhjz/+wMfHh/j4eHx9fYmOjua7775j9OjRHDlyBKPRiKGUsNn48ePZvn0777zzDh07duR4fDznDh8GpCJSDpMJe04OmuDgCr1e77zzDj/99BMrVqwgKiyME3//zelz5xCcr8OYMWMwGAz8+uuv+Pv789FHH9G/f3+OHj1a7HNWEunp6Xz55Zf07NlTfm3LxOWBQfKauYwWAK3Wn4KCvGIGjCiKHDUcAatAeLY3/v6dpU7Wx5we0q5346WVPn9moQCCmkL6CTiwArrdU+6U3FOo3QkI6E56xl9kZu4kOmpc+dfm0r8YI6Wf9cGAOfIrrJwovS+Nr4M7vgK9HxijJG/Mya3QfEC5h6koupgYuR+SvmnVeyCVht+AAaQlJKAOCiL04Wnl75CwUfp58AfoMr7a53OlE3T33aR/uVz2wvj16ytvc5jNnH38cSm1XaXCf9SoWpxp9VBlA8bhcDB9+nR69epFO7c88jvvvJPGjRsTGRnJ/v37eeKJJzhy5Ajff/89AKmpqR7GCyD/nZqaWuaY7OxsCgoKSrzZzJs3jzlzKt4O/Epi/vz5PPHEE9x+++0AvPrqq2zatIm33nqL999/n9DQUACCg4MJd3MZ9uvXD9Fmw3IqCUfTpix8+WUadO/Oli1bGFaBAlO5ubl8+umnLFu2jP79payDpcuW0ahZM0SrFfvFNBIzM/jpp5/YunUrPXtKYYUvv/yS6OhoVq1axZgxYwCwWq0sXLiQZs6nwFtvvZUvvviCc+fO4evrS5s2bejbty+bNm1i7NixJCUlsXjxYpKSkoiMlG5Qjz/+OGvXrmXx4sW8/PLLJCUlMXr0aNq3l0pdN3X7gnYZA2FhYQQEBJR4fUePHmXFihVs2LCBAQMG4LBYiDSbEZs0QR0QgCY0FPOxYzhy8xBttgplESUlJdG8eXOuu+46HNnZhHfpImUgCQJ//fUXO3fu5Pz58+idHpn58+ezatUqvv32W+67775Sj/vEE0/w3nvvkZ+fT48ePVi9enW5c8FhA7sFhwAOUTJgVKrCz5ZG4w+cRRQtFBScwctLem/OnV9NlvUkKrtIs8OpkhB4z+dSRlOT6yG0BfqCMwCYzKmI3R5EWP807PoUut4N5dT+KEyh9qwIGxB4LSRKQl6XcVwm7voXKAwh5ZRtwBQUnOZU0ifo9WH4+bXFz68del1IqeMdDgt2uwmt9hKFqQdXwbf3SG0dWt4Et35W2B08tj/sWSqFkarTgHHqYKB6asAUJWjCeKynTxMw5lbU/v7l73DR6ZlN3iml36trITOvHuPywqR9soiL772Hb19Jk2rPzeP0gw+Sv2MHglZL5BsL8L2uV21P95Kp8n/H1KlT+e+///jrr7881rt/ybZv356IiAj69+9PQkKCfHOqCWbNmsWjjz4q/52dnU10BZuECVoVkS/0rKmplXvuSyE7O5uzZ8/Sq5fnP2OvXr3KDT2knj7NU48+xh9/7+BCejp2USQ/P5+kpIplOyQkJGCxWLj22mvldSGRkbRo3hwA6/lzHIyPR6PReIwJDg6mZcuWHDpUWNPD29vb4/+jQYMGNGnSBF+3jKgGDRpw/rykXzhw4AB2u50WLVp4zMlsNhPs9IZMmzaN+++/n/Xr1zNgwABGjx5Nhw4dKnRtAHv37kWtVtO7d29EhwNrUhKi3Y7Ky4A2MlISPLt7YQIDyz3mxIkTufHGG2nZsiUDb7iBQddcw6CbbgJg37595ObmyvN3UVBQQEI5ob0ZM2YwadIkTp06xZw5cxg/fjyrV68u+yYvF7DTAiIqlR6VqvArQaXSyh6ZtLRNBAY2w24v4PjxVwFokiLiZbJIbQV2L5V26joRAL0+DFAhilYsbQeh//1FOPcfJO2AxnFlXkthCrWnoNTf2AGVSofFcpH8/ER8fMrxGLg8MH4R0k/f8kW8eXkJ/PvvOMwWzzE6nWTM+PjEYrPlYDafcy6pWK2SALZly7lENbyz7DmVhsMBa2dJxkuH22HE+54379gBhQZMNeLKRIJL64FUGpqgIBoumF+xwQ4HpDn/zy25cP5gYfq7QoUp6oUxdO5E8n3/w3TgACpvb6I+eB+fHj1qe5rVQpUMmAcffJDVq1fzxx9/EOUWQy0J143r+PHjNGvWjPDwcHbu9Ixhn3M2oXJ5B8LDw+V17mPKcvXr9Xr5qbWyCIJwyWGc+obDYmH8HXeQlpHB/Keeoln37hiMRuLi4mQtUlUR1GqpHLkoyjUgyqNouEMSkhZf53DG03Nzc1Gr1ezevRu12vO9cxk9kydPZtCgQfzyyy+sX7+eefPmsWDBAh56qGI1KFz/a6IoYj1zFofJhKDWoG0ULQsRVUYjDpNJ0sE49TRFY8/uWpQuXbqQmJjIr7/+yvpVqxj3+OP0+/lnvv/5Z3Jzc4mIiPDQ8bgozUvkIiQkhJCQEFq0aEHr1q2Jjo5mx44dxMWVYSy4Cthp1YDNI3zkQq2WQokXLv5Os2aTOJW0CLM5BS99JI2EYGATbJ4HuangHQKth0uvi0qLXh+G2ZyKSchH3/5WSdewa1H5BkyBKwOpicd6lUqP0diZzMy/ycz8uwIGTCkemFJEvDk58fy7dwJWazreeTb8rAZywhuSbzqFxXKetLTzpKVtKvV0R4++gNGvHUZjxY1kmaRtkHMWvPzh5neKex6a9gZBDWnHIOMkBDap/DlKwN0DUxMhpEqRfbpQdA2SsasYMJXG3Qtz4a23EO12LAkJqAMCiP7kYwzta6/5YnVTqcd/URR58MEH+eGHH/j9999lcWhZuFJ3IyKkp6C4uDgOHDggP0kDbNiwAaPRSBtna/C4uDg2btzocZwNGzaU/WV8lWI0GomMjGTr1q0e67du3Sq/ni7Ni0sA6zCZsJw4wfY9e5g6fjw3T5xIe2fK+sUKGhwAzZo1Q6vV8vfff8vrMjIyOHr0KCofHwStlpaNGmGz2TzGpKWlceTIEXl+VaFz587Y7XbOnz9PbGysx+IeJouOjmbKlCl8//33PPbYY3zyySclviYl0b59exwOB5t+/hl7ViYgoI2ORuXWK0buxp2bi2i3ExoaSkpKYQfkY8eOkZ/v2dTQaDQyduxYPpg7l89ff50fVq8mPT2dLl26kJqaikajKXZNISGlhzCK4jLyzOVVRXZ5YJzfAiUZMFqtLyBgMiVz8eJGTp36EIDY2CdQN5SKy5Hwu/Sz8/+BpvAhwksvfebNphToPllaGf9juVlALg+MoYgBA+71YHaVfW0A2c5UctkDU7oGJitrD3v+vQurNR0/u5Gu+7Jotz+FuH8y6NN5Hd26rqRFi+eJippATMzDtGr5Eh07LOKa7qu5/rqdhIYOQhStHPjvwdIrGJfFgW+ln62He7yGMl7+EO30Yh7fWHx7FfFq3RqV0Yi+dWvU5RjJNc7FIqn2SdtrZx5XAK6MJPPRo1gSEtCEh9N4+ZdXlPEClfTATJ06leXLl/Pjjz/i5+cna1b8/f0xGAwkJCSwfPlybrrpJoKDg9m/fz+PPPIIN9xwg+y6HzhwIG3atGHcuHG89tprpKam8swzzzB16lTZgzJlyhTee+89Zs6cyT333MPvv//OihUr+OWX6q2DcKUwY8YMnnvuOZo1a0anTp1YvHgxe/fu5csvvwQknYfBYGDt2rVEBgWhunABo7c3sU2a8NWGDcQNH052djYzZswo1cNVEr6+vkyaNIkZM2YQHBxMWFgYTz/9NCqVCkGlQhsVRazVxrC+fbl30iQ++uQT/Pz8ePLJJ2nYsCEjRlS9HkSLFi246667GD9+PAsWLKBz585cuHCBjRs30qFDB4YOHcr06dMZMmQILVq0ICMjg02bNtHamQXRuHFjBEFg9erV3HTTTRgMBo9wFUCTJk0Yf9ddTH7wQeY/+SRdrrueM//s4vz589x2222AlP4s6HSIFgv2nBz69evHe++9R1xcHHa7nSeeeMLDk/TGG28QERFBp06dsJ5I5Pv16wkPDycgIIABAwYQFxfHyJEjee2112jRogVnz57ll19+4ZZbbqGbs7+MO3///Te7du3iuuuuIzAwkISEBJ599lmaNWtWvsFvK0AEHEhGXEkGjCCoUamkInv/HXwYh8OEv383wsKGQkaR8FTXCR5/6r0iIPtfTKaz0GgwRHWH07ukcFPvGSVPyZYjh2S8i4h4QRLyAmRk/l2+DsbpgbH5BHD00JNoRS3BAVoCstJR2SygkQzR9PRt7D/wP+z2fPz9u9Fpfzoa2wlQaSA9AfUXt+I/YTX+UV1KPVXrVq+Qm3OIAlMS8Ydm0qH9hxXv82OzQPwq6fd2xdPfXXVvhNj+kqfm+EboPqlixy4HtZ8fsRvW114hRXcuSg0l8QmFvAuSB0YUy9VMXSoFBWdIPr0Eh8NMi+azPcKo9RVNUBBB//d/pH38MbqYGBp9ugitUyt4JVEpD8zChQvJysqiT58+REREyMs3zrQsnU7Hb7/9xsCBA2nVqhWPPfYYo0eP5ueff5aPoVarWb16NWq1mri4OP7v//6P8ePH88ILL8hjYmJi+OWXX9iwYQMdO3ZkwYIFLFq0iEGDBlXTZV9ZTJs2jUcffZTHHnuM9u3bs3btWn766SeaO7UoGo2Gd955h48++oio2FjGTJ2KymDg0yVLyMzMpEuXLowbN45p06YRFhZWqXO//vrrXH/99QwfPpwBAwZw3XXX0bVrVwDUPj5owkL5aO5cOrVowbBhw4iLi0MURdasWVOxLJkyWLx4MePHj+exxx6jZcuWjBw5kl27dtGokZTCa7fbmTp1Kq1bt2bw4MG0aNGCDz74AICGDRsyZ84cnnzySRo0aMCDDz5Y7Piiw8HbTzzJLTfeyCMvv0y7nnHce++9HnWPBEHwqEK8YMECoqOjuf7667nzzjt5/PHH5Qw8AD8/P1577TW6d+/O9bePJclpoKhUKgRBYM2aNdxwww3cfffdtGjRgttvv51Tp04VE7W78Pb25vvvv6d///60bNmSSZMm0aFDB7Zs2VJ2SFUUwWrCoQIRUS5gVxJqtWTUOhwmQKBF82ekm3Ok2w29WT8p28gNLy/pC9Nkdnqkut8r/dy9GBwle75cBex0uhA0muItLfz9uyAIWik0ZUou/fpANmDO2P8jJWUlSanL+beDP3/EBbF//32cPbuC1NQf2bd/EnZ7PkGB19G502I0F09K+9/6GQQ0kjKolgyVMq1KQas10q79uwiCjosXfyMpueJ1bzixSWoV4BMGMTd4bBJFkf0HprDlj46c8E+TvGWJWySjp5pQ+/tfUiHFaiPNKeBtdyuotJKGKfNUjZ0uL+848fEz2L6jH8nJn3HmzJdkZO6osfNdbkKnP0z0xx/RZMU3V6TxAiCIFS1EUs/Izs7G39+frKwsjzo0ACaTicTERGJiYvCqC08elwl7Tg6WU6cQtFr0sbEI6prX/YiiiCXxJI78PNR+fugaNy5/pzqC9dw5bBcuIKjV6Js3LzXLyJFfIDXDU6nwatWqQoUAXe+FSq9H7zQ0Lys2C5w/iEWrwqRXodH4ylVv3TGZTJw4cYKLaY9htR4lImIMbVq/UjjgzXaQlQxjl8n6FxfJyUs4emwuoaGD6dD+fSlb6fVYMGfDlL8gvLg7OzX1Jw7GP4K/fze6df2mxKn/s3sMWVl7aN36VSIjSinY53DAi6HgsLFr8HVk5x/G39iZgrR/sZRgN4eG3Ei7dm+jspjhFaf4/8kkMGVLxkvmKakdwcTVklFTCqfPLOfIkWcRBDVdOi+XeziVyXf3Sinm106BIa96bLpwYT37D9wv/623QGxCDg2GfIvQ9IaiR6rfLB0OiX/AyIVSTaHTu+CWj6Dj7dV6muzs/Zw89SEXLqwHCusf2e35NI99mkaNyk/1V6hZyrp/u6P0QrqKkMvWGwyXxXgBpxi3oWT923NypFYD9QBHQYEsQNZGRpaZIi0YvKQ6Lg6H3E+qPGq/B5IrA0n6P1CpSw8dCoJAk8ZTCAkZQGyzIqGf0Z/CsLegVfG0e5cHxuzywGj0hX2UzuwuNh4Ka8CUZEy5CHDpYMpq7JifBg4bBV5qsvMPAyrad/iQ605F0X1PJjF+QzEaOwECEeGjaNfuXckDle7MgvEJlXQnAdFw9xoIjJGMmCVDIaN0r0DDyDto0GA4omjnv4MPY7Gklz5HAEt+YYuAIuEjh8PK8QSpinloyI14eTXErIODrf3YnTiD7Oz9xQ5nt5sxmVOx2+thV3hXCCm4OTRyZslUsw4mIWE+u/65hQsX1gEioSE30q3b9zSKlkJyFa7yrFAnqP/BPoUKUx1l66uCSq9H7R+APSsT2/kL6BqX/gRbFxAdDqxnzoAoojYaUZXTeM4VRrKlpWHPzq5Qozr5vdDVkuveVYFXLQAialXZJeTDwgbRqFEJmqVG10pLCeidIl6Tya0vU1Q3KQRy+h855dqdArcu1KURGHANp059SHrGVkTRjiCUYIw7U6jPRQYBIoGBPaRaLr7hGM/uxajqQtOu7+BwWFCpCkXZchpvkFtKsX8UTPxF8hCkJ8DnI+CetYXZTW4IgkCrli+Sk/Mf+fmJxMc/RseOnyIIpTwrHl0L1jzJuxPl6a05e3YF+fmJaLVBtGnzOoKgJWnXI5zKWUeW+iK7/rkFf/8uOOxmrNYMLNYMHA7pfdXrw4nr8Zsc/qvzmHOkLCyAkFhoFAfb3pV0MNV1CstFTiVJIv7wBiNp3Ph/+PpKZRhMBVJ4MFcxYOoVigfmKkI0S3Hzy23AAGjCpGJ69pzsOu+FsV286EyZVqONiKiQGFMl62ByPNoolIZocnrDvGrJgLEV4AAcSHMtScB7qbg8MBbLBRzOTtc0lPRRnNlT4j6FHpgmpR43IKAHWm0gZnMqFy/+XvIgpwFzPkR6RmsQJtXaKZpK7WG8gKR3AQguUhPFv6EzfNQYMhLhi1sgv2TvikbjS/t276NS6UlL/4OEhNdLvRb++0762W60h1jVZsvlROLbAMTETEOj8UOt9iKm/VzidmUQkSp50LKy9pCTexCT+axsvACYzamkZ9ROdfEqkeYm4DUEFmZcXThc6usMUtHBpOTFFfI4pZz9FlG0YTR2pm3bBbLxAuDjI4Vx8/KOVbi9i0LtoxgwVxGixXnTrIWnfpcXBsB2iR2caxJHQQG2C9L8NBERcon/8lB5eyNoNIgOO44izU2LIoqi/F7UhjGJKII5x+l9kW7iNZF5odUGycJgs9mZuuwyYC4cKrE3Ulkp1C7Uaj2REVIF59Onl5U8KCeFfC8VOV5WBEFNaOhAab2rmJ2rRkxRXDfSogYMSC0Jxv8oHeN8PHx5q+Q5KAFf35a0avUyAKeSPiY5eUnxQQWZcGy99Ht7z/DRqaRPsFrTMBia0DDSTQPiE4I+pBNtjuZyjd8DtGmzgI4dFtGt2/fE9fidG67/l4YN/w+AixerL926xnEPHwH4hECI08Aowwtz7Pg8jh17kROJZffJE0UHZ85+BUDDhsU1Nd7eTRAEDXZ7bmHIU6HOoxgwVwmi3S53iRb0unJG1wx1xQtjz87GmpKCLSMDR0GB7DEpGjqqUOlzJ4IgyF4Yezl9uESbDdFZf0bQ1cJ7Yc0Hhw27Wvr414T3BaTXpDCM5KqKGy719REdkLLXc1rWDGw2qYaKt6FssXfDhncCAukZf8mZSx7kpHI+VDKeAgPi0Omc1Y1lD0wp1XhLCiG5ExQD41dJXoIzu+HrO+V6OkWJCB9Js6ZSl/ijx17k3LkiZSAO/Qx2C4S2hgZt5dVm8zmSkqQspthmM1GpihjRzW8EwO/kf0SEjyQkpC/+xo54ezdGqzUSGiK19bh4cVOVvQl2u5l9/97DieMLqrR/pXHVgAmJLVzXyFkGoAwdTHb2XgDOnPkSqzWj1HHp6X9iMp1GozHSIGxose0qlU7WXeXmHS22XaFuohgwVwmyaFSjuWwC3qLUBS+MaLNhSU7GlpaG9cwZzAkJmA4dwpyQgCUpqdKhI3fU7mGkMm4c8nuh01UoY6nacXoNHFrJ61JTBgyAl1cJOpiGzvTrIkJel/dFrw8vV7thMEQTEiw1qivRC5OTwjmnAdOggdsNSy5mV0oxPZeItyQPjIuw1vB/34HOV8qa+fZusJfc+btx4ylENRwHiByMf5z0DLeb8X/O4nVFvC8nEt/G4SjA39i50HPkTqyzF1LC7yWmowcEXIta7Y3Fcp6cnP9Kv44yyEhZy8WMLSQmfUBG+mUoKOdKoQ4pDOsUGjAle2AslouYzZInzW7PJ6kkL5eT02eWAxARPqrU/y33MJJC/UAxYK4SakvAW5Ta9sLY0tNBFBG0OqlasEoFooijoEDOIKpM6Mgdlbc3glqNaLfhKFJ91x05G6y23gtTFiJgF2pO/+JCrsbr7pZ3iVVP/+Mx1uVJKUvA605UlBQqSUn9Drvd8/XOyz9Jrq8GARWhoTcWbiirI3V+ulSPBYrVtClGw65wx9eg8YIja2DVA1LqdhEEQaBFi2cJDR2MKFrYv38KObmHpYaSiX9Ig9qNlsfn5h7l7NmVAMQ2n1WyER3ZBbwCwJRZopZIrdYTFHQdQOkaoXLIPfen/PuRQ0/hcJRsoFUbRUNIUJiJdPZfWXTuTk5OPACCIBnip08vxWYrHtIzmVLkFhANG95R6hR8fCTjKS9X8cDUFxQD5iqh1m+aTmrTCyM6HNjTJUGgpkEY+pgY9K1bo2/eHF10NJqQELTh4ZUKHbkjqFSo/KTeQY6s0sNIYm0ak3YbWPOLFLCruVRuvauYnYcHpmQhr0vAW5b+xZ2goOsxGBpjs+WQmvqjx7bzgpTqHKRvhVbr1mTTXcRb1EvmCh/5RYLOp/wJxFwPt30uVew9sAJ2fFDiMEFQ07bNGwQEXIPdnsvevXdT8N9SKYzWsJsUlnIiCX4dhIYOIsC/a8nnVWugmeR9Kq25Y0iwM4yUVjUDJqeg0AuRZ07i9JlStEZVxGrN4OixFzl7dgU2S1ah9ijEzYAJbCLpjRzWEg01lwETGjoQb2+pyWby6c+LjTubshJRtBMQcC0+PrHFtrvwdXpgLikTyZQFlrI1cArVh2LAXCWIltrLQCpKbXlh7FlZiDYbglYrh3sEQXAaVf5ow8PRhIRUOnTkjtwbKTur1DBSrXrDzJJhZddK2huVynBJ11seLg+Myd0DE9EJBJXUvM9NTCt3oa6gASMIKqIa3gXA6TPLPF7vcz7Sk3hYUD/PnVweGFuB/FrIVCR8VJQWgwqLz/25oFRRr1qtp0P7D/HxaY7Fcp5/sz/lcKwPCS0bcOrUx5w5+w1JSZ9xMe13BEFdvN5OUVxhpFIMmOCQPoBATs5/hQLqSpBrOQNA6AXpf/XEibcwm6vvgePUqY9JTl7MocOz+HNrD/6L1XAx2IDDv2HhIEEosx5MTu5BAIx+7Yhp8gAAycmLsdkKDQiHw8bZs1JBRA8xdAnIHpi843L7hkphzoEP4uCTfiV64xSqH8WAuUqo9bojbtSGF0YURexpaQCog4JqTHui8vWVwkg2W6lF7WrVGyYbMDWvfwG3dgLuHhi9ryRcBQ8dTIErhbqCISSAiIhbUam8yM09TFaWdKzc7EPkGUBwiIRGeFYHRucNemednqI6GFnAW8muzF0mQnAsFKTD3x+WOkyr9adTx8XotaEUaG2ciTRw0vY3xxNe5fDhpzh2/CUAIiPvKLOQHwDNJA8LZ3ZD0t/FNut1IRiNUifnixdL76BdEnZ7AfmiFEprkZCHMduK3Z7L8YRXytmz4qSnS81ntdogHKKFc2F69rX1YeuO3hw99lKhILcMHYxL3+Pn146wsKEYDI2xWjPkbCOAtLRNmM2paLVBhIWV3YrGYGiEIOhwOAowmc5U/qJObIbsM1Lqd17dzbS8klAMmHrExIkTEQSh2DJ48OAy9xMdDjcPTM1lvUycOJGRI0dWaKy7F8aeU/jU+sknn3D99dcTGBhIYGAgAwYMYOfOMiquVhBHXh4OkwlUKjSBgeXvUEUElUru6mvPKJ4VIdpsbtlgl9mAcaVPC2BF+n/QaMovuncp6L1K0MBAMSGvKIrk50thn3Jv3m5otf6EN7gZQA4fnD8j1VYJyrShNZZgjJSWiSR7YEoPM5SIWgN9Zkm/b3tXSo8uBS+vCLqLQ2lxPJeYrBCiosYTHj6SkOB++Pt3JTi4N01jHi7/nMYIaDEYEOGLkdLNswgukXNlw0i5eUdBAK3FgV4TSIuEPBAhNXUVmZn/lH+AcrBY0snJlcI/116zhu5e44g6U4DWocFiuUhy8mecPLlQGuzywCTv9BAsW63ZFBQkAeDn1waVSkOTxlLLhaSkRdjtUmbYGad4NzLi1lJ7fblQqTT4+DQtfA0qy7ENhb9nJlV+f4VKoxgw9YzBgweTkpLisXz11Vdl7iNardLNS6Wqkji1POx2O45KukxVer18o7ecOiVlAFksbN68mTvuuINNmzaxfft2oqOjGThwIGfOVOGJyH2OLu9LQECZbQGqA9mAycmRjRUXDlcxQa328meDOdOnLXrpvBqNHxpNDXtgnCEkmy3HU2Dp0sE4hbwWy0Xs9lxAhcEQXalzREWNA+DChXWYzec5lybVVmmQ4w0ledpKE/KWVQOmPNqOkrxKpizY/n7p47JOo9/+KdFnTTRtPJWWLZ6jbZsFdOz4Cd26rqBTx8/Q6YIqds5bF0tNNK358OVtcORXj80hznTq9PSt8g29IuTmHALAL8+GcO39+OfYiHQWzjty9DkcDltZu5eL1DBRxMenBXp9KMaMHFom5HEdY2nWbKY0Z1cRvgbtpGwvcxacP1Q4R6cB5OXVUNY4hYePwEsficVygbMpKygoSCYtXRIjR5YTPnIhZyLlVlIHI4pFDJiaa0KpUIhiwOBsOGix1MpS2ToNer2e8PBwjyXQ6VHYvHkzOp2OP/8szCB47bXXCI+K4tzFi6h0Ovr27cuDDz7Igw8+iL+/PyEhITz77LMe88jIyGD8+PEEBgbi7e3NkCFDOHas8AO9ZMkSAgIC+Omnn2jTpg16vZ577rmHpUuX8uOPP8qeoc2bN2OxWHjwwQeJiIjAy8uLxo0bM2/ePAC0ERFogqQva3t2NuZjx1jyxhvc/7//0alTJ1q1asWiRYtwOBxs3Fj1olwOs1n28miCg6t8nIqiMhhQeXmBKGLPzPTYJpqlG0Gt6F9M2ZL3RSNpXvT6ynUerwoaja/s5ZFrwUBhJtLZf8HhkAW8Xl6R5T4pF8XPrw3+/l0QRRtHj80l33JGCh85IkreweWByXEzYEQR0pxVeEurAVMWKhX0dXphdiwsuXqs3Qrf3iNlOkV0gg5jK38ed3TeUiZUq2FgN8M3/1dY2Rfw9W2FXh+Bw2EiI6O4hqQ0cnMPS/vn2qBxT4juQbPEPDToyc09zJmzyy9p2hnpknESFNhTWuGsAaMKbklExGjnHA5JYSS1BqK6S+PcdDA5OZL+xc+vsH6OSqWjceMpAJw69RGnT38BiAQFXoe3t7Ou0IWjcODbUjUqVU6lPh9f2AoBFA/MZULphQRYrVZefvnlWjn3U089ha6aipn16dOH6dOnM27cOPbt28eJEyd49tln+XrRIhqEhMg3zaVLlzJp0iR27tzJP//8w3333UejRo249957ASkUdOzYMX766SeMRiNPPPEEN910E/Hx8WidHpz8/HxeffVVFi1aRHBwMBERERQUFJCdnc3ixYsBCAoK4p133uGnn35ixYoVNGrUiOTkZJKTkwGkeiuRkaiDgrCmpODIy8N24QL2jAw04eFoAgLIz8/HarUSFFTBp9ISsKdJNxOVn99l052oAwNxpKRgz8hEHRwsC2VrW/9i1kvPLBqNscb1Ly68vCLJzc3GZD5bWL49tDVoDJImJ+0YBdaTQOX0L+5ENRxHVtYezp9fA0BwugWNT2TJg13VeN09MHkXwJIDCB5ZQZWi1XCpw3bqAdj6Ntw4x3P77y9C8t+SBmfMEqm55aWi0UvHWnU/HFgJ302WUo47/z975x0eR3l18d/M9tVq1btVLBe5dxvbYKrBNiUQOoFQA6ElIRCSjxQCAUKHUB0IwXQI1RAgYJoNGNwtd8uyrGart1XZvjPfH+9Wdclygz3Ps8+uprwzO9rdOe+95557CZIkkZx8Ivv2vUpDwxckJ5/QryHb2kWkw9LhE9GqKT9DX7mKEVUaijJhz55HSEs9Fb0+eVCn3NQs9C+JiUeLBQ2hCiSDPpmYmFF0dBTT3LxG6FZy5sCer4QOZpb4jQpUIMVaxkWMnZFxLqVlT+Jy1VBR+TwQMD304+0roHarIJH+scJhCQp5B0hgAo7KAUQJzEFBNAJzhOHDDz/EYrFEPMLJ1913301CQgLXXHMNl1xyCZdddhmnnSgqMQIEJjs7m0cffZSCggIuvvhifvWrX/Hoo8KKO0BcnnvuOebNm8fkyZN59dVX2bdvH0uXLg0ex+Px8PTTTzN37lwKCgqwWq2YTKaICJFer6eiooJRo0ZxzDHHkJubyzHHHMNFF0V6MchGI/q8PPQ5OUg6ParXi2fvXnw2G3/4wx/IzMxk/vz5g7peqs+Ht0VoUQ5G9CUATVwcSBKKy4nqDIXvD1kFks+Dz+fAqxVfeYMh7aAdOuDG6wqPwGi0kDlFvN63fsAl1J2RmroAnS70/02rd0NsHxGYcBFvQMAbnz14YiHLcMKfxOs1z0aOv2sZrPyHeH3mk4MnSd1Bo4OfPgPTLhOl2e/fAGtE08LkZPHdb2jsnyuvqiq0+wlMbLsXYtNg/E9BayJrdwWxhjy83jaKdv1tUC6/DsdeHI4KJElDfPzMyCaOfu1RQrzQvTS3+CMuwUqkkJA3UIEUGzsh8lJoDOTmBIiJil6fGrwG2PYK8gLwxV3dmhkGIzD23ahqV5PAHlHsrwbLEMLpKIE5OIhGYACdTscf//jHQ3bsgeCEE05g8eLFEcvCoxN6vZ5XX32VSZMmkZuby6OPPopaK2aagVn/7NmzI0pn58yZw8MPP4zP52PHjh1otVqOOirUYTgpKYmCggJ27NgRcZxJkyb1eb6XX345J598MgUFBSxcuJDTTz+dU07p6i4a6OgsWyx4qqrwtbRw37338sYbb7B8+XKMxsF5lfiam0FRkA0G5Jh+eHsMESStFo3Vis9mw9fcjGwS7p+HzAPG1YZLL/7nWl0cGs2B837pjG4rkUDoYCq+FwQmR5C8/pZQd4YsG8jKvICy8qeRVZnkJjdM7NotGuheAxPQvwwmfRSO0QvF+9q3Hr59FBbeC7Z98N4vxfpZ18C4brp67y9kDZzxmNCLrHoK/vcHGPsTEuLnIMsmXK4a2tu3R6RcuoPTuRefrwNJUTF7DWI8SYJxZyJtfoOClizWmSqoq/uIPebhjMj/7YBOs9mvbbFaJ6PVxkLdRrHCnAxm8TuWkDCbvfteprnZT1iGzQBJI8ruWyrxxSbR0SEIZ3fvJyvrQsrKF+PxNJGZeX6oFUNJWDWWywaf3Q4/jawaM5mykWUDiuLC4ajon6DcaYOK71GBrePi0MRaGNtYzoEzJ4gigGgEBnHz1Ov1h+QxUA+OmJgYRo4cGfHonF757jvxI9HU1ERTU1OEdf1QwWTqn3/ItGnTKC0t5a677sLhcHD++edz7rnn9ri9JMtoEhL4xwsv8ODTT/Ppp5/2iyh1B1VV8QbEu2FpnIMFjV+b5LPZRCWYzycE1Rz8FJLP1RyKvugPvPYlHN16wUCEkHcwJdSdMSz7MqzWyeS2paD1qaL5Ynforp3AYDxguoMkhaIwa/8tZuJvXylKrDMmwyl379/4fR17wT1C+Kr6oOwbvyuvSNXU98OVNzx9JMekhTpkTxV+O3Gbv2LMyL8AUFb2JPv29V5A0BkBcW5CUP/S1cAuPn4WINI4bneDMBUMRDYKX/WLjBX0+pRudVwajZlx4x4kI/1scrKvDK0o8b//UacAEmx6HcojO3ZLkoYY88jg8fuFPctB9dE6LI86z3aq043Y3fu6GiVGMeSIEpgfGEpKSvjtb3/Lv/71L4466iguu/RSfP6bZmDWv3p1pG/EqlWrGDVqFBqNhrFjx+L1eiO2aWxspKioiHHjIvPNnaHX6/H5uoZdrVYrF1xwAf/617/4z3/+wzvvvENTUzciRz8eevJJ7nvmGd5fvJjpEyf2+713htLaiurxIGk0wcqggwk5JgZJp0P1+fC1toZK2TXaA14JFQFVxYWw2tdpLAc1+gK9RGD8Ql61duuATey6g0GfzMwZ75Jf5b9xxPYUgQmkkMI6UjcOsoS6O4w4Ueg2fC749ylQuWpodS+9QZJg+LHidZkQ8wdSKI39IDCBCiRLhzdE9AByj4H4HHC1ktViJC/vRgB2Ft3e73YFqqrSFBTw+vUvwR5IIQKj1ydisYwBoLnZ/zs0xt/Pavm9tH5xE9B99CWA5KTjGTfuQXQ6v6u24hM6GoB5t8C0S8Xrj27p0scqxjJAR16//qUpJy+4qCGOnvttRTFkiBKYIwwul4uampqIR0NDAyDKmS+55BIWLFjAFVdcwZIlS9i8eTOPvfiiKNv1l5RWVFRw8803U1RUxOuvv84TTzzBb34jvCdGjRrFmWeeydVXX823337Lpk2buOSSS8jKyuLMM3sPfefl5bF582aKiopoaGjA4/HwyCOP8Prrr7Nz50527drFW2+9RXp6OvE9EIr777+f22+/nWceeICcrCyqSkqoqamhvQdTuN7gPQjGdb1BkqRQSXVLS5j+5eB2oPa6mvD6K7b1xh6iEgcQhu76IQHEZUNMCu0mFUVxIssGjMZh+3/AtkDn6x40MAFi09EgWisANO1HBVJnhEdhAufyk8cHbpA3WOTNE8+lfgLj94NpbduMy9X7TTUYgQnoXwKQZZjsF8MWvkr+8JvIyDgXUNiy9dfYWjf1eVodHbvweBqRZRNxcVPEwkAX6vAeSITrYPxppKNvgvl3gtZIu1P8r2Jtzm6bWXaL6k1CuGuwisjf/DvAlCiqh1Y/E7HpgHoiqWpQ/9IcEyJCDYn6qA7mICBKYI4wfPLJJ2RkZEQ8jjlGNG675557KC8v55lnxBcyIyODxY88wp1PPMGWkpLgGJdeeikOh4NZs2Zxww038Jvf/IZrrrkmuH7JkiVMnz6d008/nTlz5qCqKh9//HGfep2rr76agoICZsyYQUpKCitXriQ2NpYHHniAGTNmMHPmTMrKyvj444+ReyAUixcvxu12c9H115N/wglkT5hARkYGDz300ICuk+JwiIaKkoRmPyqY9heBNJLS3o7SJkiYZDi4ERCXW7iC6lQtGs3Br34KRWBqIi3aJQmyposfe0RaIahXGCw8zlBDxp4iMOYk0coAFewNoqS2cYhSSAEMnwf5x4vXM38hhLAHC7lzxftrKoHWKgyGVGJjRSSzsXF5r7sGSqhjO3yhaq0ApvjF93tWINn2MqbgbpISj0VRHGza9ItgFK0nBNx3E+JnIst+Eh9MIY2O2DYhwU9gAjoYjRaOuQmu+462RBFVid20DJ5fAHU7ez0uEEofDT9WiJ7NiYLEACy/F1pD0UHLQEqpa7ZAew0+gxmbuzS42BanxdPUj/OKYr8QJTBHEF544QVUVe3y2LlTfFFuv/12qqqqSAqrtjlrwQJaNmxgytSpwWU6nY7Fixdjs9loamrinnvuidCHJCQk8NJLL9HS0oLdbueTTz5h1KjQDOnyyy+npZO/CUBKSgrLli2jra0NVVU5/vjjufrqq9m4cSPt7e3YbDY+//xzpoadS2eUlZUJ23+7HfuWLTi2bUPx+bjjjjsGdK2C0RerFfkAmPf1F7JeHxQP+2wtYtlB1L94ve34EDNDg/bgVWGFQ1Q8SaiqG7enU+owawYNSeJmluI3XtsvBCIeWqPo2NwdZA3ECCdo2mvFPl6HaMoYn7P/5xDAuUtEs8eF9w/dmP2BKR7S/bqxQBTGf23rG3r2U/J4WnE69wKBFFInfUlCnj+6o0Lha8iyjgkTniQ2djweTxOFm64QmpUeENS/JPr1L4rSfRNHAjoYCbt9T0QvJyVhGO1GQYJj3QbYuxaemddtO4UIBAS8gSaYAFN/Ljxm3O3w6Z+Ci0OVSKV9m/btFuZ1tlFTUFQ3BkM6Mb4YVEmiqeW73veNYr8RJTBHOBSXK5ia6A4HQsB7MCAZjaKnkKKgOPrvIgrCrt9nswFCvHuooenUukAy9kJgVFV0s22tEuH1QDRhEFBVNVi6rPcoyMYD10KhN8iyLigcdnXSwbjSR9IaK/RAwXLX/UGgOWRsekiA2h3CS6kDAt74XDE7HyqYE0XFkeYQFHsO96eRyr4GIMV/bZuaVkY0OwxHIPpi9OrQedXuI1hTLxHPK+6HlY+hlU1MnvRvjMZhOBwVbNn6624bISqKh5YW0RIkqH9p3esnjjpx7cOg08URGys0d0EdDEKXoqoetNo4jFetEoTK5xaNNHuCq03474DQJwUgy3DawyJate3dYDsGozELjcaMqrpxOPpw1PW77zali9+ZhIQ5JGlEqrDBub33faPYb0QJzBEMX0cHrt27cZeUdLGsD+CQdj7eD0iSFIxcKB0D0794m5tBVZGNpmD58qGExmpFkkNtA7r8LxRFlGK2VAifioZdIjLg7oDmCvAOjMAF4PXa8ClOJFVFrxiG9uY8QAR6IkW48QKNRhtIErFtHgy+ITi/oP6lD61PQKDaVrN/LQQOV+QFhLzfAmCxjMNkykVRHNTV/6/bXQL+Lxan/7YQLuINYMK5MPF8UeX02e3w+oUYfBqmTF6CLJtoaVnNvqo3uuzW2roJn68DnS4k0KXBn6JJzO+W5AV1MGEuwuEOvFL8MDj9H2JF8TJoLuv+WpStBMUjIkiddUgZk0WKD+Cj34HXhSTJwUqkXnsiOZpFjyagWSsii4kJc0m2CHF6o1w9MC+ZKAaMKIE5QqG43XgqK0FVURWli2U9BJo4RlYgLV++nH/84x8H8UwHD9liAUBp737G2B1UVcXnr3DSJCUe9NLp7iDJMnJ8XPB1RAWS1ylIS9MesDeC4hWeF8Z40JkBBZrLB1ySqaoKLpeIRujdKnKgA/MhQlAH44qMwDTYhMYhudEN+zbs/4HCIzC9IdyNN9iF+gdEYHJmi89Rcxm0VCJJEpkZwr6guurtbncJthBo80d0uyMwGi2c/awgDhoDFH8K/5xHTFMDI0f8DoDdu+/rUnEWKp+ejST5bzsNXSuQwpGQIDpRB4W8hBMYf0Vk8kjIPwFQYd3z3Y4T1L+M6CHCd8KfICZVVET5Izn9ailQ8hWoPjypo2i1C6KTkDiXuOSj0XoUPBofrf0QN0cxeEQJzBEIVVHwVFSKqItfDOttau7ijCnKdtWuN80jBMEIjMOO2s9mkRGl03FxB/L0BgRtYiJIErLFEkmqnK1iNitrhZlX4ghInyCcWhOGi5uQx9618WAfcLsbURQPkirSR+gPnolfdzB248br87lobBIRguQmN+zr1Om4vV40KGwsod/oqwIpgIgUkr8C6YcUgTFaQ07H/nLq9PSfAjIttrXY7aVddgl0iI5t9jfd7I7AgEjNzbgCfvG5+Ly27oUlixhW0UqcdRo+Xwc7d/4psr9a5/5HECqh7qF0PT5+BpKkweGoCBKiUAuBsBLqQEuADS8LEXdn9EVgTPGwyK9T+uZhqNkaLKXulcDs9lcfjRwPqJjN+RgN6cgJw0lqFhPH/paYRzE4RAnMEQZVVfHs24fidCBpNBjy85FkGdXtQumIjFQEfUcMhsMiEjFQSHq96J6tqigd9n7t4/X3PTpUpdM9QTYaMRYUoBvWqUzY7X9fMSnCxt5o9VfIAFo9xPm3b6sJbdsHFMWL2y3KZQ0un3AE1R3aVFoohRSamTe3fI+iODBIFmLbfVD6NWx+E/77G3hyJjw0El6/EF44rf/lskEC01cEJsyNd6grkA4XdCqnNhozSEoSy6qq34nYVFG8dPjTJZZ2j/gMxvTR6yhjElyzXHTiVrxIn/2FsS3DkGU9jU1fU1PzLgBebwe2VuG4G+x/BGERmNF0B602NtgqoLl5FYriDbU5CG8hMGoBWIcJs8Bt70YO0lIhiJKkCV2P7jD+p6IppuKF928gxihSTT0SGEUJ6l+aE0JVdABYs0hq8hOYus+63b2/cLlqqav7lOLd97F+/YWs+HoaZWVP79eYPyQcPr/wUfQLvoYGIVCVJHQ5OchGI3LAa6STOdyRqn8JQOhg/GmkfuhgFKcTxd4BHNrS6Z4gabVdSZXHT0p0PTRWNCWAMQ5QoaW8xy664XC5a1FVBY2sF2JMWSuEkocQRkMghRSKwDT4K2KSrbMFySpfCe9eDetfCPmDIAlSUtdPQWQwhdTPCExbNTT7oxE/pBQShAl5vw0uysg4D4Ca6ncjKmzsjlIUxY1GNmJyKoJQh+m2eoTRCuc+DyffBUBM4ccMz/s1ALuK78blqqPFthZV9WI0DsNkCqvy6iOFBOE6mFXY7XtQFCcaTUyk4aFGKyJCEOwBFUSg+mjYDBFp6QmSJAS9xjioLsRSJCJGdru4Ll1Qsxk66kBvoclbBkBioLpKoyPJmwKqSrtjd1cDxz7gdFaxffutrFw5j29XzmXL1uupqPgXLba1eL02ysqfwedzDGjMHyqiBOYIgq+tDY+/r5EuPR2NP8Wi9d+sfa1tKJ6QmdKRWoEUDtniTyP1QwcTKp2OPaSl0/2G4hVurdAzgZEkYfgma4Vepq26++388PmceNyCyBqw+KMv5t4rcg4CjJ0iMKqqBsPrydnnCnGlpIHMaTDnRrjwdfh9aajstfz7bsftgoFGYKo3iyoWjSEU7fqhIHu2+NzYKoIC15TkE9HpEnC5a2lq+ia4adCBV5spPjM9pY+6gySJHk8aPbTXkGM5idjYCXi9rRTt+mv36SNXe5cmjt0h5AfzPW1tohGjxTI2pKMJYNpl4vhVG0QfqgD6Sh+FIzYdFojGuIYVT6KRzaiqt9t0WyD64hwxB7tjDyAHyRaAPjaXuFZBEBv68N4JQFVVqqvfYdXqRVTXvOvXi8lYLGPJyryIsWPvx2gchs/XTn39/kV2fiiIEpgjBIrLJUS7iLLc8AiDbDQim82AKpoX+hEgMAe7785QIqiDcTp6rLQCf+l0y+FTOt0vBFJCGn3vpbYaXcifpKNO/Pj3gIBwV6u1ovX4r1dP5OggwuAX8brd9SiKm/b27bhcNciyiYSkY+HGdXDbXrjmK9HPZ8ypogw513/Tq+inp0YgAtNTH6QAAgTH65/JJg7vX8ThSILBIgghBNNIsmwgPU04aleHpZGCFUj4f1f6IoCdoTMGe1vJlWsYO/Z+JElLff2yYFVS0P8FQpVfYU0cu0Nc3HQkSYvTVUVd/afi1AIC3nBYUmDcWeL12n+LZ8UXLI3uF4EBmHIxjDgRyeskxi6inV3SSB4nFH0EQHN2tv+cxofaFgDE5whdF9DY8BV9weVuYPOWa9m+4/f4fO1YrVOYOuUljjt2I0fN+pAxY+4mM+NcMtKFIWJ1zbt9jPjjQJTAHAFQvV7c5eWoioJsNqPLyOiiaQkQGp9fzKuqKqorpIE5UiHrdMHz76zxCYevuQVUJYzMHQHoK30UDmOccJAFfyqpqybE623H6xUCTIMhHTz+m/Mh1r8A6HWJSJIeUHG5aoONBRMTjxbuwLIG9N1chxz/Ta/8u74rsVxtwpQM+o4gdDZp+6GljwIIppFC0ZYMfzVSfcPnuP3RumALAa//f9D5+vQHOaJqiPLviLWMIS/3OgB8PvG9TfRXFQH9Sh8BaLUxWK3ClC+QcuyxB1KgHHrrO2BvgqpCcLaAIS5E5PqCJIkKK10MluYWIKwnktclUlSPT4WqjSDJNJld/vc2N3Kc+ByS/ASmqfk7fL6erRDq6j5h9epFNDR8jiTpGJH/O6ZP+w+JiUej1Voitk33E5impm+7WBL8GBElMIc5VK8Xd1kZqtuNpNOhz87uVpyqsVqF8ZvXg9LWBl4vqv8mtz8pJEmSWLp06YD2Of7447npppuCf+fl5e1X6bbGr4Px9UBgVFXF2xTW9+hIESwHCEx3N+7uYM0S0RqfO7IRIX7TOr++RK9PQiPpQtGFwyACI0kyRqOY1Tud1cGbUZ/uu1nT/amJ2lC1UE8IRF8MVhF96A16S+R1STpIfYoONsKFvH4CGBs7ltjYCaiqh5ra94GwFgJ2/3encxuB/iDXL9D1d3jOy7s+2FfIYhmDXh8mCq4uFM8pBX0OG0rNBM5/QvcbZs+C9Iki1brx5bD2AfMGZiaYkAvz7yDGLiKYHc0bYe1zgrh8/DuR+rJmoZ79L5o7RJl0RHQJID4HS4cPg1eHojgjSsED8Ho72LbtFrZsvQGPpwmLZQwzZ7xHXt51yHL352s25/pdilVqapb2/z39QDEgAnPvvfcyc+ZMYmNjSU1N5ayzzqKoqChiG6fTyQ033EBSUhIWi4VzzjmH2trIEtCKigpOO+00zGYzqamp3HrrrXg7pQeWL1/OtGnTMBgMjBw5khdeeGFw7/AIhurz4S4vR3E6MU+ciGnMGGS9HkmSujzu/Nvfgo6v3qYmlEAFkl5PeUUFkiRRWFh4SN7H2rVrI3otDRQhHUz3qROlre2Qdp0eNAIpJF0/S5xlTUic6mwNLg54vvh8TiRJRq9PDZEXWRs0sFu8eDGTJk3CarVitVqZM2cO//tf96ZmBwKBpo6trYW0tW0BICnphN52iUhNBG6MPSJQTdSXgBfETDs8yjAUXagPR2QfJQTcbVURBDDTL+atrnoLl7sBt7sekLC0+SMFA9HABI81S1QvNZdCazWyrGf8uIeJjR1Pbk6n7/8ukQ4K9ovqBQEdDIAs64kx9xAtkySY6S+pXvvvYJlzv9NH4Zj5i1BTx+qvRdfq1n3CIPHUh+DXG7HnT8PlqkGS9MTHTY/cPz4HCUhuFYSwoVMayeHYx/oN51NTuxSQyc29jpkz3iU2dmyfp5aRfg4A1TXvdLHOGGqoqkrl3pfYsPHnffa6OhQYEIFZsWIFN9xwA6tWreKzzz7D4/Fwyimn0BE2M/7tb3/Lf//7X9566y1WrFhBVVUVZ599dnC9z+fjtNNOw+1289133/Hiiy/ywgsvcPvttwe3KS0t5bTTTuOEE06gsLCQm266iV/84hd8+umnQ/CWjwyoioK7ogLFIcql95WWUl1dTXV1Nf/4xz+wWq3Bv6urq/nd734XTCMp7e0orSKVcDikj1JSUjDvR1onoINR3e4IkTL4tUE1YuatSUg4pKXTPp8PpZ9+Nfg8wh0UBpbiCRjSeZ3g8+DzOeiwlwR70BgMaWL2FkxPmYIC3mHDhnHfffexfv161q1bx4knnsiZZ57Jtm3b+n/8/UDAzG5f1esAWK2TMRhS+t4xkJqo6EPIu8tPxvKO7n27AMJv0j/UFJLeLPr9QEQaKS3tDGRZT3tHEdVVbwFgNuehaff3MoodBIExWkUEBIKapdjYccya+QHp6WGd7Jv2iNJmWdsvciF0MCKKbIkZ03vDz4nniXRrSzlU+qMegyEwsozlhAcBsJtkfHHpsOhB+PVG4TujNdDsN+eLj5uGRtPpO+zXrCXXtADQ2PhVkGy0tKxj7bqzaG/fiU6XxLRprzFyxO+Q5f79VqemLkSWTdjtpbT6y9MPBBTFw86iP7Fr1500N39HyZ5e2jUcIgzo1/6TTz7h8ssvZ/z48UyePJkXXniBiooK1q8Xqm+bzca///1vHnnkEU488USmT5/OkiVL+O6771i1SnyYli1bxvbt23nllVeYMmUKixYt4q677uKpp57C7Y8a/POf/2T48OE8/PDDjB07lhtvvJFzzz2XRx99dIjfvoCqqvh89kPy6I5Bq4qCp7ISpaMDSZbR5+aSmZdHeno66enpxMXFIUlS8O/U1FQeeeQRcvLziZ8+naPOPZePPxChYVmvZ/jw4QBMnToVSZI4/vjjAREZOfnkk0lOTiYuLo7jjjuODRsG5oba0dHBpZdeisViISMjg4cf7vohD08hqarKHXfcQU5ODgaDgczMTH79618Ht3355ZeZMWMGsbGxpKen87Of/Yz6xsZgS4CvPvkESZL46KOPmDR+PGarlWPPO4/te/YExbsvvPAC8fHxLF26lFGjRmE0GlmwYAGVfhF0AO+//z7Tpk3DaDSSn5/PnXfeGREJfOSRR5g4cSIxMTFkZ2dz/fXX0x4WBQoc54MPPmDcuHEYDAYqKir6dV0lrZ7nXnuPn/7iVsyWWEaNGsUHH3wQsc22bds4/fTTsVqtxMbGMm/ePErKykFnQgUWL36EcePGk5w0gRkzzuTFF5eFwvSerumjM844g1NPPZVRo0YxevRo7rnnHiwWS/C7eaARMLNzOCoASE7q542lU2qiWyg+2PmxeD3m9P6NG05gfmgeMOEYHukHA6LXUErKAgDKyhcDoronaJg4mAgMRGqWeoK/goecOX6LgN6h0RiJi5sC9KJ/CUBvhimXhP5OGC4E2oOAPv0oDBphQFl47FS80y8SEUE/ujSnDIc1CyQNCU12ZEmP07mPjo5dVFW9xYaNl/hTRuOYNXMpCfEzB3ReWq2F1NSFQFc/n6GCx2OjcNOVVFX9hwBNqKv7JPjdPVywX/asNn/DvET/zH/9+vV4PB7mz58f3GbMmDHk5OTw/fffM3v2bL7//nsmTpxIWlroC7JgwQKuu+46tm3bxtSpU/n+++8jxghsE66r6AyXy4UrrKlha2trj9t2hqI4WL5iYr+3H0ocf9wWNJrQTSZgVOdra/N7veT2KUp97LHHePjhh3nmmWeYNGoU/37qKc678UbWL13K2MxM1qxZw6xZs/j8888ZP348er8mpq2tjcsuu4wnnngCVVV5+OGHOfXUUykuLiY2NrZf53/rrbeyYsUK3n//fVJTU/njH//Ihg0bmDJlSrfbv/POOzz66KO88cYbjB8/npqaGjZtCtltezwe7rrrLgoKCqirq+Pmm2/m8ssv5/3nl6A4HChOEeL+3W9/y4O/+x1pycnc8dRTnPub37Br0aIgI7fb7dxzzz289NJL6PV6rr/+ei688EJWrlwJwDfffMOll17K448/LohBSUkwzfXXv/4VAFmWefzxxxk+fDh79uzh+uuv5/e//z1PPx0ykrLb7dx///0899xzJCUlkZqayp49e/p1Xe985Fke+NsfefDxf/LEE09w8cUXU15eTmJiIvv27ePYY4/l+OOP58svv8RqtbJy5Uq8Xi8+g5kX33uPv93zKA8+eBvTps1k+/YqfvnL67Bak7nsssvC0lPdf3Z8Ph9vvfUWHR0dzJkzp9tthhqBCEwAySnze9iyEzqlJrB2kyLau1ZUaBniejcsC0fgJq0z9y/tdKQi7xjRfLHMr4PxR+QyM86jtva/QZFtbMwYaBPRmEETmNy5sHpx72XvgfTRqFP6PWz2sMuw28tIz/hp3xvPvApWPSVeDyb64ockSYyb9DibN19Li20t6zdcyJTJz2MwpKGqPpqbBfHvIuAFobmxZqGxVZBgGkejvZCt234TrGhKTVnEuHEPRPz2DwQZ6WdTU/MedXUfMXrUX9BojH3v1E/Y7eVs2nw1dnsJGo2ZCWMeorLqVZqaV1JR+TwFo+8YsmPtLwZNYBRF4aabbuLoo49mwgQhqqqpqUGv1xPfSYeQlpZGjT/MX1NTE0FeAusD63rbprW1FYfDgambBn333nsvd95552DfzmEDb3V10KhOn5ODxtK3PuKhhx7iD3/4AxdeeCGqqnLPH/7AirVrefLll3n6+ONJSRFh+qSkJNLTQ+K8E0+M/HI/++yzxMfHs2LFCk4/ve9ZbHt7O//+97955ZVXOOkkIcZ88cUXGdbZbTYMFRUVpKenM3/+fHQ6HTk5OcyaNSu4/sorrwy+zs/P5/HHH2fmzJnYUdEDqkNEFv54zTWcNHcu2uQUXjruOLKzs3nvvfc4//zzAUGEnnzySY466qjgeY0dOzZI5u68807+7//+T9zs/ce66667+P3vfx8kMJ2FyHfffTfXXnttBIHxeDw8/fTTTJ48ecDX9fLzz+CiCy8CSwp///vfefzxx1mzZg0LFy7kqaeeIi4ujjfeeAOd39Nm9OjR+Hx2Ojr2cPd9i7nnnls4//zL0eniGTdOYufOXTzzzDNcdunPQw0gO6WntmzZwpw5c3A6nVgsFt577z3GjeumLPUAIODGC8LYzhLTt4BTbGyFtAnCPKziO5hwTtdtdvxXPI9eIByM+4PATTpxxCH3yTmgGDZL+Ny014rqnxSh7UhImIPRkBnsT2Ux5IW0U4OOwPjJcN02UQnUuUTa3REy1hsAgUlNXRiMOvSJpBEiCrfzQxh/Vr+P0R0SE+YwfdprFG66kvb2naxbfx5Tp7yI19uG12tDo7EQG9vD5Dc+B2wVJMt5NFIYJC/Dh/+G4Xk3dvWyGQASEmYH/3f1DZ+RnnbGoMcKorGEltV3slm/Go/sxeCRmLzFRuxX5yMnmmiaEENV1dvkD/8NOt2h6WzfGYMmMDfccANbt27l22+/7Xvjg4DbbruNm2++Ofh3a2sr2f4a/b4gyyaOP27LgTq1Po8dgGK34/W76eqHDUPTjyhIa2srVVVVHH20CLNLkoQmIZE5U6awZdeuXj1gamtr+fOf/8zy5cupq6vD5/Nht9upqOhfmLCkpAS32x0kCSCicQUFPd+YzjvvPP7xj3+Qn5/PwoULOfXUUznjjDPQ+ns1rV+/njvuuINNmzbR3Nwc1JTsbWggX5JQfaKy6qipUwXBs1pJAgoKCtixY0fwOFqtlpkzQ6HZMWPGEB8fz44dO5g1axabNm1i5cqV3HPPPcFtfD4fTqcTu92O2Wzm888/595772Xnzp20trbi9Xoj1gPo9XomTRJlnqqq4vXaqKws4u67n+Cbb1Z3f139acNJY0cFK5BiYmKwWq3U1YkWAIWFhcybNy9IXgJwu5vp6OigtLSSG2+4g1//+q7gOq/XS1xcnL8fjCqM4TSRN/OCggIKCwux2Wy8/fbbXHbZZaxYseKgkJiAGy9AcvJJA6sWyz1aEJjybgiMqoqbFcDYfqaPIHgjJ3Ny79sd6dAZRRSr7Bvx8L9vSZLJyDiX0rLHAbDgvykZrP2vjOsMS4poDdCwCypXQ8GiyPV7VgjzxvicflUgDRpn/wtslUNyjNjY8cyY/hYbCy/H4Shn3frzSUw8BoCEhKN6rBgiPgfKIdlpZZekR5I0jBv3IGmpi7rffgCQJJn0jLMpK3uS6up3hoTANH7xSzYl7kGVRXf4ydtaMbjFb1VCkwOLmk270sTefa8xPO+G/T7eUGBQBObGG2/kww8/5Ouvv46Ybaenp+N2u2lpaYmIwtTW1gZn/unp6axZsyZivECVUvg2nSuXamtrsVqt3UZfAAwGA4ZBClYlSRp0KG+ooKpq0GVXEx+/X40ItQnxosmjLIOmZ3Ouyy67jMbGRh577DFyc3MxGAzMmTMnqEU6EMjOzqaoqIjPP/+czz77jOuvv54HH3yQFStW4Ha7WbBgAQsWLODVV18lJSWFiooKFixYgMfrDbZMANDn5qKxDr7Dcnt7O3feeWeEwDwAo9FIWVkZp59+Otdddx333HMPiYmJfPvtt1x11VW43e4ggQl8Hj2eVlzuWhSfk2uuuZmmJhv/+Mcj5OXld72uPiHe1el0oA19niVJChK2nj7nPl87HR1ilvyvB//MUcfOD/nDABqNJtJfphNJ0Ov1jBwpKm6mT5/O2rVreeyxx3jmmWcGdP0GA2NYBCY5eYCh/dw5PacmarcJp1mtEUb2My0FYpb+86WQOXVg53IkIm9eiMDMvCq4OCPjXCoqn8NgSMfg9Ou/Bht9CSBnjiAw5Su7EpjiZeJ51IIDG/XSm4eUIJlMOcyY/iaFm66irW0rtbVCr5aQ0Ev61S/kNdqamHXCUjQaCyZT1pCdU0a6IDBNTStxumowGgZR+u6H2ribXeZiVFlLimYk40dejWZKpjAa3PEB0pd3kduayLa4JvbufYmc7F8I/6ZDjAHFsFRV5cYbb+S9997jyy+/DIpDA5g+fTo6nY4vvvgiuKyoqIiKiopgnn3OnDls2bIlONME+Oyzz7BarcFZ4Jw5cyLGCGxzsHL1hwJKe7swapMktKn9N5GyWq1kZmYGtR0Akk7H6u3bGT9tGpIkBTUvPl+k+dnKlSv59a9/zamnnsr48eMxGAw0NDT0+9gjRoxAp9OxevXq4LLm5mZ27drVy17i5nzGGWfw+OOPs3z5cr7//nu2bNnCzp07aWxs5L777mPevHmMGTMm4nOiy8xE60+HrQkTxQaOOXZsqATR6/Wybl2ou3FRUREtLS3BbaZNm0ZRUREjR47s8pBlmfXr16MoCg8//DCzZ89m9OjRVFVF9jQJCLDt9hIcjnIUfxnz6tWFXHvtz1iw4Pjur2uAYMi6YDfxzpg0aRLffPMNnrCqK0XxoChuUlOTyMxIZ0/5PkYOS4k49+HDh4f5y/Rd3aQoSoR27EBCq40lLfV0EuJnk5BwVN87hCMgDq3bLlIT4Qikj0acOLCu27JGtCrorUfODwXhfZHCCgdMpiyOmvUx06a9jtTh/67tL4EJiq47kU1VDRGY0Qv27xiHAHp9MtOmvkZiYkhj1a3+JYCAe3ZLBRZLwZCSF/B7wsTNBBRqqpfu11gNG/6O3axFq8iMO/odNOPPFXqmlNGijxSQWlmHwZCB293gL/8+9BhQBOaGG27gtdde4/333yc2NjaoWYmLi8NkMhEXF8dVV13FzTffTGJiIlarlV/96lfMmTOH2bNFLf8pp5zCuHHj+PnPf84DDzxATU0Nf/7zn7nhhhuCEZRrr72WJ598kt///vdceeWVfPnll7z55pt89NFHQ/z2Dw+oqorXfy21SUnIAzSeu/XWW/nrX//KiBEjmDJlCkuWLKFw0yZefe01AFJTUzGZTHzyyScMGzYMo9FIXFwco0aNClb9tLa2cuutt/Y48+8OFouFq666iltvvTUoYP3Tn/6E3Esp8wsvvIDP5+Ooo47CbDbzyiuvYDKZyM3NRVEU9Ho9TzzxBNdeey1bt27lrrtCKRJZr0djEQZlf/vb30hKSiItLY0//elPJCcnc9ZZZwW31el0/OpXv+Lxxx9Hq9Vy4403Mnv27KDe5vbbb+f0008nJyeHc889F1mW2bRpE1u3buXuu+9m5MiReDwennjiCc444wxWrlzJP//5z+D4qqr6S5cVfD4HkiSj0yWh1yczYkQ+b7zxIbNmHYPLZeh6XYMRkp5nMDfeeCNPPPEEF154IbfddhtxcXF8++0XTJqUyZgx47jz9j/z69/+jjhrLAvPvxKX2826detobm7m5p+f5h8/Mqp42223sWjRInJycmhra+O1115j+fLlB9WeYMKExwa3oyUFkkaJ8tvOqYlA+qi/1Uc/RgQMATvqhRg6MWTcZzL5U+2BCqTBlFCHI9c/0awuFJqXAKms3Sa8VLQmISw+AqHVxjB50rOU7HkECSnoFdMtwgjMgNBSAd89CSNP6pPoZWScTYttLdU175Cb+8vBmXh6XZTbV0AsZMWe0MX9lxQx6ZOby8jJuo/iPQ9QUfEcmRnn7ZeOZ0igDgAIK8QujyVLlgS3cTgc6vXXX68mJCSoZrNZ/elPf6pWV1dHjFNWVqYuWrRINZlManJysnrLLbeoHo8nYpuvvvpKnTJliqrX69X8/PyIY/QHNptNBVSbzdZlncPhULdv3646HI4BjXmg4GlqUu1btqiO7dtVpdN16A5LlixR4+Lign/7fD71jjvuULOyslSdTqdOnjxZ/d///hexz7/+9S81OztblWVZPe6441RVVdUNGzaoM2bMUI1Gozpq1Cj1rbfeUnNzc9VHH300uB+gvvfeez2eS1tbm3rJJZeoZrNZTUtLUx944AH1uOOOU3/zm98Etwkf87333lOPOuoo1Wq1qjExMers2bPVzz//PLjta6+9publ5akGg0GdM2eO+sEHH6iAunHjRlVVxecCUP/73/+q48ePV/V6vTpr1ix106ZNXa7PO++8o+bn56sGg0GdP3++Wl5eHnHun3zyiTp37lzVZDKpVqtVnTVrlvrss88G1z/yyCNqRkaGajKZ1AULFqgvvfSSCqjNzc2q12tXn376LjUuLlZ1OKpUn88d3G/16uXq1KnjVaPR0P11rS8W1/X1FyPOJy4uLuJzvmnTJvWUU05RzWazGhsbqx599Cy1sPBj1eGoUlVFUV998u/qlPEFql6vVxMSEtRjjz1Wffedt1V130ZV3bdBVT3OiPGvvPJKNTc3V9Xr9WpKSop60kknqcuWLevxf6uqh9l35f0bVfWvVlX99M+hZY17xLI7ElS1o/HQnduRgGeOF9dq81vdr1/2F7H+f/+3/8d6ZLwYq+Sr0LKvHxLLXjlv/8c/EtBUJt7v31JU1efre3ufT1XX/EtV78kU+92VJj7fvcDjaVW//Gqc+vkX+WpLy8ZBnWbz+gfVz7/IV7/4LF91duztuoGiqOp9uar6V6vqqVylLl8xWf38i3y1rv7zrtsOEXq7f4dDUtUDbOV3iNDa2kpcXBw2mw1rJ62E0+mktLSU4cOHYzQOXfnZYKAqCq5dxaheD7r0dLTJyX3v9CPG8uXLOeGEE2hubu5S7RbACy+8wE033URLS8sBOw+PpwWHoxKNxkxMTKSHiGhWKByqY2PHIUlhOiRVhZotoPoguWBAYsn29iIUxY3JnIdOGyucZ12tonFhIOzvtkNDkRDwpk/cb53BUH9XtrbZ6fApHBUfOcvbWNGMLElMzo7veedNb8B7v4SsGXC1P8X83ROw7M9C43H5h/t9fj9ofHSLsMSfc6NomNkZ7/4SNr8B8++EY27av2O9czVseROO+wOc8Eex7N8LhLncaQ+H+hb9kOHzwt2p4rt+S1HvDTKb9sAHvw6ZDWpNoiJsxIlwybu9fo+3bbuFmtqlxMSMZtTI20hMnDegSMzmDydTb24ng9GMO7EHZ+7nF4kKwLP/xW5zGeUVzxAfN5Pp09/o93EGgt7u3+GI9kI6xPA2NqJ6PUg6XUSH6SgObyiKv9N3N+6ZsqxHlkUa0Ovt1L/J5xI/aEigM6IoHjo6SnC7G/s4nhtFESJgbUBwbvBXqbnaQhuGN3A8zEqDfarKuYUlnLlxNx/WtQSXtzk9XPSvVZy9+Du+Le5Fg5XTKTUBsCNQfTQEZaQ/dAQaGu7rwaxyf03swhHoIh4wtLM3wV5/8caoI0//Mij4vWCAntNIig9WLYbFRwvyojPDwvvh2m9E6XvJl7Dl7V4Pk5N7NRqNhY6OXRRuuoING39GS8u6XvcJoGPvF9SbxO9H7rg/97xh6hjxXLeD7OzLkCQdLba12GyF/TrOgUKUwBxCqF4vvvp6ALRpaYfUBj+KgSFAJgJEpTM0GpH3D5iEBRE0mDOBJOP2NOLz2XG5alHVntsQeL3t/nHNoYhOkMB0QGDfgXS4PsjY63TT4hVC8l/tKKewVZzrnvoOnB4Fn6Jy3avr2V3Xfc8r4nPEDUHxwt510F4n9DAAY047GG/hyEagp1T1pm67mQ+ZBgZCBGbvWvC6xY1YVSB1HMT3z97iB4HedDBuO7z4E/jk/8T3Nm8eXLcSZl8runQfd6vY7pP/6ypcD0OsZQxz53xBdvaVyLKelpY1rN9wAYWbrqS1bWuvp1ex4z6QJJJd8cSk99KCI8VPYOp3YjCkkZ72E7F/xXO9jn+gEb1jHkJ46+tRFQXZaNyvsukfE44//nhUVe0xfQRw+eWXH9D0EfQegQHQaHsgMGEdqFVVxesRbtaq6sPrbaMnBMYJECNAlA3LWkAJRSTCeyAdZiixh6qdHIrKZVv2UOV0U9oQukZtTi9XvbiW5o5uSvklKXJmv/MjQBVl0HE9mydG4UfyKNGF29MB9UVd1wcjMIMvxw0da7Qo7/c6RcQs6L578v6PfSQhSGDKu65b9zyUfyv+J6c9Apd+ECGuZu5vhIDW3gCf/aXXw+j1yYwe9SfmzP6SzMwLkSQNjY0rWLv2TIqL/97t5MjVUUmNJBqg5mb3kdJLCUVgAHJyRCl+Xf2n2O3dvLeDhCiBOURQ3O6gaZ02PX1w6vEoDglUVe2TwGg1QuPh8zlQ1bDZbliERFGcwUgOCF1NT8fz+glMRIWAJIE+EIVpF/oaT8CB9/CLwOxxiGt2bIKFMTFGat1eLttSSlGDiLjMH5tGdqKJ8kY7v3xlPW5vNxGpYGPH76LVRwOFrIGMKeL1vvWR67xusPvTmEORQpKk0P+q9OtQZ+gfS/oogJ4iMB4HrPRX5C28V3jzdI7Aa/Vwhn+bja9E9LLqCUZjBmPH3MPso5aRniYaaFZU/pstW2/E53NEbLt3019RZAlrh0zc2Kt7HzjVb1HRXAYeBxZLAUlJxwEKdXWHrjo4SmAOEby1taCqyBZLsDQ4iiMDquoNzmh6SiHJsq6rDkZVI5osBgiL7O9j4vW2oSjezkOhKG5UxQN0Y7ho8H92XG3+9gGK6BukPfQmU50RiMBMijXz0sThJOm0bGl38KbXjgrMyEvg35fNJNagZU1pE396b0vXZqeBCEzlWuHqClH9y0CQ5Tftq+qkg+kQqWxkHZiGyCY+4Aez9jlwNInGjdkD9P850tETgdnwkujdFZcDky7sef+co2CGv73KhzeFJih9wGzOY/z4Rxg/7lEkSU99/ads2HgxLn/Xeq+3g70dghDlWk9G0vThqBKTAqZEQBUmhUB+/s1MnfIyubnX9eucDgSiBOYQQPV68fmbTerShmC2E8VBRUj/ouvVB6GLDsbrFDoASUbVGvF6RfrIoE/1kxg1uCwcofSRqevxAjoYT4eIwkC3DryHA/b4CUy+2UCOycALE4djkCX2GSW8o6wMT45hdFosT148DVmCt9bv5Zmv90QOklwgfki9DlA8whvmQFrS/9DQk5C3XfhQYUnt0VxxwAj4wbRVi+cRJwlh648J3REYjxO+fVS8Puamvnt3nfRXERVr3A3fPDygw6en/4SpU19Cq42ntXUT69adS0dHCVVFj+HVKJgcPlKm9iLeDUCSQlGYup0AWGMnkJg495BmD6IE5hDA12IT0RejCXkAxnFRHB7oK30UQCDd4/X5iUVY+sjnc6AoHiRJRquNRaeNF5t0k0by+ffvYjAFItIS6HcUmEUfhvoXgBKHmD2OMInrNjMuhkcKhKDTlx/LDq1ItR03OoW/njEegPs/2cknW2tCg8hyKDUBA+t9FAVk+QlM7Tbwhjkwtw1hBVIAaRNDKU4YUPPGHwyCBKYS/G1CKHxFkLrYTJh6Sd9jmOJh0QPi9bePBglEf5EQP5OZM97GZMrB6axk3fpzKa95BYAc32gka2YfI/gRFPLu6H27g4gogTkE8LU0A6BJiD+0JxLFoNBfAhOIwCg+p0gNuUMEJhBp0Wpj/S6+8QD4fHZ8vtCNRVXVYAoqQsAbjkAUJrDfYah/cfoU9jlFW4R8c+i6zYsxo9ktopFP1Dbi9v/IXzY3j0vn5KKq8Jf3t0amkgJpJIAx0fTRgBCfKyJYigdqwipUhrKEOgCNVjSRBED68Ql4QVTNSRrx3eyoE1qjb/8h1h1zU/9TvePOhNELxf9tWT8iJp1gNg9nxvS3ibNOxettxS250LkVMsbd2v9BOkVgDgdECcxBhuJ0ojidIEnRyqMjFH2VUAcgdDDiB8rn6whGYFSdCU+QwMQHtw1GbLwtYcdyoape8XnpqeGoITby78OQwJQ5XaiAVSuTrAulEcoa7GhL2pB8Kh1hJAfg/xaJGV99mwubI7ScEScAEiTk/TgaMQ4lJCkUhQnXwQxlCXU4AmQzazrE/AhNOjt7wWx6XXTJtqTBtEv7P44kwYK/i9e7PxPGdwOEXp/E1KmvkKoVRGR4YwyaEQNofhpI1UYjMD9e+JpbANDExiJpD4988PLly5EkKVh6/MILL/Rapnwk4/jjj+emm27arzH6G4GBsHJqb0dQwOvTyKjB9FEoLaT1R2E8npZgxCGkfzH3rLfRh1cmHZ4C3qD+xWSMyJmXNrQjAWafeL+VzlBVllmvJdkiSOLe5rAKirTxcMXHopN01Dtp4Aj4wezrhsAMZQQGRHXN1EtEpc2PFYE0UmNJSMNy9G8GnupNGiF0RADrlgzqVDSSnombajj2u0ay868bmFYuJVCJVB6KJh9iRL/9BxGqquKztQCgGQRBuPzyy5Ekqctj4cKFQ3qeF1xwQZ8dpQ80DlcSJUqoAxGYvomC1p/28XpbARUkDV5VfPm1WmsEKdH5/1YUNz6f3b+fv3xa00ulmkYnrMdBPO+HqE5V1S5dy4cCgQqkEebIa7bH7wGT5DfnCycwAMMSRDRpb3OnH8zcuZA4fMjP80eBzG4iMAdCAwOiounMp8JSST9CBAjMyseEH4w5GaZfMbixZgr/FTa+0u+KpAjs+h80FqPTWAcWAQLRUNWcRHgl0qFGlMAcRCjt7aheL5JGgzzI0umFCxdSXV0d8Xj99deH9DxNJhOpqalDOuYPBarqRhARCUnS9bl9UAejelAA1ZyAxxNIH0WmECVJg1Yr+n54vC1+MtEeMU6PMPr7hRj62K4P2Gw2amtrcblcfW88AAQ8YPJNkQSmtF4QmEyDuJYVXQiMIGaVTZEeFlHsBwIppPqiUBuKYAppCEzsoohEgMAEUi9zfzWgHmgRGLVApKQcTbD9/YHvH/CemXll19RzfxCIwtQfHjqYKIFBzDo7fL4D/mhrbMauqriscdj9xxxoL02DwUB6enrEIyEh5NsgSRLPPfccP/3pTzGbzYwaNYoPPvggYoyPP/6Y0aNHYzKZOOGEEygrK4tY3zn6cccddzBlyhRefvll8vLyiIuL48ILL6StLeQc29bWxsUXX0xMTAwZGRk8+uijfaZrNm3axAknnEBsbCxWq5Xp06ezbt06li9fzhVXXIHNZgtGme644w4AmpubufTSS0lISMBsNrNo0SKKi4sjxl25ciXHH388ZrOZhIQEFixYQHNzc7fn8NFHHxEXF8err74KiHTarFmziImJIT4+nqOPPpry8pDTZDD6Iun7VT4ou+3Iij8dZLbgM8ejql4/WelKYnU68b/0emwoit8ET5LRaPoIN1vSxQ/lfrioqqqKwyGIgtM5iNldL9jTQwQm4MI7wiK8cPodgYli8LCkgnUYoEJVoVh2oFJIUYQIDIiIVCCKMhhotDD9cvF63fMD27dilWi9odHDUdcO7vipkY68hxqHhwjjEMOuKIz4esvBO2BDHeyqA6Dk2InEaDR97DAw3HnnnTzwwAM8+OCDPPHEE1x88cWUl5eTmJhIZWUlZ599NjfccAPXXHMN69at45ZbbulzzJKSEpYuXcqHH35Ic3Mz559/Pvfddx/33CO62t58882sXLmSDz74gLS0NG6//XY2bNjAlClTehzz4osvZurUqSxevBiNRkNhYSE6nY65c+fyj3/8g9tvv52iImF5bvFHrC6//HKKi4v54IMPsFqt/OEPf+DUU09l+/bt6HQ6CgsLOemkk7jyyit57LHH0Gq1fPXVV92mRV577TWuvfZaXnvtNU4//XS8Xi9nnXUWV199Na+//jput5s1a9ZEEJWB6F9w2qCpFK1Bwi1L+AxmVK+ouOmcPgpAo4lBkrSoqhenU5QPa3vTvwQgy/7w7uDh8XiChNrpdBI3hCLzkjAPmAB8ikp5kyAmExNioLWVCkdk5Cc7URC3CA1MFPuPrKnQulekkfKOiRKYA4lwAjPnhsFFPsIx7VJYcb/o7F27TWjC+oOVj4vnSRcMPtIW1hPpcECUwBxh+PDDD4M38wD++Mc/8sc//jH49+WXX85FF10EwN///ncef/xx1qxZw8KFC1m8eDEjRozg4YeFmKygoIAtW7Zw//3393pcRVF44YUXiI0VX76f//znfPHFF9xzzz20tbXx4osv8tprr3HSSUJktmTJEjIze/cXqKio4NZbb2XMGPGlGDVqVHBdXFwckiSRnh76ogWIy8qVK5k7V1Q3vPrqq2RnZ7N06VLOO+88HnjgAWbMmMHTTz8d3G/8+K5f8Keeeoo//elP/Pe//+W4444DRAt3m83G6aefzogRIwAYO3Zsp+vQT/2Ln7yAikaOAZx4fR2iogiCZdOdIUkSOl08bndDmID34Dg1h0ddfD4fXm9XV+DBwObx0uARY4WnkKpaHLi9CnqNzKQkC5T3HIGpjEZghhaZ02DHf4WQ19EMPv91t0RTx0OOlDGis7Q+BmZds//jxaaL5qXb34e1/4bTH+l7n/pdUOS3/J/768EfO1hKHY3AHDYwyzIlx048oMdw7ylFcTrQpaWhSQrNlM0DrKI44YQTWLx4ccSyxMTEiL8nTZoUfB0TE4PVaqWuTkR8duzYwVFHRdp5z5kzh76Ql5cXJC8AGRkZwTH37NmDx+Nh1qyQUC8uLo6Cgt4dUm+++WZ+8Ytf8PLLLzN//nzOO++8IHHoDjt27ECr1Uacf1JSEgUFBezYIb5QhYWFnHfeeb0e9+2336auro6VK1cyc+bM4PLExEQuv/xyFixYwMknn8z8+fM5//zzycjICG4TisD0UkIdRl4wxqOJGwbtO4P7SpKmV02LTpeA22/5DaFKpgONzroXl8uFZgiig3sc4uaYptdi0YbGC6SPcpLM5PqJTa3bi9OnYNSI70V2QigCo6pqtGfYUCG8lDoQfTElHJYVbEc8LCnwi8/AYBXtFIYCM64UBGbzf+DkO/uO6nz/hHguOA1SRg/+uIEITEu5aCCrPzi/TT0hqoFBzHpjNJoD9jB5vRhdTsySTGxCQsS6gf4gx8TEMHLkyIhHZwKj00WKSyVJQgm4QA4SB2LMO+64g23btnHaaafx5ZdfMm7cON577739GtPUD2fjqVOnkpKSwvPPP99Fg7RkyRK+//575s6dy3/+8x9Gjx7NqlWrguv7TCF5XRHkhYRc4Qfj73cEoNXF9fp/12iMwe0lSUYjH3hnXUVR8HiE10rgGg6VkHePXUR28nvQvwxPjiFRpyHGT1r2ukJRmMx4cS52t4+m7jpURzE4BJo6tlSINARE00cHEhmTh7ZqbvhxkDQS3O2w+c3et22rgU1viNdH70f0BYSXj9nv59NdR/ODjCiBOQgIeb9YkHR9V64cSIwdO5Y1a9ZELAu/QQ8G+fn56HQ61q5dG1xms9n6VYo9evRofvvb37Js2TLOPvtsliwR/gZ6vb6LbmXs2LF4vV5Wr14dXNbY2EhRURHjxo0DRPTpiy++6PWYI0aM4KuvvuL999/nV7/6VZf1U6dO5bbbbuO7775jwoQJvPbaawCoqoKiiJt8jwSmox5QhTdLQq7wZSFUTg2g0/Y9CwuIeTUay0GJOgTIilarxWwWaRu32z1gkXl3KPHrWkaYjBHLAwQmPzkGSZLINoqoVqUjRFSMOg2pseJaR3UwQwhTvOgjBbDrU/EcJTBHDiQp1ORx3fOiUWxPWP1PkSLMPgpyZu//sQNppMNABxMlMAcY++v90hkul4uampqIR0NDQ987+nHttddSXFzMrbfeSlFREa+99hovvPDCfp1TbGwsl112GbfeeitfffUV27Zt46qrrkKW5R5vvg6HgxtvvJHly5dTXl7OypUrWbt2bVBzkpeXR3t7O1988QUNDQ3Y7XZGjRrFmWeeydVXX823337Lpk2buOSSS8jKyuLMM0Xr+Ntuu421a9dy/fXXs3nzZnbu3MnixYu7XKPRo0fz1Vdf8c477wQrpUpLS7ntttv4/vvvKS8vZ9myZRQXFwfPKaB/kSQNktRNakXxgb1JvLakBckLhHQskqTtuyQa0OuSMBqHYTT2s0/JfiJAYAwGA3q9PhhhC0Rl9gd7uhHwQmQEBiDHT2A6l1JnJwYqkaIEZkgRSCMVLxPPUQJzZGHyRaA1Qu1WqFzT/TauNljrr1Y6+jdDc9zDSMgbJTAHGEpHB6rHI7xfYvdTfQ588sknZGRkRDyOOeaYfu+fk5PDO++8w9KlS5k8eTL//Oc/+fvf/77f5/XII48wZ84cTj/9dObPn8/RRx/N2LFjMRqN3W6v0WhobGzk0ksvZfTo0Zx//vksWrSIO++8E4C5c+dy7bXXcsEFF5CSksIDD4hmZkuWLGH69OmcfvrpzJkzB1VV+fjjj4MprtGjR7Ns2TI2bdrErFmzmDNnDu+//z7ablyPCwoK+PLLL3n99de55ZZbMJvN7Ny5k3POOYfRo0dzzTXXcMMNN/DLX/4SiNS/dEvM7E2g+oRgr1NOWquNxWBIx2TK6VdERZIk9PoEZPnAR+xUVQ0KeA0Gg//YgkwMCYEJRGD6IDDBCExPXjBRIe/QImBo52wRz0PdRiCKAwtzIkw4R7zuqaR6/Yvgsolo2+hFQ3PcYCn1oScwkjoUMeLDEK2trcTFxWGz2bBarRHrnE4npaWlDB8+vMcb7FDBXVmJz2ZDk5iIvo+qnB8SOjo6yMrK4uGHH+aqq/bD9+AwgstVh8tVi04Xj8mUHblSVYUy3+eCuGEQk3JoTnIQ8Hg81NeLTtbp6enIskx7ezutra3BFhOD/a6oqsqob7bQ7lP4etYYRseIMVxeH2P/8gmKCmv+dBKpsUb+WVHHHSVVnJkazzPj84JjPPRpEU9+tZtLZudw91kHVmz/o0LlGvh3WIPFU+6BuTceuvOJYuDYux6eO1FMmm7ZKUhNAB4HPDEdWvfBGY/D9MuG5phlK+GFU0V5+E0Hxn6kt/t3OKJVSAcQituNr9Xv+3EY2uIPJTZu3MjOnTuZNWsWNpuNv/3tbwDB1M4PAb0KeF1tgrxIGtHt9whCIH2k1+uR/VVxBoN4j+HeMINBvdtLu09BBnJNocqtyiY7igoxeg0pFnGsHP/6Ckf3EZhoCmmIkT4RZC0o/nL5aArpyEPWNCEQrt4E/5wn0tbuNlEhFCyNTxPeL0OFgAampQJc7WA4ODYP3SGaQjqA8DU2gqoix8Qgmw+/DsFDjYceeojJkyczf/58Ojo6+Oabb0hO/uF0oO21C3WHKCnHnAjy0BoTHmgECEx4hEWr1QbJzP74wQQEvNlGPYYwy4A9/hYCw1Nigim1nlNIfi+YpmgKaUihM4VuRhBNIR2JkCSYfb143boXbBWRvj6yFk74I+iGMNNgTgxFmBsObSVSNAJzgKB6vXj99vXa5CMnnTBYTJ06lfXr1x/q0zgw8PeL6TEC43GGesocQakjEOXT4QLeACRJwmg04na794vA9CTgLWsM6F9Cs7cAgWnweOnw+YIO1eFuvFEvmCFG5jSo8acBohGYIxOTLoD4XBEB1scKbxaDRTzrLaLZ61AjZYyouKzbGepufggQJTAHCN7GRlAUZKMR2XJozX6i2A/4PNBYgoKKahFfly4RmA6hH8EQd8QZgbndgaiS3EXoHCA0+xWB6aMHUkDACxCv02LVyrR6FfY6PRTECAKTEWdCksDlVahvd5Eae2B1az8qZE2DDS+K11ECc2RCkiC3bzPSIUXqWCj7JtSg8hDhR51COlD6ZdXnw9ckymm1KSnRGeORDFcboKLI4n/YpYRa8YrOsCAcN48whEdfOn9O9Xq9vyO2j46OjkGNv8fhN7Hr1IU6kELKT44k992lkfRamQyrIC1RHcwQI2uGeNbFDJ1LbBQ/fARLqaMppIOOQMmt3W7vl3PrQOFrakb1+ZD0euReFNRRHAFwtwOg+NMZstcrcswmfwdwexOoivBj0B86Mdtg0V36KACNRoOiKPh8PiorK0lKGnizyFAEpnsTu+GdCEyO0cC2dicVDheKovD2229jtVoZlhBHlc3J3mYH03ISiGKIkDYeTviTqJyLTrSi6C+CPZEObSn1j5LAaDQa4uPjg718zGbzkEVJVEXBXV+Hqiho4+JgiOzYf0xwu1vweBoxGDLQag+h+FlVod0GiorLaMbj7kDrUXC2lYLVI7xeWmpB8bcNOML+1z6fL+j/Eu4FE/jbbrdjs9koLy/HbDb32lm82/FVlTJ/RVG4BqbD5aWuTVyrvF4iMLW1tWzfvh2AnOEnsIaokHfIIUlw3O8P9VlEcaQhEIGxHdpKpB8lgQGCXY4DJGaooHR04LPZQNag02rBn0qKov9wuWpRVS+SVI9enzp4cqkq4iEP8mPu80BbNSDhNttRFCdaVYfW5YR9DeJL62oTpYtWA0gtgzvOIYLb7cZut6PRaHpMEel0OoqLi7FYLAMW0O51uvGoKgZZIssQEhIGoi9JMXriTJECw3ACY/OGCGGCoxJIiKaQoojicIA5UWim2mtFGmnYoRHy/mgJjCRJZGRkkJqaOiRuoyC0LxW//CXsqyLpmquJnzMEfSd+ZOjoKGXzlr8E/87NvY7MjLMHNoiqiv4uK+4XZk5nPTU4pfyWt2HlfZA5nU0jwW7fw5jRfyNh3Wew65PQdlMvgynzBj7+Icann35KcXEx06dPj+hgHoBOp0NRFLRaLe3t7dTV1ZGW1n+hZyB9NNxkQA4jPj2ljyDMC8bpxuazBZf76svRYmVv1I03iigOD6QU+AnMjiiBOVTQaDRoNEPj29H68cco69aji48n9cwzkQ+wy+8PEVVVy1CUKrTaOLxeG5WVDzIsayF6fT/N4exN8NHNsC2sq/XHv4HrVg68QmjPp9BeiZp5KXb7iyiKk7j44RhPuw+c9bB9qTCum34hHGH/a0VRKCoqwm639+qyq9FoyM3NpaSkhD179gyIwPS3hUA4AhGYvU43LR0tofP1eRihaWRvc1RTFkUUhwVmXQMTzoW8/reyGWoMuArp66+/5owzziAzMxNJkli6dGnE+ssvvxxJkiIeCxcujNimqamJiy++GKvVSnx8PFdddRXt7e0R22zevJl58+ZhNBrJzs4O9sI51Gh8fgkVV15F/RNP0v7tSnxtwv9DVVUanv0XAAk/v+RHYVx3IFBXLyIbo0b+EYtlLF5vK3tK/9G/nYs/h6fnCPIiaWDe7yAmFRqLYeVjAzsRxSfKBAFXzkQUxYkkaTEah4FGC+c8B8f9AX7yBMRn9zHY4Yeamhrsdjt6vZ7s7N7Pf8SIEQCUlJQM6BiBCEznCqQggUnpmcA0eXzU+l2sExMFeS3Q1LGv2Y6i/CC7n0QRxZGFsWeI9gQJeYfsFAYcgeno6GDy5MlceeWVnH1296H9hQsXsmTJkuDfnSscLr74Yqqrq/nss8/weDxcccUVXHPNNbz22muA6INwyimnMH/+fP75z3+yZcsWrrzySuLj47nmmmsGespDio7vvgs+AJAkDKNGoc/NxbVzJ7LZTOLFFx/SczxSYbeX0tGxC0nSkpJyMiZTNhs2/ox9+14nK+tnxFrGdL+juwOW/QXW/Vv8nTwafvqM8LhIHQvvXAVfPwTjz4bkkT0ev67uE1zuerIyL0Su2SqqjQxW7FZxozWZcpADehqNTjhcHqHYvXs3AMOHD+8zApmfn09x6jA+NyRyisNJqikUrXl1dTlrS5u4+6cTsRgif05K++hC3bmEGiBWqyFBq6HZ66O8Q4iK582bx0cffUSi10G8p5W6NhfpcUdWxCuKKKIYegyYwCxatIhFi3rvamkwGIIi2c7YsWMHn3zyCWvXrmXGDOFB8MQTT3Dqqafy0EMPkZmZyauvvorb7eb5559Hr9czfvx4CgsLeeSRR3okMC6XK1gSCoIEHQik3nor9hNPwLGxEEdhIZ7KSly7duHatQuA+AsuQPMD6XvU6Pbyl937WJBs5czUUOlqbauTv324nbOnZnHS2KEzv6qrE9GXhIQ56HRxJCQcRWrKIurq/0fxrruYOvWV7kWk7/0SdvxXvD7qWph/h7BJB9GttfBVKPkSPvotXPpBt+WiHR0lbNl6I6BSW/M+ExyTMALkzcPurADAbM4fsvd6KOHz+di4cSMAo0aN6nP7tLQ01uaPp9Vg4qZ123ltnuhirKoq9/1vJ21OL0kWA385fVzEfoE2AiPCIjCqqrKnXkRbO1cgBZBt1NPc7mCf20u6//gTJkygsLCQAm09e5vtUQITRRRRHBgju+XLl5OamkpBQQHXXXcdjY2NwXXff/898fHxQfICMH/+fGRZZvXq1cFtjj32WPT6kOPpggULKCoqotlvz98Z9957L3FxccFHX2HxwcJYMJrEn/2MrAcfYORnyxj1zddkPfE4iVddSfx555J87S8PyHEPBe4s2ce7tc1ct62czxtDhPAvS7fy0eZqrntlA6v3NPYywsAQSB+lpiwILhs58v+QZT3NLauor1/Wdae2Wtj5kXh98duw6P4QeQFBVk57WPi0lH4Nm9/s9thl5YsBkZqwtW5kjfMVGhN0kH88dnspAGZz3n6/x8MBhYWFNDc3ExMT0614tzOavT5aDeKafumVeXubMK8qb7TT5hQuvS98V8b2qtBnxOlT2OsMlFCHyEaz3UOrf5+8pB4IjF/IW+83DIyPj2fmzJliH7mJsprufwOiiCKKHxeGnMAsXLiQl156iS+++IL777+fFStWsGjRInw+HyBy76mpqRH7aLVaEhMTqampCW7TWSwY+DuwTWfcdttt2Gy24KOysnKo31q30KakYD35ZNJuvZWMu+5CE/fDcLNca+vgTf+NQgGu2VbGljY7XxXVsWx7LQBun8I1L6+npL69l5H6B4djL21tWwGZlJSTg8tNpmHk5FwNQPHue/H5OnmtbHtPlEoPmwmjTqZbJOaHvC4+vU0IfSOOXUFt7QcATBj/GLExY/FofBROsFJirqDDLrQfZtPw/X6fhxper5evv/4agGOOOSZiktATtrRFli7/tayOvTU1bNkXViWkqPzl/a1BfUqZ04UKxGk1JOlCKarSBvFZyYo3YdR1n7oK6GDajGZ0Oh0mk4msrCx8xng0ksqeoq39f8NRRBHFDxZDTmAuvPBCfvKTnzBx4kTOOussPvzwQ9auXcvy5cuH+lARMBgMWK3WiEcUg4NXUblt114AzktP4NgEC3afwiWb9/Cn/wljsZ/PzmVqTjw2h4crlqyloX3/TNzq6z8FID5+Jnp9ZAfrvNxrMRjScTorqax8PnLHLW+J5wnn9n6AOb8S5kv2Rvjs9ohVZeX/RFV9JCUeS1ra6UxP+g1ZVQ6QJMrqX6epSYh5zeYjn8Bs2LABm81GbGxsRBS0N2xuE6XLx8bHEOPz0GiO5Xeff8uWUjGZmD82FbNew/ryZt5eLz43e8JKqMPTfsEu1D2kjyBEYFqNZuLj44P7xw4rAKB93y4URen3e44iiih+mDjgvZDy8/NJTk4OigbT09O7mMd5vV6ampqCupn09HRqa2sjtgn83ZO2Jop+wOOAF8/ocgPvjBerGtja7iBOq+GvI7J4bsJwCmKM1Lq9lObHkJJg5A+LxvCvS2eQk2imosnOL15ch9PjG/Sp1fkJTHj6KACNxsyIEbcCUFb+NHZ7uVjRVAr71gkjufE/7f0AWj2c/g/xeuPLUC5E2E5nFdXV7wKQl3eDOF7ZSsbs7mC8fSIaTaia7EjXwHg8Hr75RpCxefPmBVtq9IVNAQKTaOWvo4YB8G16HtXF36PFx8nj0vjt/NEA3Pu/HTR3uHts4hjqQt0zgckJRGAMZuLCIprDR43BrWqQ3R3s2bOnX+ceRRRR/HBxwAnM3r17aWxsJCMjA4A5c+bQ0tLC+vXrg9t8+eWXKIrCUUcdFdzm66+/jjCY++yzzygoKCAhIdoHZdCoXCN0IN8/Dd7uIyb1bg/3l1YD8H/5GSTrtVi1Gh7KyUBy+VBjdVjmpmPQaUi2GFhyxUziTDoKK1v47X8KB1Xi6nLVYbNtACAltSuBAUhP+wlx1qn4fHbWb7iAtrZtsPVtsXL4sRDbDzFx7hyYdpl4/d+bwOOkvOJZVNVDQvxs4uP9EYk9y8Uxsy9i5oylxMVNIzl5fpfI0JGGdevW0dbWhtVqZdq0af3eb7M/hTQ51swlOelMizHg1WhZn5vHsbo9jEuP5fKj8yhIi6XZ7uGBT3cGPWB6LKHuLQJjCqWQwglMbmocu31JwfcSRRRR/LgxYALT3t5OYWEhhYWFAJSWllJYWEhFRQXt7e3ceuutrFq1irKyMr744gvOPPNMRo4cyYIF4sY0duxYFi5cyNVXX82aNWtYuXIlN954IxdeeCGZmZkA/OxnP0Ov13PVVVexbds2/vOf//DYY49x8803D907/zGiyT9rVTxQ072O4J6Salq9ChMtJi7NDDXve3ZZMbr1jciKyg6vh9/vqkRVVUakWHj259PRa2T+t7WG+z7p2tzL57NTXbMUj6f7yjAhzlWxWqdiNHQfYZMkmYkTn8JiGYPbXc/6DRfRuEeU3TPxvP5fg/l3QEwKNBTh+uTXVFX9BwhFX7A3QVWheJ1/HDExI5gx/S0mT3rmiO4q7na7+fbbbwE47rjj0Gr7V4DY7PFS4RfjTow1IUsSD4/LQwb2pGRBip7d679mz+5ibj46iTjJwXtr9rCpSWhdOkdg+pVCMggC49bp0cXFB5cPSzBR5BP6uaKiImw2W3e7RxFFFD8SDJjArFu3jqlTpzJ16lQAbr75ZqZOncrtt9+ORqNh8+bN/OQnP2H06NFcddVVTJ8+nW+++SbCC+bVV19lzJgxnHTSSZx66qkcc8wxPPvss8H1cXFxLFu2jNLSUqZPn84tt9zC7bfffsg9YI54NIWF3fet77J6na2DN2qEwPXe0cPQ+G/YX+2s47Ptteg7vPw9NwMZeL26iWcq6wE4Kj+JB88T1SzPfr2Ht9ZFCqhL9jzK9u23sGHDhbjdDV2OG6w+6iH6EoDBkMb0aW+QED8bn6+DTcNsVKfHwJjT+/f+QfTwOPtZQKKi6b8oips461QSEuaI9WXfACqkjIXYH066cu3atXR0dBAfHz+gpowBAW+uUU+8TpCesRYTJxlFVdK3IyezfvMmXn/9dVZ8+h5jctswzraw3SVIT45eg83u4ZVV5Zz11Ep21gjjx94ITIxWQ4xPRF8dlpCWLc1qxC6bqVFiUVWVDRs29P8CRBFFFD84DNgH5vjjj0dVe04TfPrpp32OkZiYGDSt6wmTJk0K5uujGCKEE5iqyB9/nxoS7l6YnsiMOHGDcXp83PHfbQBcecxwLh+Zjteo4c/F+/hHeS2XZCZh0Wo4c0oWpQ0d/OPzYh75bBdnTslCr5Xx+VxUV78DQHtHERs2XsLUKS9jMKQA4HY30dKyBuhe/9IZWm0sU6Y8z/avTqVWLmP7aBOuuv+Qm3NN/yMkI07EfcLN7HW/AECedVFoX3/6iPzj+jfWEQCXyxURfRlI64yA/mVSbKSz9MgWH5/ho9UUw6Zpx+D2KWy3JOCRxdiSqjK8vool7zbwaakHt1eIbjWyxFlTsshN6t2p2upy0GHW0WoMER2NLJEZb2Jncwrp+jY2bNjAscceO2StQKKIIoojCwdcAxPFYYSm0tDrThGYl6oa2eIX7v5pREZw+bNf76G80U6a1cCvTxKmZ1dkJTPCZKDF6+PlqpAPzHXHjyA11kC1zcnSwn0A1Nd/gtdrw6BPw2BIp6OjmA0bf4bLJUTZDQ2fo6o+LJZxmEw5/XobsqRn/OZacirFzbWk5AF2Fd+Jqva/MqUyy4yikYht85D08aOh0uoggTm+32Md7li9ejUOh4OkpKR++b6EI6B/mRRrili+a18rup0tAKyNSWSTNRmPrGGU2cCpWiOnfPctp+xYy7aSfbi9CmPSY/nzaWNZddtJPHz+5F7JpqIomDtEpKZFG1nmPSzBRIWSgEZvpK2tLVgcEEUU/YHi9OKpjzYE/aEgSmB+LFCUiAiM2lBMRUsD79Y2c9uuvfy9pAqA3+el02pz8ea6Sv7w9mae+krcIP502rigVbxGkrghR2gRnqmsx+UvaTVoNfxinig1/ueKEhRFZV/VGwBkZl3EtKmvYTRkYrfvYf2Gi3A6q7o1r+sTlWuQbJWM2iczKv8PgMTevS9TuffFfu3u8dio3PcyAMObYpFaKuHda6C5TFwjSQO5R/f/fA5jOBwOvvO3vRho9AVCJdSTwyIwqqqydV8rcq2TE2NjSNRpuDQziY+njeLrWWP419EFJPtTTMfmmvjwV8fwv9/M4xfz8kmJ7buhZltbG7EOoZWpI5LoZCeYUZDRJucBBB2Ff8hw7mqmdXklqi9aOr6/aHxlB7WPrMdZHDVD/CHgR9+N+keD9hrwOngj/VQ+SzuBtaYR1G3cG7FJvEvhiSUbucvuiVh+fEEKZ0zKiFh2TnoCD5TWUOP28E5NMz/zC34vmpXDk1/uZk99B8s2r0HXsgaQycw4F6Mxg2nTXmfDxotxOMpZv+FnuFzCSyQ1NbLhZ68IeL+MOZ2cvGuQtEZ27bqTkpKHSE46EbM5t9fdK/e+hM/XTkzMaJIX3AvPL4Ddn8F/fi42GDYDjD8MH6E1a9bgdDpJSUlhwoQJA9q3xeOlPEzAG0BlkwObw4NBI/PClBHotZHzII0EZx1VwBdf7GVSipYJWQMzd7TZbMQ6BXGqdEZ+FocliPNoMmURw06KiooE4YmN7XVMb5MTTawOqQfzvMMVPpuLxpe3o3oUvA0OEs4ZdUSLyQ8lvM1OXLtbALB9XIrhV/FIcvRaHsmIRmB+LGjaww7zcG4q+AMfxc+izpCEDoWpsWbOiLWgK2zE8XUNLXYPBq3MrLxErjt+BO+e4uJf5w7v8qNpkGWuzRY6lqcq6vD5dVGxRh2XzskDYHOxiHIkJx2P0SgIkMk0jOnTXsdkysHprERVPZjNI4mJ6bnJYgR8XuG+CzBRmNcNy7qEhPjZKIqTHTtv6zWV1Na2jYoKIRjPy7seKXNKyB+mZrN4/oGkjxRFCdoVzJs3D1ke2Nc9IODNMepJ0IXmOgEH3oL02C7kJYBAB+mmpqZu1/eGlpaWMALjjlg3LEFEgiodWoYNG4aqqmzatKnX8dyVbdQ8uJamd4r7dXxVVamtrT0szPJsn5ahesR52NfV0vbVwXEY/yHCsSVUQOCp7sBeWNfL1lEcCYgSmB8LmvawOVaYjY2V2nl/443sqnuc/80YzVEOGU2tk+nD4njv+rlsuWMBb147hz8ML2Xa11ege+UsQRw64ZLMJOK1GkocLj6uD5W0Xn50HjE6H6NjhWV9ZtaFEfsZjZlMm/Z60Nk2Le20/r+P0uVgbwBzUpBoSJLM2LH3IssmWlpWs29f9wJxp7OaTZuuxuezk5hwDGmpp4oVUy6CGVeGNvyBEJiSkhJaW1sxmUyMHTt2wPuHBLyR+pcAgektshLwaxoMgQmPwFQ43RFFA9mJ4lwqmxxBL5uNGzf2Wljg3NUMqriBKa6un+Nw+Hw+3n33XRYvXsyHH3444HMfSrgr27BvEDfZmNliAtC6rBz7xuiNdzBwbBUERpsmSHDrsvIgOYziyESUwPxY0LSHnTGCMMyN1XNU6xZM+9YCUOQvbZ03KoWpOQmhWfX298Vz7VZYv6TLkBathiuyhLnbExW1wZtIssXAtbOriNV30OFNICmxa0WP0ZDO9GlvMm7sg+T6ex31C1tERRPjzgJNyEnWZMphpN+td3fJ/Tgckekxr7eDTZuvweWuJSZmFBMnPokkhaUTFt4HBadB/gmir9IPAIEy40mTJvXbdTccm9tDBnbh2OonMBN7ITCBCIzdbsfpdA7ouDabjViXIDAdPoVmb8jhORCBqWl1UjBmLDqdjsbGRioqKnocz1Pl79XlU3EVt/S4naIoLF26lC1btgDi+lVVVQ3o3IcKqqrS8qHQrJmnpZJw1kgsx2YB0PT2LpwlLT3u621y4m3Zv9YePzR4W1y4K9pAguRLx6GJ0+NrcdH+/aH5/0YxNIgSmB8LGkvYGSOs8MemZAn7/da90FZDUa0gMGPSw3QEig+Kw7o/f3VPsFLH53NRVfU2DsdefjEsBZMssbnNwTfNoaaOM1JE9OXL8llsq+ro9pT0+kQyMs5Go4mc4aOq4lidQ/geB+z4r3jdjXndsGE/Jz5uJj6fnZ07/xgkVKrqY9u2m2hv345Ol8TkSc+h1XbSTGgNcNFrcOnSCGJ0pKK9vZ2iItE1eiCuu+HY3E0JtaqqwQhMbwTGaDRiNov9euog3xNaWlrQKgoJkvj/haeRUiwGUZ6vqDQ51aCupzdPGHd16PPn2N599/Rw8iLLctBU89NPP+01unOg4Nhcj7u8FUknE7cgD4C4hcMxTUwGn0rjyzvw1IWqaVRFxbG9kfp/b6HmgbXUProeny1KYgIIpI/0uVa0SSasJ+cB0PplJUonzV8URw6iBOZAw9EMtdsO9VlAUyk7AgQmLh6SRWM839717PITmIJwArN3rWh8aIiD1HHifSy/F0Vxs2Xr9ezY+QfWrvspBk8pF/sFvE9UiNJou70ce/tqVFXim32z+eeKkv6do9cNm96Afx4DDwyHv2fC4mPgzcvgy7vFw90G1mGQfVSX3UOpJANNzSuDLru7iu+hofFLZNnA5EnPYjING8wVPKJQWFiIoihkZWV16ezeH9g8XsocXQW8e5uFgFenkRidbul1jMHqYAIOu5l+wW2FI0RgZFliWLw/jdRsD5Kzbdu2dRvpUZxefE2h5c6iJtRO7S4C5GXz5s1IksS5557LBRdcgFarpby8nB07dgzo/PcXituH7eMyAGKPz0YTJyq3JFki8fwC9LlWVKeXhue34qnpoHV5JTUPrKXxpe3BCJPq8tEa1csE4dgiTDfNE0XE2DwtFV26GdXppXV59DodqYgSmAONd6+BxXOh6JNDdw6qSnNrHTV+87iCGCNkTQegrWQNTo+CQSuTmxTmjrrLf76j5ov0CqCu/TfbNlxNY+NyADyeJjZuvITLkhxoJfimuZ2NrXaqqt8EwGiZQ6MziY+3VrOnPhSd6QKnDVY+Bo9Nhvd+KVJWAF4H1G6B7Uvh6wfh+yfF8onnQA+CVLN5OCPybwGgePe9lJQ8zF5/efW4cQ8TFzdlABfuyES4S+306dMHNcYWf/oo26gnsQcBr0Hbe0XPYHQwqqrS0tICQI6/j1JnIW+WvxJpb7ODYcOGkZKSgtfrZevWru0xAukjjVWPZNSidHhxV4RaWiiKwvvvvx8kL+eddx7jxo0jLi6OuXPnAqIPm9fbu3ZmIOjYWEfdM5txbG3oNrrT/s0+fDYXmngDsf60UQCSTibp0nFok4z4WlzU/mMDrZ+U4WtxIZu1WI4bRuIFYnLSsbYGb9PA0ndHIvqKkIWnj0x+AiPJEtaFIqXe/l0V3uYf/nX6ISJKYA4kvG7Ys0K8/vyvIi1zKNBex069mIVnG3RYtBrIEq0gvJWiKd6oNAua8JLCAOEavQjyj0Mdezo7R5qoa/0WSdIxftyjWCxjcbsbqNl+KWcmibTLE+XVVFeLJoujh1/C/LGpqKowxOsCZyt8+id4ZLzokN1WBZY0OOmvcGsJ/GoDXPQfOOVumH455B4jIi+zem8pkZ19ub/xYztl5U8DMCL/VtJSFw32Ch5RKC8vp6mpCb1ez/jx4wc1Rk8Gdv1JHwUQiMAMJIXkcDiCTVzzYwWhruhEYLITRWpqb5MdSZKCbU26SyO5/elL3bBYjGMEoXLsEIRKURQ++OADNm3aFIy8jBs3Lrjv0UcfjcViobm5mTVr1vR4zs7iZprf342vte+UjeLy0fL+btylNhpf2UHji9sjSIbP5qLNHxGIWzS827JvTYyO5CsmIMdo/e/NQsJ5o8m4bRbxi4ZjnpqKYVQ8+FRav+hZG/RDwHfffcfdd9/dqwYqIN7V51rRWEM+RMaCBAz5ceBVaV1WfsDPNYqhR5TAHEjUbAaf/0etfqdIjxwKNO0JpY8s/huSPwJjadwMqBSkhfmeNJdB/Q5h6DbyJFRVZfe4YVRlGEFVmRB3CenpP2HqlJewxBTgdtdxTMtfAPhfQytlbgN6fTLJySdy3fEjAHhnw15qbJ1mOf/9tYiquNtE76Ezn4abtsC8myEmGZJGQMFCmPsrOOMxuOIjuGoZxPWeApIkDWPH3o8sCxfXzIzzyc395f5dwyMIgRv5hAkTInqQDQTdGdhBSMDbH2+XwaSQAumjmJgY8mKMAFQ6OpdShyIwAJMnT0aWZaqqqqipqYnYNhCB0WfGYBorUp1Ovw5m8+bNFBYWIkkS55xzTheyZzAYOOmkkwBYsWIFHR2RWi7F6aXp7V00/HsrHd9X0/JB36lS+/paVKcP2awFjYRzZxO1j66n9asKVK+C7RNRNq3PtWKa1HP3c22yibSbppN20zTSbpxKzPS0CLJjPVl4Idk31B5RzrOK3UPbN3v7rUvZtm0bPp+vVw1UQP9imhB5PSVJIu5UEYWxF9bhruolShzFYYkogTmQqPTP2jT+m8hXfwfPIQhVNoUEvGP8NwVSx4NGj9HbSq5US0G4nmGXv59VzmwwJ1Ja9iQV9SKqMnZXO6lfvwkeJ3p9IlOnvkRMzChSPZuYKW1CRWIZi8jIOBdZ1jE9N5FZeYl4fCqvrQ6b5VRv8vu5SHDBK3D99zD1YiGmHQLExIxg8uTnGTnyNgoK7vzRmH85HA62b98ODF68C91HYPor4A1gMAQmkD6Ki4sjx9h9CilQiRQgMDExMYwZMwboGoXxBCIwmRaMBQkgS3jrHXgaHMFqo2OPPbZHk7/JkyeTnp6Oy+Vi+fLlweWOnU3UPrIe+7ra0LJtjXhquxesgxDatq8ULTasJ+eS9ptpGPLjUD0KrZ+WU/Po+mCJdPzp+X1+ZjWxenTp3TfFNORYMY5NBBVaPz/8ojAdHR189NFHNDRENne1LSvH9lEpzUv7bhGhKAr19ULbUlxc3K1vj8/mwl0uUoaB9FE49MNiMU1OARVs/yvtsj6KwxtRAnMgsddPYI7+DVizRNXP2ucO/nmElVAHIzBaPaSLvjiTpT0UpIdFYIr+J55HL6Sicgmlpf8Qf+b/gUx7ErSUB/Uoen0yU6e+gtk8gmOVjwHYwmQyM84PDnf+zGwAvi4O+7H68h7xPPFcGHsGHACCkZgwh9ycXwQjMT8GbNmyBa/XS2pqKllZWX3v0A1avT72OETkMLwCaW+zgxa7EPBGCL57QEAD09raGkwL9YVABCY+Pp5so/i/VTpdkV4wCSERbwABsrZ58+bgsVSvEqzU0WXGIBu1ImUANG/ex549Iq3ZW38oWZZZsEC0uVi3bh21FVU0vVlE4wvb8LW60SYZSfnlJEzjk0CFtuV7exzLubMJb6MTyaTFPD0NXaqZ5KsnknBBAbJFh69RTG7M01LRZ/d9fftCIArj2FyPp6ZnYnUosGrVKtauXcuyZaFKR1VRg+kex5aGXskgiM+K2y3IbUdHR7cl7/aw9JE2rvvJUdwpuaCRcBW34NjW0O02URyeiBKYA4lK4bPC8Hlw/G3i9TcPCdHqQYQaRmCCERjAmyG0A5PlklAJtasNykTn4qasDIqL7wYgf/hvyc67Bk6+U2z3zSPQKn4wDPpkpk19hammDiTVR42USYscaj0wZ4QI3W/ZZ6PN6RGRqeJPRYoqcF2i2G+Ei3enTZs26KjTFn/6aJhRFyHgDaSPRqf1LeAFERnR6wUJCURW+kJ4BCbLqEMCHIpKgyckog33ggl0uc7Pz8dqteJ0Otm5cycAnlo7KCqyWRus5DGOFVGh7Zu2oaoqaWlpJCUl9XpOw4cPZ8yYMaiqyodL3hPmchJYjski9TfTMAyPI/ZE0YjUXliHt9GBoiisWLGCZ555JhglaP9WRF8ss9KR9f6u3ZJEzNRU0m+ZgeXoTAyjE4hbNLxf16ov6DMtIg2lisjG4YR9+8S1KC0tDQqk3RWtKO1+oqvSZxVVXV2koV9xcVen5WD6qJvoSwDaJBOxxwiy37x0d7Ss+ghClMAcKLRWiYiLJEPmNJh8ESSPFuXIKx8/qKeyr7WZVm0sWlRGmEOzkBqLECxO0+4hNdBkr+RLUDyoicPZXf8qAJmZF5CXd4NYP/E8IaT1dMAXdwXHMhhSmTftecYaRB55ZUson5wVbyIvyYxPUVlb1gRf/E2smHqx0LlEMSSorq6mpqYGjUYz4K7T4QikjzrrXwaSPgJxcx5oGikQgYmLi8Mgy6QbhDg8XAeTbNFj1MmoKlS1iHOVZTko5g20TwjoX3SZliCZC+hgipvFDb2/Iuf5J56EjESlWs/OuFqSfzmJ+NPzg0REn+VPUalQ81kxL730El999RXV1dWsW7cOd1U7rj02kCVi5mZ2GV82aYk/YwQpV05AEzt0EUPr/FyQhO7HvbdtyMbdH6iqGoyWeDweysvF/8KxVWiTdMNEOtuxqb5X/U6AwARI8q5duyLW+1p7Tx+Fwzo/F22KCaXNEzQQjOLwR5TAHCgE9C9p48FgAY0WTrpdLFv1NLTV9rzvUEJV2ekWP94jDTL6sPLj7ZLoPzSOMqRAhZS/+qi+YBJtbVvQaGIYkX9LaDYvSbDgXvF68xvQEMpVGwypnJg+CoBvmyMFcYEoTNWGT6DsG9Do4djfD+17PQzQ0dHBK6+8wldffXXQjx2IvowdOzZoIjcYBA3sLN0TmIE0ZxwsgYmPjwcIppHCK5EkSeqigwGYOnUqkiRRVlZGbW1tUJSpywjpRLSJRrwpWqokURkVXnXUG/Sb7Yz3ilTot66tvPDJ68EUVACxJ+awV27klR0fUlZWFly+a9cu2r4RqSXTxOQeUxkHArpUM+aponP84RKFaW5ujvDsKS4uRlVD6SPrCdlB/U7blz1HYQIEJpA+rK6uprU1VCLv2NIAKuhzYvu85pJOJuHc0SCBfUMdjqKBt8CI4uAjSmAOFAIEZtis0LIxpwubeo8dvn7g4JxHRwM79OkAjLVG5tXXtyfRqpow4BJVR373XRXYYxaCtuzsK9DrO4XYh00X5dWqAivuj1h1TIKYPX3b3BahW5gzIhlQmVHylFgw/QqIzx6693kYwOPx8Prrr7N7926+/vpr2toO3ozX7XYHRan7I96FngW8/Wkh0Bn99YLxdXjwdXgiUkggGklCVyFvdzqY+Pj4YM+n1atXBwW8+sxIw719KW2okkqyIZ7k5N5n5gCeOjutX1Uy0zuC48bPwWAwUFNTw0svvcRrr71GfX09Pp+Pb3ev5RN9IU7JTZIxnquvvhqNRkNzczPVmwV5CKQqDiasJ+WALOHa1Yyr7OCmr7tDIPoSmBTt3r0bT1UHvhYXkk7GMCpBnDMiJedpcHQ7ToDADB8+PKj3Ck8j2fuRPgqHIdeKxR8da3m3GMU5dN4/URwYRAnMgUJAwJsdRmAkCebfIV6vfwEa++lQuz9o2hOqQLJEViwU1XWwWRHr2LdePOwN1GTG0+GpQquNIzfnF92Pe/z/ieetb0N9KHQ7My4GnSSxz+WJmDXPyU/iJHkDY3xFqFoTzLtl6N7jYQBFUXj33XfZu1fMtFVVZdu2g+fAvG7dOlwuFwkJCeTl5Q16nDavj5JuBLz7Whw02z1o5f4JeAPojxeMr8ND7SPr2PfwGux2QUg6R2B67ErdFBB1yC8AANhPSURBVJlimD17NiDEvG3V4pi6zMjP/W6X0F/kOZNRvb0381MVleZ3i8GnYi5I4vhzT+HXv/41s2bNQpZldu3axdNPP83ixYv55ptvABjjzeSM9qmkW1OC/4sK6tHnWodEnDtQaJNMxMwQPlC2T8pQfX23RvA2OKh9bAN1izfRvqp6SHUhAQIzYcIEJEmioaGBmnViwmQsSEDWa9APi8U4JhCF6VpF5fP5ghVMqampjB4tGtUGCIyv1d3v9FE4rAvy0CQZ8dnc2D6OViUd7ogSmCFCQ8NXbNn6K9zuJvC6RJkwdG0MmHcMjDwZFK/oL3SgEVGBZIxYVVTTxmbVr0HZtwGK/ociQelwMWPNzf1l155BAWROEc0PO0VhYjQaplnFzSU8jZQSo+OPRtGIsXTEJRA7cHv7wxmff/45O3bsQKPRMHHiREDcRPsDu92Oz9c/k8Nal4cHS6spc4RM09avXx+s5hg7aRpyDy7F/cEWf/Qly6AjSd+9gNfYjblaT+hPCql95T6UDi+tDhGx0uv1GI3is5pt6iECkxjpBRNcnp1NRkYGXq+XHb5K0Mpok0NEzG63U1YlbojD3Sm4SnuPSHSsq8Fd1oqkl4k/a6QQ3cbEcOqpp3L99ddTUFCAqqo0NDSg1+s555xzODFzFlqvTNs3exk1QqRpK+UGLMd01b4cLMSemANaGXdZK81v7+rSTiEcXpuL+ue24KnuwF3eSsvS3VT9fTWNr+7AsbOpXwSoNwQEvPn5+eTkiEjLrh2ib1e4V0t4FMbbGPl/bmpqwufzodPpiIuLCxKYkpISvF6vqCZSQZ8dizY+8nevOzQ0NFBcXIys15BwtkiDd6ypwbm7e+LtbXbiKm/FXdWOp8GBz+bCa/dw644KbiuqxBt2fb0+hZvfLOTvH+9A6eW6RzFwaPveJIq+4PPZ2b7j93g8TZhNeYzQzwOfG8xJkJjfdYf5f4Xdn8HWd+DYWyF17AE7N09TKcXm+UBkBZLN7qHa5mSTHEZgVIWqdCMOjRO9PoXsYT/vffDj/w+KPgp7H8KL4+gEC6ttHXzb3Bbsk8T2pYxQymhVTfxHfzY/pNqjNWvW8N133wFw5plnkp+fz9atW6mqqqKhoaHXNEV5eTkvvvgikydP5swzz+z1OCV2Jxdu2kOl083ODif/njCcdevW8eGHHwKw3ZuKs9HKKfvxXnoysBuogDeAAIFpaWnB5/Oh0USSH8XhpX2lmJG3S0IXEauLCaYXAimkii5mdv4ITHNkBEaSJGbPns17773Hdu1epqWMR9KEqrF27tyJqqokG+OJc5px7mjCOCqh23P3tYZm4dZT8tAmRN4Ik5OTueiiiygrK6OoqIgZM2aQlJSEQ9dE4wvb6FhVTeaceABqNDak/N57Rx1IaOMNJF1UQOOrO7BvrEPSysT/dCRSuPs24Gt30/DcFnwtLrRJRmJmpWPfWIenxo5jSwOOLQ3IFh3GMYkYhsdhGB6HJsHQ74o3RVGorq4GIDMzk/b2dsrLyynvqGGMJl1EXfzQZ8diGJ2Aa1czrV9Vknju6OC6QPooNTUVWZZJT08nNjaWtrY2SotLiF0pyHB/oi9er5cXX3yRtrY2rrnmGjJHZBIzO4OOVdU0v1NM2k3TkfQy3lo7jq0Nwu+numuJ98Z4DS8fJT6XPkXl/jHZSJLE18X1vLtBkDaNLPGHhWP6da2i6BvRCMwQYF/Vf/B4xAyzpmYpasVqsWLYrO79TdInCj0MwMZXDui57bE14Zb1xOBlmDFU3bCrTnzBayx+8lS7FV/DNkpzxBcwL+8GNJo+hKAZk4SHCyqsuC+4+Jh4EbVZ2dIudDA+rzDxA57znsaX5T+cMsVdu3bxv/8J35wTTzyRSZMmYbFYGDFCEMOALqUnrFixAkVR2LJlS9DTojtsbLVzxobiYCRiRVMbq9aGk5c01nhzWL6r+/46/UVhsAN1ZAuBzXv9At5hfRMYxS4qOZrfLSbWbEGj0aAoSlCgG47276pQXT60qWaUyeJzY27TBD1AAimkvS43SoQXTFcRbwDjx4/HrDNhl1yUmyN9PQJpvbEjxU3Esb2xx+vV8t8SVKcP3TBLUBvRHfLy8liwYEGwHNtYkIAuMwbVrSCvaCJeMaOiUlJ6EFLGvcA0PpnEC8aAJPoktXxQEvHeFX+DSG+9A59V5v2Y9XzZsoGUX08l9VdTsRydiRyjQ2n3YF9XS/Nbu6h5YC01966h8fWdtH9fhbcHvUoAjY2NuN1udDodycnJjBoloh1VchPaEbHIxsg5tXW+PwqzoS6i5UI4gQFBXANjbfl0Hd4GB5o4fTB11hu2bdsW1KsFxNdxi/LQxBvwNbtoWLKV2ofWib5Tn1cI8iKDJtGIHKtHMmpAlliWETr3l2qaeLpSlM9/UBjyp1m8vITXVh9+xoJHKqIEZj/h87moKP9X8G+nq4rmus/EH9kze9gLmHqJeN78H/AN/oZut5dit/dcXbDDIVITY3Q+5DAytbNGfGET0/PAkg6o7M004TbIGI3DyMq8oH8ncJxfC7NtKdQKB9jpcWaMskSd20txuwO+uhsai1FMiSxRFlJc105d25HfPK2qqoq33noLVVWZOnUq8+bNC64LlDFv3ry5xxtkdXV1sJLF6/Wye3f37qMrmto4p3A3TR4fkywmEnUa2n0KS779HoCt3jTUrEkYtBpqWp0U1w3OEt3m8fJpg9ANHJ0QSh1W2xx8VyJKXOfkJ3a7L/idZtdUU/PQOtq/3UfHmhravqgICnk762AUly/kTHtiNg7/ZNmiGGh6owjVq5Bp0CMDLkWlzh0SVQZSSPVtLpyeyPSbVqtlglmkTTfZioPX3263U1oqIiqTjp4GWhlfiwtvbddSXcf2RlHFIkPC2aO6RCp6gyRJxJ6QE/w7RxJNVDuX+R4KmCenkHCeqLbpWFWN7aNSVFVFcftoeGEbnqoO5BgdthPMVNfVsHHjRlauXIk+y0L8GSPI+OMskq4Yj+XYYehzYkGW8LW6cWyqp+X9EmoeWkfNw+to+XgPrj22LummgP4lPT0djUZDWloaMbIRn6TQkNaVwBtyrKKvk6LSFuYL05nAAME0UmlTJaqsknjRGGSzrtfroaoqq1evDv5dWSmOIRu0JJwjCJG7rBVvoxO0EsaxiSScO5qMP80m4/czyfzTUWTdMZf0u4/myxHiM3lirfg9v6ukireqGvlsu6g4XTRBFFP85f2tLC+K9LA51FB9Ch1ra2j6TxHt31fha+t5MnU4IUpg9hPVNe/gctdiMKSTkX42ADXeHWJleAVSZ4ycDzEp0FEPu7/ocTNVVdnR7uCJ8lpeq24MLlcUlU3lFXy76id8t/p0XK5uyrJVlSKfCHuPjYmcURfViBtVQUYcZE3Dq5EozxbbDB/+q/6716ZPgHFnEh6FMcgyM+OEcPLbzx+Hbx8FQD7hj+RkiC/x9yWN3Q53pCBQceTxeMjPz+f000+PCKOPGTMGnU5Hc3NzUNjbGYG0U0CzsmPHji7bvFfbzCWb92D3KcxLsPDu1JFMUMSPS0VCKlu96SSOmsHLv5jN7HwRAVhRVD+o9/SfmiYcisLYGCMzrKHo2+urK/ApKkcNT2RkaveaKPfeNuoWb6Ll3d0odi+aBFG22rZiL/Em4fLcWQfTsaoaxe5Fm2zCNCklGKGJ1ZnxVHfQ+nkFOlkiI+AFE6aDiTPpsBjEjLe7KExBexoaVabGVh+8/jt37kRRFNLS0kjJSMU4Kl6c11u7aHqziJYPSrAtK6Pt6720vC+iJZZ5w7pUMfUHpvFJaFPF92l0gegO3ZPd/cFGzLQ0En4qbs7t3+7D9kkZja/sEFofo4bkqyZQZQv9nnz55ZdB4idpZEwFicSfOpzU66eQeccckq+eiHV+jnA59rdqaP96H/XPbqbq7lU0vrEzGFELEJjMTBHR8rW4GOYWpLhS6f6mHtDCdKyvDUZ4uiMww0ypaFSZNtmJ75gEDHl9RwsrKysjHHwrKiqChNc4KoG4M/IxT0sl8WdjyPzLbJIvG0/MjDQ0MZHE6NuWNho9PhJ1Gp5ISObCcvFZvbmokjazhmEJJp762TTOnpaFT1G54dUNbKs69BVhqkehfVUVNQ+uo/mdYuwb62h5v4Tqv6+m/tnNtK86vMlMlMDsBxTFQ3n5PwHIzbmGTH/Uoi7eh0+jgaxeylk1Opjkj3IUvhqxyuFT+Lyxlf/btZcZ32/nhLVF3LOnmpt3VvLQhnL+753NzL73Cx58/1k02JFUO2u3PNj1GI5mdhjFD8WYhMhS6F01YpZekG6BrGlUZBnx6GTMhmzS084a2IU47v8ACba/DzVbAThGIyI8Kz1G0BrhzKdg1tXM9fvBrNpzZBOYuro62traMJlMnH/++V20HXq9PljS210aqaWlha1bxbUKWNXv2rUr6EoK8Nzeeq7bXo5HVTkzNZ5XJuWzd3cxmm2FYvuELEZMnsPTl0zHqNNw7Ggx0/+6eOAERlFVXtgn/idXZCUHyZjbq/DaGjEr/fmc3K772T00v1tM3VOFeCrbkAwa4k7PJ/13M4T/iAqmGnFDCCcwqscX9EaJPX4YkiwFCUzaTBE9aVtRiavMRo4poIMJCZeFF0zXUmoQ2hVDh8wIRaQPVq1aBYTSRwHvl4A+wrOvHfuGOtq/q6Lty0psH5fis7nQJBqDN8+BQpIlEi8cg+WYLArOmIbRaMThcPRIZg82YmalE3+mSHO2r9iLa1czkk4m+YoJ6DMtwe7OCQkJqKrK22+/HeGxEoCs12AcEY91fi4p10wi8/bZJP5sDOapqchmLarDi6Ownvpnt+BpcATJQqDs2bGtkWGK+E3YXda9gZwhLw7DyHhQVOqeLqR1Q3XwsxQgMIrLS/tbe8hQ4gHYZ+16rt0hEH2ZMGECsizT0dERESmMPTqLxPMLME9KQTb0LBl9r7YFgDNS4kmcn8cfHHqOq/XgAdxTkzhmSjqyLHHf2ZOYk59Eh9vHlS+spdoWIt+qquJtcgoS8d8SbP8rpX11Nc7iZryNDlRfiPwqTi+emg4cO5toX1WF7dMybJ+W0fp5Oa3LK2n7dh/tq6roWFuDY3sj7so2vC2uYNWd4vbR9u0+qh9cS8vSEnwtLmSLDssxWaJSTgXXHhstS0Nkpu2bvXjq7fuVoh5qREW8+4GamvdxOveh0yWRmXkBsmzApEnCQSN1+cPJ0HffaC2IyReJnkJF/wN7E5gTuX9PNf+srMMRplY3yhIGr4pNhof31qFfV4ekws9Hbwpu47Atpb39l1gso0Ljh3WhHhMX6nWkqio7AxGYNCtt5iwqnOJmkD/y98jyAD8WaeNg/E9h27uw/F4YdTJHf/M8TH6c7xJmoFy5DDlzMiAM7f71TWkwJXGkIvAjl5KSEqyY6YyJEyeyefNmtm7dyoIFCyJIzvfff4+qquTn5zNz5ky+/vprOjo6KCsrY+TIkXzd1Mafi0V65aqsZO4alYUsSXz0+QqybeLHu8Nq4Xdzx6LViHnIcaOTuQtYXdqEw+3DpO9/tdDXzW3scbiI1cickxYStX66rYaGdhcpsQYWjE/vsl/Di9uD5armqanELRqOxioIR/wZ+ThLWrB06EAXSWA61tSgtHvQxBuCRmsBD5iUCcMwtzuwb6ij6T9FZJ+Wyvd0X0q9s6atSwQmYGA3yTqKXR3VbN++nZqammAUIeC+a56SimzS4rO5UZxeVKcPxSWeVa9C7HHDgk67g4E+0xKM3owcOZKtW7dSXFwcrLw51LDMyRQdsD8qBY1E0qXjMORacbvdwa7eP/vZz3jrrbeoq6vj7bff5rLLLutC1sMhG7WYJ6VgnpSCqqi4K1pp+e8ePPvaqXtuE9XekIAXwLG1gSwlEVmSaGxspKmpKSj8DkfCT0fS+MoOPNUdlL61EdWgYjKasFgsqKpK87u78TY4yLWks9fbxK7iXRx9zNG9vn+bzRZsfHr00UfT0tLC3r17qays7PYceoJLUfi4vgWAn6YlIGkkUi4awz2Pb+Aao8z2OA3LdAq/c3tJ1mv558+nc+7i7yiua+fPz67l/pl5SFUduMpaUVp7iXZIoLHqUZw+VFf/qha7HcakBVVFdYoxNHF6Yo/LJmZmqKO5t8mJY2sD9i0NeCrbcO2x4dpjw/ZRKZokI6aCxKCQW9IdujhINAIzSKiqj7LypwHIzfkFGo0RSZJId4kf+ZrU3nOvgEi/ZEwGxQNb3ubLxlYeLa/FoahkGnRcmpnEyxOH86A1CedXVeBWUGN1HHVsDi9eXsD4ZJFTL2/NQZZUviuMLMvuaCyl3CRmOmPCUkg1rU5anV60sorB9QZrq/6GTytjNY0mNXXh4C7IcX8AJNj5Ifz3N0y2bSVGcdGstbA9NkSqZuYlopElyhvt7GvpXfB3OCNwMw7oO7pDfn4+MTEx2O12SkpCAk673R50zZ07dy6yLAe7KQfSSIFWDD9JjeduP3mpq6ujraEao9tFokfMpL4OK1UfkWIhK96E26uwqnRgBHHJPiF2PT89kZiwPkcvfy/0VRfNykGnify58NSKMls0EinXTCTxgoIgeQGQzToSzxmFVRXpqKZacQzVq9C2IhB9yUbSyPh8vuAMPz4+nvifjECTIESUKWUi/dBjKXUnLxhPtbgmGdmZ5OXloaoqb775ZjB9FKgKk2QJ09gkLLMzsB6fTdzCPBLOHEniBQUkXTwW/bCh82wJCEwPBx1MOGLnDSP56omk/WpqsBpr3759KIpCbGwsycnJnH/++ej1eioqKvjii57T3Z0hyRKGvDiSrxiPNslIg60Jr9eLwWAgMTERX5vwatGjJTtLmFp2188IhJdN6g1TiD0phyaN+P/GO404tzeJKMOmepBh0llzAJEKcjh6/31Zu3YtqqqSm5tLRkYG2dniHAI6mP7iy8ZW2nwKmQYds/ypc228kfKpSTy6wUGmXaHK4+XkdUXcWlTJigYbj07K5iXZwp2NEs5PynFsbhDkRZaCovGYORkYxySiTTWDVgYVfDZ3kLzIZi26zBiM45KImZ0h9pmVjnlaKqbJKZjGJwlB+TALmjg9+KvxVIcg6ZpEI/FnjyT91plY5mYGyQsIx+rYY4eRdsMU0n8/k7jT80UUTCPha3TS/l0VDc9vpepv39OxtmZA12soEY3ADBK1tR/hcJSj1caTlXVxcHnGvhZKc6FJ04DTWYXR2If3w5SLoXoTjk1vcptyDABXD0vmbyOzkCQJm8PDSR+tRfKqzNca+BwP22MlMmI2Ual6sVjG4FJ+i6Jei8b9DXUNa0hNFtqboqYGYBQpip3kME+Popo2EgzN3DjtdcpKReO75OT5jB1zL5I0SE6bOgYmnCOM7SQZ3Yl/ZrY1mS+a2ljZ0s4Ef1lurFHHpGFxbKxo4fuSRs6dPmxwxzvECERgeiMwGo2GCRMmsHr1ajZv3hwUGa5btw6Px0NaWlqwWmns2LGsX7+enTt3ctppp7HbLkTOM62hkuJ169YBUKnEMy3GzOduJ181tXFOupgtSpLEsaOTeX1NJSuK6jmhILXzKXWLSqebz/zi3cuzQmWnO2taWVPWhEaW+NmsrlED+yaRqjKOSsCQH9/t2MaCRNImZkNRIc0tzficHhybGvC1upGtoSqRtjbh3CzLMhaLBVmWSTy/gPpnN5OypxUmmiKMEYFu2wkAIQfeDAuz02ZTVlYWJJz9bR0w1Bg5UnjI1NbW0tLSEjTqGyyqqqr44osvaG1tpaCggAkTJpCWljaoBp7GEZHnEriBZ2eLMuDk5GTOOuss3nzzTb777juys7OD6dH+QGPRk3zlBLY/9REokKxakXxg394Iquh9NGrMaMr3VrB7926OOuqobseRtDJxJ+fibtsKmyHea6bx5e3BaXjcguHEThhG8vJkGhoaKCkpYcKECd2O5fF4gj2zAsfLzs7m+++/HzCBWVrXAojJRnihxIv1Nqa6nTy2QeLaWWaq8fByVSMvA7Ks8v/snXd4HOXV9n8z27t6L5YsWZJ77xUDpgaDaaYTSkIKJLwhedNICCQkJG8gEJpD6BB6Cd3GuGIb9ypZVrV610rb28z3x+yuJEtylY3h831duiTtzs7Ozs48z3nOuc99F00xM6U9SEajlxaXnz2EKJUlRqpEZhtNXDYhg6x45RqXZRnJGSDY6UU0KOakx5odlGUZ2RMk5PAj+yU0aSYE1ZHHe3WcHsvsdCyz05F8QXzldrz7O/GUdiB1+1HFHVln52ThTAbmOCDLUjT7kpV5M2p1uFQU8GKoLSbGHgBkmpr+e+Sdjb4cRA2Pasdw0OsnVafhFzmp0YHob5+V0ub0MTzRxFNzChhu0NEWCPJ4XaQGfAE3zp3PthYlXbp59/3RGuV+pzKwF6n6dvzUN3zAfTP/zDDLfkTRQGHhnxg75im02qNPmw6I8/4M038IN34Ac+6OdrL080UKk003VHxzreuPJoABoqJ2+/fvx+fzEQgEonX3WbNmRb/nYcOGodPpcLlc1NXVccClfGd5YfNNv9/Prl1KyXB/KImF8QpBcXWHo0978bzj4MG8WN+GBMyJNZPfSysokn1ZNCqZFFvfQUqWZWXVCxjHJx52/5mXjEZAIIhE/bv7erIvczMQ1MoQFOG/WK3WKKlZl2PDNC2VVI/y+WoP0YIZyE4AekpImjQTI0aM6PMdHa1541DDZDKRkaEE64NlGY4GDoeD999/n2XLllFRUUFrayvr16/nqaee4vHHH2f16tVRhdrjRYT/0rvUNXLkyKjK8XvvvXfU3lYRqOMNOAuVCTfObaDjtf1R7yPDqJ526qqqKgKBw3dltnnsAKTkZYAASKAvjMM8R8k2RxYKh8t27dmzB4/Hg81mi2Y/IxmYlpaWPl5Nh4MrGGJ5m3LtLk7quc7anT6+LG/jCXwMM+t4d62Th7e7ufqgnxxnCEkQ2Bej4vnhOv4828aeuUl0xOtwyzJbD3byyOdlnPePtby5tRZZlhEEAZVFiy7LiibReFylTUEQEI0aNMkmtJmWowpeDoWoU2MYlUDsknxSfzmVpB9PQDfMeuQXniScCWCOA62tK3C5ylCpzGRk3NDzROMukAKk2pXyUWPTO0cmPJniKRt5Df/MvAaAB/LTMYdT+Dtr7bz8lTKJ3L94NGatmnvzlIzOO95xtJJIctIFGLVq8vN+ii+kwUAxlXUfA7A/qOynyKBkX4JBB/uKf0aK/CdMGg8euYBpUz8gPe2q41q59YM5Ec77k6I2TI8v0ka7s48y5czhyip/Y8XgGhynOyID+JFq5enp6cTFxREMBtm/fz+7du3C5XJhs9n6TKZqtTo68O4pKaE6PFlHAoq9e/fi8/nolnQ0SlYuyozDpBJpCwTZ6+zJQMzMS0AlClS2uvrJ7A8EnyTxSmMPeTcChzfAuzsUDs510/uTdwP1ToLtXgSNiL4ovt/zvaEx6rCalWC2eXcNoQ4volmDaWoPp+ZQE8cIbOdmkxHOCtZ7/YR6XS8D2QlI3iChdmXy0aQpmZzICrt3+ejrwNFMrIMhGAyyfv16HnvsMXbs2AEowfFll11GUVERKpWKtrY2Vq9ezT//+U9eeumlIwYCA0GSpCjRODKhR3DOOeeQmZmJz+fj5ZdfPuYgpsmuBLyJ2PDsa8dXZgfAMDqepKQkrFYrwWCwjxHmQIh0IA1bMJLEO8ZhXZRN3NUF0Vb3yHkuLy8fsOtLluUosTtiCQFgsViIiYlBluWoWvCRsLy9G48kk2PQMq6XdtLHexoJSTJFGTZSrx+FWa9mbjf8zhbLmgkj2Da9iEcKM5kTayYIbDTIZC/K5oO75/LgZWOYnB2L2x/inrd2c9drO3F4Tz/tLEEQ0KabjysQGiqcCWCOEbIsU12tGBJmZt6IRtMr+gz7HyUZJyGKOtzuChyOwwuZybLML1KvJSBqONu+jQvCg3IwJPHrd/cgy3DZhPTopH9uvJXJRjcBtLyjvgNjWO/i0knj2NmhdLPs2/8QkhSkRIgBoMBmo6trJ5s3f4empneRZIH/VixCk7ws+vqTgVFmAzFqRbNkt7NnkpmUHYtWJdLY5eVg+5En2dMNwWAwytc4UgZGEISoJsyuXbvYuFHRbpk+fXo/MmRkJfhVldJ5ZBBF0sItxJHyUWkokUSLnkSTjjnhAHF1R49ppFWvYWJWDHB0WZgPWux0BEKk6zScG9/TdvrO9nrc/hB5SeZoxqw33LvD5aPCOETdkVeD8UnK9esQlWDLMie9zyryUBPHCESjhvyzhqGSZAJAfWtPNi8jzIHpdAdw+pTurYhCqsqmjba6TpkyhfPOO48lS5Yc8ThPJqI6JVVVA4oWyrKM3W6noaGBiooK9uzZw+bNm1m1ahWPP/44n3/+OX6/n/T0dG655RaWLFnC2LFjueqqq7jnnntYvHhxtFRVUVHBihUrjvkY29ra8Hq9aDQaUlL6krZVKhWXX345NpuNjo4O/v3vf/dpQT4cgsFglBicd9F4JXMCqJOMaBKNCIJAXp5iuzCYHhKA1+uNBruJiYnosqxYF2T1EcDLzMxEr9fjdrtZvXp1v3NdXV1NS0sLGo2mn/HpsfJg3m1WMrGLk2KRJImXXnqJ//znP3wQFq/7zrg01AkGUn4xlbTfTif20ny0GRbSDTquTo3njXHDeSA/Ha0gsLy9m+vLasjMj+X1783gnkUFqESB/+5q4IJH17GjZnA/sf9fcSaAOUa0t6/G4dyHSmUkM+Omvk/WKqUBdfp0EhMVQffGxncOu7+3mzvZ4NdhCPn4Y+lDCBVfAPDSpoPsa+jGqlfzqwt76s2CIHCT+n0EWWJtaBzbupQBWxQFFky6G4ffhElVx659yygxKDdjjLSNbduvwuOtQadL5/+2/4T3Ky6kMPXwk++JQiUIzIhRJtkve5WRDFoV48OT7DexGyky2Wo0GkymI3Sa0VNGqqyspL29Hb1eP6BjdF5eHmq1moNhgm6+UYcoCNTX19PQ0IAgipSHEhieqLzn/DgleP6ivW/L6Nx8paRzNHowEfLu9WnxqMMrWFmWeWmTkvm7fnp2v+ycLMl4dimvM447fPkogkimymlVdF9M01P7PD9YBgbAMimZ1KByDCWrekQbrXoNNoMSpNSFy0g95aMe7RaVSsX06dP7aIZ8HUhKSsJmsxEMBqMdUaCc77KyMp588kkeeeQRli1bxksvvcTbb7/Nxx9/zJo1a+js7MRsNrN48WJuueWWftkRvV7P+PHjue6667jmGiWbu3nz5mPO9kTKR+np6QN2G9lsNm699VZSUlJwuVw899xzR1USa2lpQZIkDAYDqdNyiVmcB2oR88ye6yBSRjrc/lpblWvaYrFgNA6sFK5SqZgwYQIAa9eu5Z///Cc7duyIZmMi2Zdx48ZhMPTVxzqWAMYeCLIqvHhYnBwbDTxLS0tprq1AEODCscrnE7WqaLm0NwRB4NaMRD6ZPIJ8o44mf4ArdlbwUHUTt88bzhvfm0F6jIHaDg9XPLWRJ1aXn/FT6oVjDmDWrl3LxRdfTFpaGoIg8N577/V5XpZl7r33XlJTUzEYDJx99tn9LsiOjg6uvfZarFYrMTEx3HLLLTidfXkSu3fvZs6cOej1ejIzM3nooYeO/dOdBFSX/Q2A9NSr+3JGZBlqtyh/Z07tEbVr/gBJGrg1zh4I8rtyJVL/qVxKtrcJdr5CU5eX/1uuDDz/e34RCWZd9DWBgJ247veYyyoAfldeHy3DzMjLptx9OQClja/Spo1DkCXkpr8hy0GSky4iKfc19rfnYNCoonLsJxOzwlmCQ3kwET2YbyIPpjf/5WhKb/Hx8VHdC1AyAjqdrt92Op2O4cOHYzcq5Za8cPkokn0R4zLxoSEvSTmnC+KU7bZ2u3AEe9oq5xUoQcWGinYCof4p9Ah2O9xs63ajEYQezypgY2U75S1OjFoVl01M7/c6f003oS4fgk6FvuDoeFORAMaXqyXlZ5P7aWoMloEBZZDPjlEmmqpmB57SntJFTyeSktmJEHg1xyE+d7LRW+4+MiY2NDTw4osv8sorr9DS0hIlMSclJZGdnU1RURGTJk3i3HPP5cc//jHjx48/ollnfn5+tGz2/vvv9xtbD4fIxH24Vm+LxcJNN91Ebm4ugUCAV199NVrWGgy9BewEQcA8LZX0P8zEPL2nySE3NxdRFOno6KC9feCFzUACdgPhnHPO4bLLLsNms9Hd3c3777/P008/zY4dOygtVYwjByILRwKYurq6I4oOftzaRUCWKTLpKTDpo6raAOPUDUzJjiXVZjjMHnowymzg08kjuDY1Dhn4x8Fmfrq/hknZsXx81xwuGptKUJJ56NNS/vBh8VHt8/8HHHMA43K5GDduHI8//viAzz/00EM8+uijPPXUU3z11VeYTCYWLVrUhxR17bXXsm/fPlasWMGHH37I2rVruf3226PPd3d3c+6555Kdnc22bdv461//yu9//3uWLVt2HB9xaJHfbCKxzUfWJy/C2r+BK3yjddWCswkEFaRNIC5uFlptEsGgnbb2VQPu60+VjbQHguQbdXx/tLJioPQT/v7+Rpy+IBOyYrh6St+VVmvr58hykBsNmzGIIlu73fy3uedmXzL7x7R64mlRKxNSEs2YVCpGFj3EqFGPUN4WrhMnmxGPQR79eDErnIHZ3OXE12tAiJQlNlV+83gwR8t/6Y1IGUmlUjF16uAKzYWFhXSGA5h8ow6PxxMVvGvWKsFEXmLYLdygI9egIyjD+s6eMtLoNBtxJi1OX5DtBwdPO0eyLxcl2kjU9rT9R8i7l05Ix6LvLwcQ6T4yjIo/ag2ISKltMN5EJAMzUAADkB2eCBoMouLhE85SZcT0NXUMhDMw2tQjZ8a+DkTKSKWlpbz99tssW7aMqqoqVCoVM2bM4Gc/+xk/+9nP+MEPfsDNN9/MVVddxcUXX8zMmTMHDHoHw9lnn01SUhIul4v333//qO+xSAbm0AzPodDr9VxzzTWMHTsWWZZ5//33WbNmzaDvE+GURPRfgH72DDqdLho4RYKMQ3G0AYwoiowdO5Yf/ehHnHPOOeh0Opqbm3n//fcBGD58OImJ/bOHSUlJaLVafD5fNNszGN5rUe6tS8O6Sb2zanGih7NSgwO+bjCYVCr+rzCLp0YqnLN3mjtp9PmxGTQ8tnQC9y9WOqr+s7mGLs/px4n5OnDMAcz555/PAw88wKWXXtrvOVmWeeSRR/jNb37DJZdcwtixY3nxxRdpaGiIZmpKSkr49NNPeeaZZ5g2bRqzZ8/mscce47XXXotG6a+88gp+v59nn32WUaNGcfXVV3PnnXfy97//fdDj8vl8dHd39/k5GbDFT2dsjRFdZxN8cT88PBL+eyfsek3ZIGU0aE0IgoqUFMVduKnx3X772drl4sUGJfD4y4hMtKljIGUsSAH0pe+iEgX+uHhMvyCjpVUh6BalzOaHccpg8Zvde/hox0pkWSYnKZZO8WZqUW6CbKmBqVP+S2rqEgRBiHogFaQMncbF4VBo0hOvUeORZHZ09/BdxmfFoNeItDn9x+3d83XhaDuQemP8+PEUFRVx3nnnYbEMfu4LCgqiGZgUKcju3bsJBAIkJiZS3K0EE8OTerILkSzMql48GFEUmJOvcE4G48F0BoLR+n1v8m5Tl5flYe+WG2YM6/c6OSQrHkGA4SjLR9AT7HV0dPSb5GRZPmwJCXpMHRutakLtXhxrwq2+kQxMpwc5KBFoUa6x0zEDA5CTk4NarcbhcEQVmseMGcOPfvQjFi1aNGhZ5Fih0WhYsmQJKpWKsrIytmzZcsTXOJ3O6LUd6Zg6HNRqNZdeeimzZyuk/VWrVrF8+fIBtz3UQmAwRNrcN23aNCAJ+WgDmAg0Gg2zZs3irrvuYvr06dHs1YwZMwbcXqVSRT/74cpILb5ANKt8SVIMgUAgun11SBkXQvX7jmtxtjg5luk2ExLweqMS8AuCwLVTMxmdqMYfDPLfXUfHPfq2Y0g5MFVVVTQ1NXH22WdHH7PZbEybNi1KXty4cSMxMTFMnjw5us3ZZ5+NKIrR9tKNGzcyd+5ctNoeUaxFixZRWlrazxAuggcffBCbzRb9OdIK4rgx5274yR649Gkl4Ah6YfsLsCosItfL/yg1RQny2tpX4/f3XXk+UKFcgFemxDIzXGZhvKInc4VqDTfPHMbItL7taYFAFx0din9OkpzNHZ9cTr6rmlZNLLfY47l6xUeUdbaxdO53OehU/FfEmhB3vtlOTZgsG/VASjk1rW+CIES7kV6ob4u2/OrUKqYMUya1FzdWn9IsTGcg2E8UDZTWx6au/u2TBz0+6nptH7kGJa2J5u6ja7fU6XRcddVVTJlyGINPwGAw0GUOfzf1NdHy0cRJk6kJl0nyegUw83sFML3PYZQHc2DgAOblhna8kswosz7qWwXw0qZqQpLM1Jy4AYNcX6UdyRlANKrR58Uc4VP3IBLs+Xy+fgJj27dvJxAIoFKpsFoHvi6zwgFMa5oSsHSvVmwGxvsEbkTLtF2dND28DUIygl4d9WI63aDRaKLaJDk5Odx+++0sWbLkmILho0VycjLnnqtw8ZYvXx6d/AdDJPuSlJTUjxsyGARB4Oyzz+aCCy4AlLE7ItIYQSAQiL73kQKYCRMmYLVa6e7ujl77vXGsAUwERqOR8847jzvvvJNbb701ShgeCJG5I3I+BsIHrXYkYKLVSLZBR21tLaFQCEFrYGMgG0lQ0drSfNwt80tTlQz1fxo7aA13lz3++ONMdmxkhvogb2w5Nq2abyuGNICJsMyTk/tamCcnJ0efa2pq6nfxqdVq4uLi+mwz0D56v8eh+OUvf0lXV1f051jFiI4Jah2Muxq+txZu/gSKLoaIAFzewuhmZnMBFstoZDlATe2/o4+7QxJbupVa/f8M62H6t+ZcjF9WMUas5heOB8HZd/JpbVuBLAcwazMxvfYDTO4WPmv5Fz8NlaCT/KzRZLBgRzWP7N9Fm1bpLhKcEp+XNHP2w2v4+/JSihsjFgKnJgMDcG1qPCLwboudXx6oi060S8PiaC9vquGvn5WekiDmoMfH/M37mfNVCZXuHm+dsmYHC/++hnP+vqaPU3aLL8DCLaWcv+1AtAQWCWD+/EUtVz29cUiPu9kfxCeqEGSZqk1f0traikajwZY+nKAkY9KqSLH2aLLMjDWjFQRqvX4qe3kFzRmhZFX21nfT5vT1eY/tXS7+Vq3cR7dkJEZ5PBvK23hqjVLHv2nmsAGPL1o+GpNwTO2TWq02mnnqXUaqq6vj44+VrOK8efPQaAZWsI5kYOpUsuJOHJRpfWo34za3cRt6RjqkaPu0cULi0MgCnCRceOGF3HXXXdxwww1HnNBPFFOnTiUvL49gMMjbb7992Nbq3gJ2x/M+8+fPB+Cjjz7qM/42NTUhyzJms3nQADUCjUbDvHnzAFi3bh0+n3Ltlru93LOviuawl0+k/LO3vov7PthH+yHX+GCIiYk5YnbpaIi874W9jxYnxQBE+S9NkhUfGpJylUzS4cpqh8M8vYgBmYNeP796+TVWr14d5QUNV7VTWt/OvoYuQkGJjkYXVbvbKN/WQvm2Fsq2Nis/W5TfTZVdeJ2HLznJsozPHaCr1U3Qf/w2Baca3xolXp1Od0w14iGBIED2TOWn86DCgwlroESQM+zH7N7zPWprnyMj/Tr0+lR2OdyEZEjRaqIrS4DtbSo2BK/jt5qX0ZT+F2rWw4V/g1GXgSDQ0vIJAEnlFeB3wLA5GJe+xi90Zq4u38S9e7fzmW0iT3UDOmVg/Om8cSzbIrK+vI1Hv+hpTzxVJSSAuXEWHi3K4sclNbzQ0I5GFLg/L50LxqRy33dG8bv/7uOJ1RWoVSJ3nzPipB1Hmz/I0l2VNPuV2vSyulb+PCKDBruHG57djN2t3OQvbzzI3ecqGazn6ttwhiScIYlyt4+RJn00gOkIanG0u2ns8pIWc3Qr1iMhosBr9bjwu5Qgd8yYMdR0Kcc2PMncZ3I2qVRMizGxrtPJqg4Hw41KcJNk0TMy1UpxYzfrylq5dIIyaDf5Aty8twqfJHNegpWrwyq+Ne1ufvDqdkKSzKUT0jl/dH/fIzko4dmrDKKGsUdfPoogNjYWh8NBR0cHGRkZOJ1O3njjDUKhEIWFhdFSxEDIDBs6NvoDmC/Ox//EbmR/CClWx/L2burU8L83TkSTakJlPkon9a8JGo3mpGRcBoIgCCxevJgnn3yS5uZmVq5cyXnnDWwXMpCA3bFg7ty5NDc3U1JSwmuvvcbtt9+OzWbrR+A9EsaPH8/69evp7Ozkq6++Yu7cufxfVRPvtnSRWjiJG2v2RbPzf/l0P+vK2qjr9PCvGyYfYc9HRigkkRinLJY7Ozup3t+IXmtEVAkIooAoCrRKIbZ0uxBQzBuhh/9ywG1EqxZZcsFZ/PvJEurr61nx9iaEbiVwsyYasCUasCUYsCYaMIatN3yuIK4uHwcra9i5bxu1TZUMyx9LSVoO+1OymSDoyEwaTlntbrrddi4NdPP533ayxishH2VXks6oJibZiC3JgCVWj8cZwNnpxdHhw9npJeDtCVxMMTqsCfrocZpjdYNyJlPzYrAmDM34d6wY0gAmohvQ3NxMampPe1xzczPjx4+PbnNoKjMYDNLR0RF9fUpKCs3NzX22ifx/qDbBaYPYbOXnECQkLMRmm0xX11Yqq/7ByKI/R1ufJ9mMfW7o7TWdvBBaRFzRPO5yPAzNe+Ct78Ledwgs+j0d7esASGp2Qd45cNVLoFEunOy86byQOYqVnz/Jbymi0piJMeRm5ohRzBlr5dO9Tdz/YTENXV6SLDoSLac22Ls8JY6ALPPT/bU8U9eGWhD43fA0bpw5jEBI4oGPSnh0ZRkaUeDHC/OPvMNjhCsY4rrdlVR6fMSoVdiDIV5v7OCOlHhufXYzjV1erHo13d4gL39Vww8W5CGLAi809HRJ7XN6yJKDBAIBJBlcsjL4lDR2D1kAE1HgTZF6VkyTJ0/m3VLlmokQeHtjQZxVCWDaHdya0RNYzCtIpLixm7UH2rh0QgbekMR391bR7A9SYNLzz6JsREHA6Qty24tbsbsDjMuw8eBlYwacaLwHOpG9QUSLFl3OwGTbwyEuLo6amho6OzsJhUK8+eabdHd3R6XqD9ddk6zVoBUE/LJMi0VN1q+ngiDgkSQeuPczCMJPM8zoDUfhQfYNg0+SKHZ6cQZDOEMhHCEJRzBEpy+I3i8xTqXF5Q/h9AXp7vDgrnGRHmNg0Tk5GK1azGYzl1xyCa+++iqbNm1i5MiR/YKUQCBAY6NitHi85XdRFFm8eDHt7e20tLTw+uuvc/PNN/fjv3hdAbpaPLi6fCRkmrHG9713VCoVCxYs4J133uHLL79kypQp0Yx1Y0wCVYKyyJEkmZ01dgBWFDfz6d4mzjsk8JYlGafdR3ebJ/zjxWn34XcH8XmC+D09v/3eIFJQCQZU8UZCGjfvPLkOna+v+OGWPB1MMpHeFuD9ezZgjFNRLygk5XR3LLNtZtYvK0fbnUzQWM/m7RuJ6RiHQP97Sq0VCUkSXlU7HlMdAW1X9LnRtW2UpOVQFZ+B50szNSUyIVMsWOxYdW2oO9KRAY1ORUyyEbVW7HPfCoJyjhztXpydPnzuIM1V3TRXDc4PValFQkEJl92Hy+6jsbxr0G0jOPeWUd+OACYnJ4eUlBRWrlwZDVi6u7v56quvuOOOOwCFPGW329m2bRuTJk0C4IsvvkCSpGhb24wZM/j1r39NIBCIppRXrFhBQUHBKVu5DBUEQSA/7xds3XYFjY1vk5X5XbaFyZiTrH07JXaEb8bUgqkw4QtY/zCsfQj2f0ir80vkXBUmVxBT9vlw+bNKKas3dBYWXvhzZpd+xhubniFVr0NtmAnA+WNSmV+QxOtbak4Z/+VQLE2NJyjL3FNax1O1rWgEgV/lpnLrnFyCksyfP9nP/604gFolcsf84UP2vn5J4tZ91ex0uInTqPjvxHxu31tNscvLVZ/uoanFSYpVz5vfn8HVyzZRb/fw7o56pAwTHYGeVck+p4fpfoW450aLFK7AljR2s7AoecD3PlaUh8taheFrIy0tjbS0NMrX7QT6EngjWBBn4Q8VsMHuwBuS0IdLO3PzE3lydQVrD7QSCkn8/EAt27vdxKhVvDAmB7NahSTJ3P36TkqbHSRadDx9/WT0vUzdeiMiXmccm9Cvg+Ro0JvIu2LFCg4ePIhWq+Wqq64a1NE7AlEQyNBrqfT4qPX6yQ5zXIyIJJi1tDn91Ha4saUfe2B1uuPqXRVstLsGfd5W5mDyPje5QRUpIREBaACe+7ye9PwYhk9MInd8NhMmTGDHjh188MEHfO9730Ot7hn+GxoakCQJs9l8QmOsTqdj6dKlLFu2jIaGBt549e1oYFS53kX5f9fic/ftzolPN5E9JoFhYxJIzrEiigKjR49m/fr1tLS08OGXG6lT9XT8fWBN5jf+AB0dXhy+nn394f19jFBrcNS7aarsoq3OSXe7JxqUHAu0QRsejRssLizmDGRJRpJkZEnmQJZy7RXWBQgFJNrsrRALqqCBaV4zeAO0EEAvZuA2NBLUdpM330BSbDpdbR66Wjx0t3pwdLpwqBrxWOsIqSO8MIE4bQbZiUVYTXFsCvio1UDL3HjO6hLxBkzsaK4ioO3ifXM33790ApfOzDpiZivgC9HV6sHe7Kar1Y2z04fBrMEcp8cSq8ccp8Mcq0etFfG6AnS3eulu89DV5sHe5qbK7SflkCqdPyTR4fJHs0hfB445gHE6nX2UEquqqti5cydxcXFkZWXxk5/8hAceeID8/HxycnL47W9/S1paGosXLwaIdmLcdtttPPXUUwQCAX70ox9x9dVXRyP0a665hvvuu49bbrmFX/ziF+zdu5d//OMfPPzww0PzqU8xbLaJJCaeS2vrcsor/sY2550ATLL2dBwEQxK76+wATMiKAbUW5v8CCi+A935AS0w1oCJJzIMrXgDV4F+drmAR1+efo4TgvWDQqrhp1slT3j0aXJ+WQECS+VVZPY/VtKARBX6ek8r35w0nJMn89bNS/vLpfjQqgVvn5J7w+0myzN37a1nV4cAgirw8Jpc8o57b0hP46YE6qi0qEg1qXvjuVDLjjNw8axgPfFTCM+urCM5SgpIik54Sl5dip4c2WQlguiUdhSkW9jc5KGl0HO4Qjgll4RLSnOHDyGRB1G6gPNypNXyADEyhSU+KVkOTP8DaTgfnJiiT+KTsWExaFe0uP7/fW8MbHXZE4OlRwxhmUAbhR1aWsby4Ga1K5OnrJ/XzPIqeR38Ib3G4fHQM3Ue9EQlgIr5QAJdeeumA7awDISscwBxq6pgea6TN6aeu083ob1kAc9DjY6NdKVcUmPRYVCr0Mvg6fdQ3OanP0NOVbyHkUZFSolw7LrOIwxMkJSRSf8BO/QE7a18/QGJOElq1ntbWVj5693Nmz5yNKUaHzqju0z59IvwhrytAa5mPXPNU9rnXUhY2iwVwNagQJSXgMMXoMFg0tNc5aa930V7vYvunB9GbNWSNiiM22cSI9Am0tHzGR2VVUBhHst9DyOejzRLDz3dUM/9ggPE+FSNMBkzOEDF2mU8e2dXvmERRwByvx5agx5pgwByrR29SozUoPzqDGq1RjVavRqNTodGp2LM3jvfeew9bjsQNt8yM7qsjEOS3X+4FGf5w6wTivbDi88/YWwptISt12iBXTM8iOz+W5Bwr676S2LJlC03eA5y3SNlPd3c3W7ZsYevW7VFCu06nY/LkyUydOrWPlMBttS3cW97AjmE6/jJFKWt3Pl9CdXU1Ol0bbxU3ctms/pn/Q6HRqUjIMJOQceTuPINZi8GsJTnHil+SuH53FWs6/Tw9KptLwn5Pbn+Qpf/6il3Ndmju5LaCryexcMwBzNatW1mwYEH0/7vvvhuAG2+8keeff56f//znuFwubr/9dux2O7Nnz+bTTz/ts8J65ZVX+NGPfsTChQsRRZElS5bw6KOPRp+32WwsX76cH/7wh0yaNImEhATuvffePlox3zQMz72HtraV7G/fTYsQRC3AWEtPALO/yYE3IGHRq/tMUqHEfA7MnUl7k5KGTZ77xGGDlyiOIHb1deK7GYkEZZl7yxv4e3UzBSY9lyTF8sMFeQRCEo98XsYDH5UwPMl81I7Kg+H+igbeau5ELcAzo4cx0WZClmV2fFUPxhDoVVx/2cgoJ+iqKZk88nkZB0IBAm4vRpXIfXnpXLmrgn1OD5vrlIE+qDZyz6ICbnlhKyWNQ9eyX+ZSJvYii4lJYTKjLMtUhCX08wbIwAiCwPw4C681dXDDnirGWQycn2BjUYKN6cMTWN5s51/tnSAInCPoyAwpE9Qnexp5dKXSJfHHS0czMWvwQci7vwPZL6GK06PNPD7+VO9OJIA5c+Yck6txpkELnQObOu6qtfdzpf4mwe8NcnBvOx2NLjQ6lTKpGtS8LSufabxayy+rROpK2uls6pEj2Fgg8fl4I6vHGimakMQ947MwWrX89bP9PP15JSMCKs6xWgi2eGmt9KDTD8Mfs58de7+ienUQdUgpPXTF7gMB3PUa1r5+AKNVi8mmRatXo9KIqDUiaq0KlUZEpRbxuQI4w2WGyO/uNi+tB7uJcFbNxlyc1goADFoTF94+CVuiwqnQhG0kvK4ANfvaqd7TTs2+drzOAAe+UugCMjLqOAuNJiVjHN/aTGFTNe9OnMcnbhexxQ7O8WjBE8mSCrgFmfThNkaMTCB5mBVbUpi/cYx+PZEyWkNDA8FgMJqtWtHWTUiGkSY9uRYDWKClUxmbdwpmUkdbOPuawuh+Zs+ezbZt26iurmbLli3U1NSwb9++qEheTEwM06ZNY+LEiQNyOJckx3F/RSO7nR72OtyMthgZO3Ys1dXVDFe18V5FGzXt7qhr9VBCCpf814Q1pp6ubeWSpFiCIYkfv7qDXbV2YowaFhR+fQrXxxzAzJ8//7CsakEQ+MMf/sAf/vCHQbeJi4vj1VdfPez7jB07lnXr1h3r4Z22MJlySUu9kg0NygQ4ymzA0OumivhcjM+MiZKl3O4q9uz9MU5nCSAwfPg9mCwFp/zYTwZuz0yizhtgWV0r7zfbo5H9XQvzqe/08Oa2Oj7c1XhCAcwzda08WauUPf5emMXCeGUg/OcX5byxuQ5NroVAvpXPvW7uCTu+WvQarpqSyZNuJShZmhLHFJsJEegIhNhW10Y6kJ+VwrjMGACq2l24/UGM2hOryDqCIZr8Cvcl4kIN0Njlxe0PoRYFsgcZqH6QlUSVx8fmLhe7HB52OTz8uaqJtOF6pPR4EATEeher99az5tNK8pLM1Icn/O/OyuGKyYPzHiRfEOd6pcZvHHv8HT69hf/y8vL6LISOBhHC+6Et8AOZOn4T4PcEqd7bRsW2Vg7uaycU6K/8+s4CCyRpSNrcyZ6ycA5fgE4dlMoBLsrPYHy2hb8dbObJkJNsRzc3WRP42bkF+AISz6yvYlugk79eO5IRQQ3tDWnsqO6kO9CMO7YcS9sYAv4QHrkTBOiqEtlzoO6EPldcmolhY+LJGjWBrcVr2bFzB3kFueQO4FquN2kYMTWFEVNTCIUkmiq6qD9gx9mhcFX0HYU0hbvukh3tJDm6mFru46t8PR9ONnLO5x3MHJHIpAnJPLe/gdf3N1Go1vDBoiw0J2AyGBcXh9FoxO1209jYGA1oPmmzA3B+opIlcTqdUU5nk2ThplF9OTg2m40JEyawbds2Pvroo+jj2dnZTJ8+nYKCgsNyv+K1as5LsPFBq51XGzv4k8VIUVERH330ETF4iRM8vLG1lp8tGvp54U+Vjbzd3IkoyyDLbO92s6vbxWufV7Byfws6tci/b5w84KLqVOFb04X0TUBOzp2UNz4HwChNG9Bz0UX4LxPCq+CWlk8pLvkFoZATjSaO0aMeIS5u1qk+5JOKS5NjWVbXyrpOB0FJRi0KCILAJePTeXNbHevKWqNW8scKSZb5vyqlVfjXualcGe628QVDPL5aKYH+ZkwmD/q72e30sKnLFfVtmjcxlcdL/SDLLNQbMahEhht1lLl9tKkF0oF5Y3JIMCtk6FaHj9ImR/S7O15EykdJWjU2Tc+tGSkfZccbBx2UR5j0vD8xn1Z/gOVt3XzS1sW6TgcNgSCoBEYb9VwzPIZVPjUbK9qj+5ydl8CvLigccJ8AwS4f7c/tI9DkQtCIGCcfP9fHYDAwatQouru7WbJkyREl8Q9F5iABTG8xu9MRfm9QyVR0+HDaFUJly0EHtcUdhII9QYs10UBGQSyhgITPE6QtEKQ2Ubn2p/pVjJqbTmZRLIE4Lec9+SVqUeCfZ+VgNajxyzKP1rTwvwfq0IkCS1Pj+fWFRfiCEi9tOsjPPy7msaUTuXBBIeM7k3niiSfwYeecW2KIsyTx4mvrUIkq5l0yHq8zhLvbj7vLT8AXJOiXCAUlgv4QoYBEMCChM6oxxegwx+gw9fpJzrH2IeWmDL+IEQUjjqqzSaUSSR8RS/qInvvIL43l96t3ApDc3UFCQjwv3zyZOZv30wS8N0bPH64qIt6s4+dj4lj+93b2Nzn49/oqvj/v+Hl0giCQmZlJaWkptbW1ZGZm4gqFosapF4a7jyLO2R2SAR8aFo3q32Qye/Zs9uzZQzAYZPTo0UyfPv2Y2uevSY3jg1Y77zR3cu/wNAwGAwUFBRQXF5OrauOtbXX89JwRqIZQWf2Zulb+WaMEZvNKt1Mbm0R5ciY/31JJ6eZaRAEeXTqBSdlHr0Z+MnAmgDmF0OmSqNHOAD8kOd5FkqYiigqhd3s0A2PiQNkD1NYqgY7NNpnRo/+BXneadl+dAMZaDNGOoB0Od1RQbfKwWHRqkRaHjwPNzuNq+d7n9NAZDGFWidyR2ZPF2X7QjjcgkWjRcfv0YZQfqOOlhnaW1bZGA5gPHcrkLrZ4+bSjlrOy4hhpNlDm9uG1GMAOw9KUfRalWml1tFLSeOIBTITAm2/sy0OJlI8G4r8cikSthmvT4rk2LR5XMMSqDgfFLg83pyeQqNXw3RnD6PIEWF3aQnWbm5tmDUM9SFDkr3fS9sI+pG4/ollDwo2j0Jxgt8EVV1xx3K89Ygam8/TIwMiyTHN1N3tW13FwT3s/0mpvxCQbGT4xkbxJScSn922Rf7mhHbm0lrEWAz/85fjo40+uVsoyM4bHYzMq48cvc1PxSjLL6lq5e38telHk0uRY7vvOKHzBEG9sreOu13agVYucMzKZBQsWsHz5cj5fuYKZMxVuRkZmBhPOHjak50KlUh1TmfBQ7HV4CAoier8Pm8dFcu4wLBo115mt/K2jg1COmWYk4oF4s45fXVDEPW/t5pHPD3DhmFQy446/tNI7gAFY1e7AK8lk67UUhX3KIu3TjZKVsRm2AbsRY2Nj+fGPf4woikdl/noo5sZZSNdpqPcF+KSti0uTYxk7dizFxcXkqTvY1u1h7YHWISvlfNhi57dlSsZ1alUxBc21xAX9lCdnskf2o1EL3HfhyAGDtVONMwHMKYQ3JFEWUMoY2f4vaWh4g5SU71Dfsp2R1ve4MKMGVWsDtQGldTcr6zaG5/5PNMj5tkElCMyJtfBBq53VHd3RAEavUTE1J451ZW2sK2s9rgBmXVjme3qMOeqyDLAxbB45c3g8giBwW0YiLzW082lbF1VuHzaNijebFKE19UEn7zns3LOokIww78hhVoKICJ+jKNXC2gOtQ8KDKQu3UPcuH0FPBuZYU7UmtYqLkmK4iJg+j9sMGi4Z39+ksTc8Je10/Gc/sl9CnWQk4aZRqOMO3yl0shHVgvEF8EkSunAGJzO2l53AABk7vyTxUFUTixJsfVSHXb4gD684wGUTM/qpXh8Kl93H1k+qqdzRSkyykYzCWDIK40gaZkEVDgCDgRDlW1vYs7qOloN9id1avQpznF7JWMTqsCUaGDYmgbg006AZxo9b7QBckNCXmPzZPiWzeG6vCUQQBO7LS8MrSbzY0M6PSw4yzKBjgtXIg5eNxReUeH9nAz96dTsf/Hg206ZNY/fu3TQ1NbFqleLVdtLUy08AW8Pt07mSH4GeVmyxxYvY7kFKNvCz0lo+mJiPShC4fFIG72yvZ2NlO79+by8v3DzluEuevQXtZFnmkzalpfj8RFt0n70DmKWHmdAPZx9yJKgEgatS4/h7dTOvNrZzaXIseXl5ilqyx0OK2M1rW2qGJIDZZHfyw5KDyMCohiom1Bxg3rx51DklvnB102myMmNOBtcPYDPydeBMAHMKscfpISBDnCpAYrCFA2X3U3rgd4DMkrD0STAAanUMI4v+QmLi2Yfd37cB8+OUAGZNh4N7chTtIL/fz7QkmXVlMuvK2o6rGylibjgntu+k/2WF0kkza7ii7TDCpOesOAtfdDh4pq6VJK0GjyQz2mzAZDayq9POKxuqqZX8oId2kxW9Xh+VWh+Zqkx8QxHARDMwpoEzMHlJZnwHu+l86wDqRCOGUfHoC+NQmYY2wHVuaMD+QQXIoMuLIf7aIkTD1z9UJGjUGEQBjyTT4A2QEw70Iqtetz9Eh8tPvLlvAPh2cyf/rGlhfaeTTyf3CCW++lUNz6yv4kCLkxe/O7DBpsfpZ/unB9mzpj7KUXF3+2kos7P5gyo0OhXpI2KwxBso29KM16VwmES1QP7kZEbNTiM+3Yz2GM+fIxiKeu2cHy5XgOJVtbPWDsC5I/uW8wRB4M8jMmgPBPmotYsfFFfz+eQCTGoV/3fFODpcftaVtXHnf3bw3g9ncfHFF/PMM89EOY3HK2B3MrE17J92YX4OC1MXR7vydtR0oqmxQ7KB7d1uXmpo56b0BARB4I+Xjua8f6xj7YFWPtrTyEVjj0/tOC0tDZVKhdPpZE9JCSvalUxaJKC02+10dHQgydAsWVg0amikFAbC1SlxPFzdzLpOJwc9PrINOkaOHMm2bdvIFTtYWdJCq8N3QvpeB1xebtyjiFwO72hmVtkuRo8aRUL+BH68bAN56U1sGTGKcm3wuEv7Q43Tt1XlW4itYQG7KTFxmIw5yHIAkPHJKWxunEiJ8yYmTXyd2bPWf6uCl+bmZl555RXq6vqTA+eGvXx2ONx0BZQB4v3336dt26dMU9fwVVUbvuCxSVsHJJlN4XM9O7Zn5eP0BdkVHvxnDI+PPv79cInpP00d/LteIf1+LzOR2+bkkIbAnNVNXLFJKfHZjRbMvcioRb0CGOkoFTEHQ4QDc2gJqbwlLGJnM9Dxn/0EWz14i9vpfPMAjQ9souXp3TjW1RNsPzEOiCzJ2D+qxP5fJXgxTk4m4eZRp0XwAsoEnREuI/VupdZrVCRblYF7IB7M9vAkuN/lIdSrAWFHrfKdDhR8+twBvvpvJS/9eiM7P68lFJBIHW7jgh+MZd41BQyfmITOpCbgC1G9p509q+vwugKYY3VMX5zLTQ/O4uybRpKaF3PMwQvAyvZu/LJMnlHHiF4ZuRXFSvZlYlYMydb+GTFREPi/gkzSdRqqPH5+HS4FqFUi/3flOOJNWvY3OXjo01LS09Oj2ltwdAaOpxoR0c9p8TbGjx+PRqOJCtgJPolbE5V78S+VjXSEx4/cRDM/COtI/fGjEtz+Y3OFjkCj0USd4x9ftZ7uoESSVs3kcBYvkn1pk01kJVrJSzp56uZZBh1zw2PZa2GDx4jDfa6mE1kK8s72EyNf31tWT1cwRIariwX7viIzPZ1LLrmE376/D6dfJtlvQB0KUofImpbBXe5PJU6Pken/E2wLp0Mn28xMLPgPTlcpFnMhN79Yzpfl7fzx0tHExBy5p/+bhlWrVlFWVkZHRwd33HFHHwGtTL2WPKOOcrePL+1ORnu62bdvHwBF6hacAS3bqjuZmZcw2O77YafDjTskEadRRWvVAFuqOghKMllxxj618Tmx5qjWizukDFKXJMUg6/2kiGaSJYEkH5j9QZxaNb6EnpVWboIJrVrE5Q9R2+kmO/7Ya9ygBF3VnggHpmfC6nIHol5GKdva8Nt9qOL0GCck4S1uJ9Dowl/Vhb+qi66PKjFNSyHmouEImmNbm8ghic63ynDvUIh71kXDsMzPOC1WWb2RqddS5vYNyINp7vZR2+mOdodFsD1833klmUq3L5rhiqi4tjp8tDl9JJh1yJLMvvUNbHqvIspdScyyMO2SXLJGxkXPx+i56ciSTFudk9r9HXQ1u8NibPHH3LI7ED6OlCsSbH2+g8/2KS3Gh+MfxGjUPFaUzZKd5bzW1MGCeAuXJMWSZNHz0OVjueWFrTz7ZRVzRySwYMEC2tvbo103pxMafX7qfQFEYEIvyYnyVicOXxCjVsU9hel84XZT4vLyl8pG/lKglH2+P284b22ro67TwxOrKo67S+fss8+mqamJtWplobLQZkQcoHx0KvggS1PjWNPp4NXGdu4elkJmZiYxMTHY7XYyRTv/2VzDbXNyB5X8PxxKnB5WdzoQZJm5e78i3mLh6quvZneDk521drRqkT/ecD71n69mR1wqj+wrY37ytCPv+CTjTAbmFGJbeCU4yWpCp0skPm42KnU8u2qVwWpC5jdLZfho4Ha7OXDgAADt7e1s2bKl3zbzwiuL1R0Oli9fDkB8vJIhmaKpY9Wmbcf0nuvC5aOZMeboYAOwoRf/pTcEQeD2zJ4Wz5vTE1D7JewvFJMiCdQj8QF+0pzK99fUqSbQovytVomMSFbKVCdSRqry+AjKYFKJpOp6SkLl4fLRBUYD/u0tIEDc5fnYzskm+a6JpPx8CraLc9Hl2kAA11dNtDy585iyMZI/RPuLxUrwIkLsFSOwLjgxQbOThaywAF+/TqTYgTuRXKEQ+1095pzFLuX5lm4vDb2cx/c3OuhscvHu37ez5tVSfO4gsakmzvveaK745WSyR8X3Ox+CKJCYZWHiudksuL6I3PGJQxK8eEMSK9uVaynSrgtgd/vZVKmUQI80Yc6MNXNXthJo31NaGz1fC4uSuWGGskj62Zu7cQTg2muv5fzzzz/h4x5qbO1S7rGRZgMmdY869PaDyup/bIYNvVrFH/OVzNFLDe3sdSiv0WtU/OZCxVBx2dpKDrYPrmR8OKhUKi67/HIOJiqcMcPeHYRCIWRZprLy1AYwFyTaSNSqafYH+bStC1EUGTNmDAAFmk6q292sL287wl4GxlNhuYmc1gYSpCBLly7FYrHwzDrlM146Pp1km4E78pQAcbOsobrTfuIf6gRxJoA5RWjw+mn0BVAJMM7aw1Qva3HgDK8mTqXB4qlCRLQpknVZvXo1LlffwWReuIy0ormduro6NBoNN954Iwm5owHwlW+KtiseDSLcgTmxfc/nl+XK4D9QNuey5FhyDFriNWquT4ql7YV9BBpdCCYNfzQHeCVGIBFl8mtWG2h5bAfOrxqRZZmisDVD8Qko8kZMHPOMuj4TZUWLEwvwI58S1JhnpaPLjYk+r47TY5mVTuLtY0m4eTSiSU2gwUXzYzvw7D3yYBZyBWh7Zg/e0k4EjUj8DaMwTTp5tfwTRaSVusbTV9d8MC2YPQ4PoV6VvRKncp4jPBIAUYa9K2p47YHNNJZ3odapmH1lPlf/dirDJySd8kBubacDV0giVadhfK/Mw8qSFoKSTEGyhWEJR870/c+wFCZajXQHJX5cfDBaPvvVBUWMSDbT5vTxi7d2nxIX+ONBhMDbW7Ecejo2I8KLM2PNXJIUgwT8uqw++nkWjUpmTn4C/pDE/R+WHPdxlARkXBot2mAAVelePv30U9ra2nA6HYRkAZU5gbEZJ18BWiuKXJeqLL6eq1fu7UgZKVWwoyPAS5sOHvN+m30B3m5WylLj6sq59NJLSUlJ4WC7i8/CJctb5ygK7hePHUWGx4Ekivx1654T/kwnijMBzClChIw20mTApOpZTUT0X8ZlxAxpH//pgt27dwOwYMECUlJS8Pl8fPHFF322mRljRi1AYwi69EZmzJiB1WrlisUXUh2KRUTmP/95rZ8J6EDwhKQo12h2LwJvp8tPcThDMiM3vt/rdKLIiskFfDmlEN6qwF/VjaBTkXjLaF7+2Tw+/ckc4tx2ABriLcgBCfu75bS/WMz4GGUyOZEMTESBtx//pdXJT9BjDYE60YBt0eAlRv2IWJLunIg224rsDdH+cgn2DyuRQ/0F0gCCdh+tT+/CX+NAMKhJuHUMhsKvV9fhSBislTqiBVN7SAYmwn+JDHS7O5w4OrzsOtCOWYLMoMgNDh2h3XakoEzWqHiW3juVcWdlHlcqfigQ6XY5L8HWJ4MY6T46WrKoRhR4YmQ2JpXIpi4Xjx5Uyk96jYp/XD0BrVpk5f6W45r0TgUi/JfJtr7B2vbwmNlbOfre4WkYRJGvuly826I8LwgCv7t4JGpR4POSZlaXHnn8GAiRct5csw6VLLNlyxY++OADAJolM+eMPjqX7aHAdWnxiMAGu5NSl5fExMSwcbJMkbqFlSVN1NuPjQv3bF0rQRmSu9qZm5YUbXt/dn0VsgzzCxLJT1YWg4IgcGO6sgD8LKiivfPr5cKcCWBOESL8l4mHrCYiCrwTsmJO9SGddHR0dFBbW4sgCIwZMyaapt6+fTtNTU3R7cxqFQWCMsm2pmQya5Yi2JdsNdAcN5ZmyYzP5+WVV16hu/vwQcLWLhd+WSZVpyHX0MMliaTeRySbB2Xqm1Qi0geViuePWiDhxpFo08xY9RpMWhWGFsWU7qBFi/WCYaAS8JZ0MGdtM5eiYX/DCQQwgxB4teVdLEKLDMRdWYAwiNFiBGqbjsTbx2Ceo6S8nevraXlqN/YPKxWC7sdVdH1SRden1bQ+uZNgiweVVUvS98eiy/56TD6PBZkDkHihJwNTd4gWzNYOJRuX26Bsv62pmxd/tQHjZ83c0W3gaqeOREnEp4JzbhnJRT8a288d+VQiKMl8Fp4wL+xVPvL4Q6wtU9L8i0YffblimEHHgyOUEsvfqpuiwX1RqpX/PU8RMPzjRyWUNg2dn9dQwCdJ7HYoE3Fv09sudyAqK9B7zEzXa7krWyHj/6G8AVeY+J+XZOGmmcOUxz8oxh8cOJgfDLIs80mr8n1cPTyLs846CyDqHdUkWTn3JHYfHYp0vZbzwl1Qz4ezMOPGjQNgvLqBizT7eOnj9VGrgiPBHZJ4rlYJ7CY0VLJo0SJAKVe+sVUhBd92SBforaNGYAwFceqNPLmhPyXgVOJMAHOKcCyriW8L9uxRUow5OTlYrVays7MZNWoUsizz6aefRlO9Xq8X20FFnMuVW9DHE2ROQQpf+PORtGa6urp46aWXWLNmDVu3bqWkpISamhra29sJBJT21Qj/ZXZsX1GwL6P8F2X1IAclgu0efJV2XDta6F5VS/vLJbi3NoMA8UuL+pRquru7sTm7ESUJhyTTNTWZ5DsnoM2yIAYk/gcDv7QLdNYc2X5+IEQCmDxTz2cPOf1c0KhMvN3j44/ag0hQicRcmEv89UUIehWBWgfO9fU419XjXFuHY00djtW1hLr8qBMNJP5gHJrk4yMfn2pkhbVgWvxBPL0yS5nRAMaDJMmEQhK7VtbyZb0dgAmVSoary6TCrxcIIRNCRlAJ7NUEed7qI3fiqS8XHYrNXS46AiFi1Sqm23oyiGsOtOINSGTEGqKt+0eLK5JjuTQphpAMPy45iBS+726eNYx5IxLxBSXueau/CeLXib0OD35ZJk6jIsfQ43Yc6RzLjjf2a5f/fmYS2XotTf4AD4ezTQB3nZ1PgllHZZuL576sOqbjKHZ5Oej1oxcFFsRbmDNnTrSVG8CpjWPqsFObtbwpnAF5s6kDZzDE1KlTmTdvHqJaQ5zowXvgS5588kn27t17xEDmP3UtdMtg9bi4rnB41O7jla9q8ARCFKVa+3EGDWoVl8Yp1+Z/PRJtbcfHuxkKnAlgTgF8ksQe5wCrCU/PamL8tywDI8syu3Ypg2KkTgtwzjnnoFarqa6upqREqUuvX7+elFal3XMvaoK92pFn5yXgQ816ijCZTLS2trJq1So+/PBDXn/9dZ599lkee+wx/vrXv9LW1sZ6e1giP6bvZL+hop00BC52CzT/Yzv1v/mSpr9upXXZHjpfL6X7s2q8+5QsTexl+RhG9b1pOzs7UckS8T5lhV/i9KBJNpH4/XHEfGc4HmTGosb51B66VhxEPoaVnizL/VR4ZVmm/e0yrLJAOSESFg076v1FYBiVQPKdE7Gek41lfgbmeRmY56Zjnp2OeVYa1rOzSPz+ONQxX69A3bEgVq3CFCbK1vXKwqTG6BEF8Acl9m5r4vX7N/PpB+V0GUUEWebOK0eSHiZHZ901ir/HeHkyMcBtj85jbaxEtyxR1XZ8RM+hxMdhr51zEqx9BBiXR8tHKcccZAmCwF8KMjGqRKo8fkrCpGZBEPjrFWMRBdhd10XDMZYeTiZ6+C99hf52HGbBp1eJ3J+vZB6frm2lIrwosOg1/OI8pQvp0ZVlNHd7+712METEBOfHWTCpVIrVySWXgDWF+pCVSUW5gypZnyzMiTWTZ9ThDEm81dyJKIosWLCAn/zkJ5SJmfhlFa2trbz11ls8+eST0W6pQyHJMo9VhhV32+uZO3s2oNxDL2yoBuC2OTkDXm8/LFQ4MTVxyawpKT0Jn/LocCaAOQXY5/Dgk/qvJiKaJFlxRhLMA5c1vqmor6+no6MDjUbTR0o8JiYmKl2+fPly2tvb2bRpEwkOOxYBHCGJnY6eMsDUnDi0apHKbjh78dXMmTOHCRMmMGLECDIyMoiNjUWtVuP3+9m0ew87w5yHWWH+S7DLR8OKav63Fd7AQvJOpfUYALWAOl6PLteGcWISlgWZJNw6GtOU/in6znCtN1NWWmv3hQNSQRQwz0zjyTw9XxJAkGQcK2tofXo3kv/o9GsafQFcIQmVAFkqFa4tTbQ8sQt/SQcBZP6u9ZM0gET50UAdp8e6MAvbeTnEnJ9DzAW5xFyUS8zFw7GenT3kIngnG4IgDOiJJAckpqsMXO7Usu7fJXQ2uWlNVwKzArOB/IJ4iszKOVzVqGTJxmTY0KjEKHm+eAhdxY8HvcsVFyTERB8PhCQ+Lzly+/ThYFWrmBbO/m4MB/kASRY9Y9KVksRXVe3Hte+TgUgH0mTroRnrCIE3ZsDXnRNv5aw4CwFZ5t6yhujjSyZmMD4zBpc/xF8+2X9UxyDLMh+1RtrZe95PrdbwmS+fFYECFo0+PpG8E4EgCNyYpmRhnq9vi2ayrWYTU2fO5S3fWFrMw9Hr9bS2tvLiiy+ybt26ftmYdw820IQKbcDPT8aPRKtV7qv/7mqgxeEj2aobVAQw16hjhkkHgkBN5vF7Tp0ozgQwpwBbo/yXvquJI92M32REyLuFhYX9bOJnz56NxWLBbrfz7LPPEgwGycnOZn64thsxTIOwrUA4RbujOcjChQu55JJLuOaaa7j11lu56667+M53vgPAitpGJCDHoCVDr8X+3wqaHtyMtLKWkaiQUJRlYy/LJ/VX00i/fxYp90wh8fax2Jbk8W7jap5b8Ro+X98OF1D4PADD1cr3FwlgIkjPsvELPHw63IBgUOOvdShS/EchbhfJvmQFBdr/vIXOt8sI1DqQBXgUL6qUweXm/39EhMhb0eVh/6ZGPnpiN8/+bD2z2iAnqAIRxp+die07irJshHc2KhzA7AkHuRPCejERMcL9XzMPZLfTQ70vgEEUo515oPC3ur1B4k1aJmUff6k54vW1odPZ5/FpYVL7poqO4973UCPCGZxk6+EMSpIc7R4bzHdMEATuz09HIwis7Ohm/ub9/KO6mVqfn/u+MwpBgHd21HPRY+v419pKGrsGzzpt6XKx3+VFLwqcm9BTtvtifwuNXV6sejWz849en2oocWVKLAZRZL/LGxXtBLh6aiaSqOHjtjjOv/oWxo0bhyzLrFy5ktdffx2Pp+fz/r1UIW/PdNuZNFopi8myzDPrKgG4aWYOWvXgIcKvCrJ4aUwOd2Z/fV2LZwKYU4CI/svkfgReOzD4zfhNRSgUYu/evUDf8lEEWq2Wc845ByDaUn3uuecyP04ZJFY1dtL+2n58lcrqZ054kBhM4yA/Px9RFCkWlYltTqwFT0k7zg3KCqzOrOL/8PDOjHgSbx2DaWoKKqu2T1CwZ88eqquraW5uZsOGDf3eI5KBKQpzVIqdfdPQkUnwvz4vCTeNArVC8LV/UHHYNtVAs4vtyxX+T1abH9kbQhWnx3reMD6anci7BMg7ChPHbxNkWcbrCtBa66ByZyv71tWz/bODbHi7nC9eKkEqVwKNTz6rYuXzJVTvbiMUlPAbRDbpAsiLUph1eT67w4P1hPB9FxE1rAspWbSI4F1hJID5mjMwkezLWfEWDL3KEj3eR8kn1Kk4MxzAbOpyRnkwANNzlQXC6ZKBafD6aRhMwM4bxKBRUXgYyYnhRj2/y0tDKwjsd3l5sKqRaZtK+FVLC7PPzkHUq9hb380fPy5h5p+/4MqnN/LSpoN0uPoSw5fVKaTpJcmxxPZyh382zKNZOi0L/RFI9ScLNo2aJcnKvBFpqQZItuqjWbrXdzSxePFiLr74YlQqFaWlpSxbtozGxkY+LC6lQmtElCR+NWVMdCxcX97G/iYHRq2Ka6Ye3lpiis3EOQk2VF/j4uqMEu8pQIT535v/0nc1EfM1HNXxo7y8nOXLlzNy5EjmzZvXLztQXl6O2+3GZDKRmzuwj9GYMWPYvHkzdXV1jB49mvT0dOaGSwI7vV5a9jrx7GzFMD6RuRMTeRBlJeoPSv1WBQaDgdzcXF63KWJ0My1G7C8qQYF5bjp376qigQAvjUxkIIRCIVavXh39f8OGDUyZMgWzuVcbdjiAGR9jhVY/VR4frlAo2hIfCWBKmxyoMszEXVlAx6v7cW1sRB1nwDKnv3mia0cL9nfKKM/TAFryjToSbslDNzwGQRTY958dAAw/RhPHbxp87gC7V9XRXNWNo8OLo91LwDd4+U0YoYNYE3ajQFyaieETEhk+MYmXiutZt7Kc9GAQSZaj5cSJ4ftuZDgD49IK6IDxkQxMeDL8ujMwy3up70YgyzJflChdIueOPDGxtHEWIwZRpCMQotTljZbUJg+LQxSgut1NU5eXFNvXy4mKSk4cRsDuSLyTWzMSuTw5lo9bu3i3pZMvO53KQlIE04I0fqAxsXV3C5urO9hcpfz83/JS3v3BLHISTNR6/XwcDihvzegZN0oau9lQ0Y5KFLjhazY0vDkjgZcb2/m41U6zL0BymON13fRsPtrTyHs76vnf8wuZNGkSqampvPHGG3R2dvLvf/+blUWTIS6FabKPsemp0X3+Kyxcd+XkzKjT+emMMxmYIYDkDxHs8BLs7E8Oa/IFqPcFEOhZCQJUtrno8gTQqcXo5PdNwPbt23nllVdoaWlh9erVfPTRR/1qq5Hy0ejRo1GpBl6hCILAFVdcwfz587ngggsASBdUDPPKhASB7Rl6EMCzsxXry6XcojXg84eiZbdDkVZQRLtZGfjH7rQTsvtQxejoHJ9AQ5cXrUpkcvbA3QI7duzAbrdjMplITU0lEAiwZs2aPttEApjhifEkadXIwP5eWZjsOCNGrQpfUKK63YVxbCK2CxSiW9fHlX1E5eSAROc7ZXS+XoockKhJVDJHY6ZkoM+PRQivsqMu1N/SDEwwEGLH8hpe+u1GNn9QxcG97XQ0uKLBi8GiISnbwrAx8RROT2Hc2ZlM+04usyYqE7lqhJWl905j6sW5xKebyYhTApXaTjflbh+OkIRBFCkIE6NzDTo0AqAWiUswkBqeqEeEA5jGLi92t5+vA23+IMVhcm3v8tHBdjcNXV40KoHpA+gXHQs0osDUMA9mQy8ejFWvYVTa6cOD2dZ1BAG7oyyjxWjUXJMWz5vj89g5cxQP5KczwqjHJUlsNsi8/r3pbPjfs/jVBYUMizdidwf48ydKY8Gzda1IwNxYczTQA6JdTOeNTiH9OHlpQ4VRZgNTbSaCMrzc0PO9Tc+NIz/JjNsf4t3tCkk3LS2N22+/nfz8fDpVGvbHKi3nv57Qw08sbXKw9kArogDfnZVzaj/MceJMBuYY4Vhbh6+yi5DTj+QMILkCyIGeCVxfFIft/Bw0ScrNF6nlFpr0mNW9Bex6VhOaU8xiPx7IssyqVatYu3YtoDjX1tTUsHXrVnw+H4sXL0alUuH1eiktVVjpEX2CwWCz2Zg/f350/51vH2CaP0B1tpZds5O44qI47O9X4K91cDMa5iNyYHP9gAN5W3I6OJuId3bh39iKFj0xi/N4p7ZHZ8eg7R9MBQKB6GeaM2cOycnJvPDCC2zbto3p06cTHx+Px+OJ1o5jYmIYZXbS0uFgn9PDpPCEIIoCBSkWdtTYKW50kJdkwTwnnWCHF9emRtpfKyXxdi0qk4b2V0oINLhAAMtZWVQbHeAPkt+rhVqSZCp7uVB/myCFJPZvamLLh1U4OxX+T2yKkTHzM7AlGbDE6bHE6VEP8H0B2Fxefr95P/t9flr9ARK1ykoxcp521NjZEJbiH2cxRLt51KJAoizSgERGdo/HkFWvISPWQF2nh/1NjhMOFI4HEWJtoUkf/TwAG8P6RRMyYwe8fo8VM2JMrOl0sMHu5JZemYVpOXHsqe9iU2U7l4zvny08ldjaPbDkxOE6kI6EJJ2GWzMSWZRgY/ZXJay3O/m8vZtzEmzcPnc4CwqSWPTIWj7b18yaijZeaVTO+229zlGb08d7O5Wy9Okywd+UnsDmLhcvNbRzZ3YyGlFAEASun5HNve/v46VNB7lhRjaCIGA0Glm6dCkr1m5FlkUmaEUmx8dE9/XE6nJAIYpnxZ9evliD4fSfOU8z+BucePd3EKhzErL7eoIXtQgCeEs6aH5kG53vlhFy+NkWYdMfejMegYx2OiEYDPLuu+/2mehvvvlmlixZgiiK7Nmzh9dff51AIEBJSQnBYJCEhISwQuTRwbm+Ac/uNqZ1KKvvtU4X2gwLiXeMI3ZJPgGtSA4qztrZhf2Tqn7k2K0eRQcmzd5KtdCCYUwChsI4NkTsA4YPTLbbvn073d3dWCwWJk2aRE5ODvn5+UiSxMqVK4Ge7IvJZEKn00VLEYcSeXs7U4OSZYq5eDj6wjgISrQ/v4/mx3YQaHAhmtQk3Dwa5qfTEnbLzeslYldv9+ALSmhVIhmxX+9Kb6jg9wYp/aqJ1+7fzKqX9uPs9GGO1XHWDYVcfe80xszPIGtkPLEppkGDF4B8k55JViMBWebF+p6V57gMW3Tl+U6VkvGacMgqXuMOZ3cS+p7TwpSvlwfzZTiAmRXTN1jdWKF8vunDhyaoivBgNtqdfbhZkaDtq8qvl8jrkyT2hAXsJh8iOVE2gIDdsSJTr40GJX+oaIhKNuQnW7g6zPn4+VcVdAclcg06Fsb3ZMdf/aoGf1BiXGbMadN4cWGijQSNmiZ/ICqACHDphHRMWhXlLc5oEAxQ5fXzOUrG91dFPUHYrlo77+9sQBDghwvyTt0HOEGcCWCOEaZJycQuySf+hpEk/mAcKfdMJu2+maTfP5Pkn05CPzIeJMVUr+mvW9hcowwI/RV47UBPJ8TpCq9XUcDdvXs3giBw8cUXs3Dhwqi67tVXX41arebAgQO8/PLLbN++HVDIu0fbOeOrtNP1icJ8P2tqBmoBqj1+qj0+BFHANCUFww/H8S5Ket+5po625/chuQPRfUT8j9LtbVSr24i5OBdJkqM376y8/hOA3++PBmVz585Fo1FWvgsXLgSguLiYurq6aAATG6sEm5FulsGIvL0tBQSVQNzSQjTpZiR3ENkbQpttJfnOiehHxEY7kFK0Gqy9MnSR8lFOgumU60wMJXweJWj5+MndPHvPej5/rpjOJjc6k5qZS/K49g/TKZqZdsyy/RFewgsNbfjDJUxBELhp1jAAdoVb8Scc0obrbFUe9xn6ntORqUrZpuQE/KxOBF+GBRhn9bK/kOWe63cg+4vjwXirEYMoKDwYd8/1OyUnDkFQStstx6CTMtTY00vAblgvyYkIXzA7/sQlJ+7MTiZOo6LM7YtmWgB+evYIjFoVNVblPrwlIyFq5eAPSlHLhe/OGnbadAXqRJFr05Rr46/VTfjC94JFr+HSiUom7W+flRIKB2r3VzQQlOHseCtzwqVKWZb540dK6ezSCemMTj/5vk5DhW/uyPg1QZ8fq0yoI+PRZVlRxxsQdYrAkSbJSMINI0m8fSyaTAuBgMTekDLJ5qyox/5xFe4dLThqu6kIEwZPZwE7t9vNs88+S1VVFRqNhmuuuYZJkyb12WbEiBFcd911aLVaDh48SG1tLUDUJfVICHX5aH91P0hgnJBE8qyM6Mor0pUBkJJs5sNkDb/DTUgl4DvQSfPjOwk0u6j3+qn0+BBlmVR7G01CJx4xwP4mBx0uP0atirEZMf3ee8uWLbhcLmJiYpgwYULPe6WkRMtfn3/+ebSFOqJSOdKsZEqKXZ4+3Rw9k2DfVbyoU5Fw4yj0I+OxLMwi8fYxqGzKILy1V4mxNyL1/hHfEINPWZJxdfloruqmfFsLO5bX8NHju3j2nnV8/lwxVbvaCAUkrIkGplw4jOsfmMmEc7JQH2cXx4WJNpK1alr8QT5s7bvytBjV+AzKfnsvHFodPrrDLuKNh/C2op1ITac+A9PsC1Dm9iHQ0+oMUNHqpNXhQ6sWh4zorxXFaDZ4o72n/dZm0EQVfjdVfX1ZmEjDw+RDJSfCBN6hWPBZ1Sp+NkzhUT1U1YQjbDuQaNFxzvxsZJMaMSixuBeZ+qM9DbSGtVEuGHP0meVTgdszEknQqCl1eflbVY9Fyx3z8zDr1GyvsfPMuko2dDr5tK0blaB4R0Xw2b5mNld3oNeI3LOo4Ov4CMeNMxyYkwBdro2kH4xjx45GvF0tGIMyqcVdOOkZaD/FTIVKJqbRjWzVnzYRfW9s2LCBlpYWzGYz11xzDWlpA4saDRs2jJtuuomXX34Zt9tNVlZWNFtxOMhBifZXSpCcATSpJmIuzUMQBJakxLKpy8Wyula+m5GATlTi7EsmpPHQp6XI1hB/lA2E2r20PL6TVd9RvF5GdklkCGZasVNaWsoOt3IMU4bF9etc8vl8rF+/HoB58+ZF3bIjWLBgAXv37qW6ujrqvxT5THkGPTpRwBWSqPH6GRb2XCoIlyGau310uPzEmXpWkCqrloQbRvY7B5+1Kfs+K75voLKiWBEuW1AwcOfU1w1Jkqk/0EnppiaaKrpwdvoIDaI+HJNsJG9SEsMnJhKfbh6Sa10rityYnsBDVU08U9fKZeGWUqNWzdwp6bwtBtEG5aj6LihpctGhlOtqfH5cwVC0yyXSllva7CAkyafUWDVCqB1tNvRp142UjyZnxw5pu+6MGDPrOp1s6HRyc3pPaXVaTjz7GrrZVNnOd8adeoE2ICpieWjGenedHRi6kvv1aQn8u66NCo+Pxw4286vwhF4Xo4EuH0Kti7e+quV784YjyzL/Xq+Qd2+YMey04yzGa9U8VJDBd/dW83hNC+cl2JhkM5EeY+C3FxXxi7f38LcVB0jVKtne61LjGRFeMPmDUpS4fNucXFJt36xy9en1TXyLIAgC+1OViW2s2UD8pXmYpqeizbYSVAtoESgKibQ/X0zrsj34ar5eDYpD4fV62bJFMeq68MILBw1eIkhLS+O73/0u48ePjxqCHQ4hV4C2F4sVJ2S9mvjrihDDvIcrU+JI0Wpo9AV4s6mn6+i66dlY9Gq+6HSyZ2Eaulwbsl9i1X6lzXRKR5BRE5TMT0lJCRsqBi8fbdq0CY/HQ3x8/IBaNTExMUybNg3oEbGLBDBqUYh2tvTmwZh1arLD5LejcabuDAT5qkuZvBb1Wu3VdrjZ3+RAJQqcVZh0xP2cSnQ2udj4XgUv/XoD/31kJ6Wbmuhq9RAKSggCmGN1pOTayJ+cxNSLc7j63qlc8/tpTPtOLgkZliEN1K9Pi0crCGzvdrO9l5hXVq7yPQU7vFS09jy+s9aOEJAwhDu097t6dZHFm9BrRLwBiYPtp9ZS4Mtw+XNm7CH8lyEuH0UwOA8mrAdT+fV1IkVKf+N7BTCyLLOnvkc9eSigEQV+Gw5altW1Uuf1U+L08GWXEwFQ1bj456pyOlx+th7sZG99Nzq1eERtlK8LFyTGsCQ5Fgm4a39N1CfsysmZLChIxJuo44DXj1kl8rOcnnb8lzcdpLrdTYJZx/fmfX2KuseLMxmYk4iIDsWERAvmvJ60452vbmfr7ib+mJ1Mfr0Hf1UXrU/swjAqHuuiYdEOpq8Tke6ihIQECgqOLq2YkJDA4sWLj7id72A3Ha+WEOryg1okfmkB6l4OwDpR5I6sRH5X3sA/a5q5OiUOtShg1Wu4aeYwHvuinEc3VfHfO2ax59MKVuiVCWBBaiyjpw5n9ZZ1VFRW8qXHAqiZO6JvFsPj8UTF6ubPnz9oq/fs2bPZvn07Xq8y0fXOKo00G9jt9LDP6eHCxJjo40UpVg62uylp7GZW3uFVOle2dxOSlfJRdi/n7Ej2ZXJ2LDFG7WAvPyFIIQmPM4DerEF1mBWlq8tH60EHrbUOqve001LdE5jpjGolszIhCVuSAVOs7rD7GmokajUsTo7hjaZO/l3fxsRwaaQyqJRtRbufFzdW84dLRgM9PIosjZpSKUixq6eLTCUKFCRb2FXXxf4mB7mnsHX9S3uY/9KrfCRJMpvChNoZQ0TgjWCC1YheFGgLBClz+6Kr8alhHkxFq4sWh5cky6nVg+kKBKnyKDy3sb0E7Bq7vLQ5/ahE4ZiNLA+HRQlWZsSY2Gh38efKRnThrNv5CTaaYh0UN3b38U66bGIGsaaTcz8OBf6Yn876Tgflbh9/rmrkvrx0BEHgd5eO5rONSpZlSlAV7XLrcgd49IsyAP7n3BGYdd+8cOCbd8TfIOwcYDUBsLu+iwZkxIVZpCRZ6F5Rg3t7M5597XiK29EXxCHoVIoeiCiEf4OgVSGaNKjMGkSz0pIrmjSoLFoEzeATR7Ddg7fMjq+8E19lF5I3hKAWENQiqEQEjYigFlDHGdBmWxDTjWzauAmAWbNmIYZLOJIvSKDRpfy0uBFUIqJJg2hSozJqEM3K8ajjDVEtk96QZRnnunq6Pq0GSUadYCDu2iK0qf2dkK9Li+cfB5up9vj5b6s9WiK4eVYOz6yrYm99N2vKW/lXpoi/Q2C2pGbhwlxUOjWJiYm0traSLnQxauyYaIdJBBs2bMDn85GUlNTHWfZQGI1GZs+ezeeffw70cGCgN5G3byfSyDQrn+5rorjhyBmYT8NdA+cl9F1VRnxvzhk5NBLdsiRjb3HTctBBy8HuaEAS9CtZE6NNhzlWhzlWjzlOh1oj0lbnpPWgA3d3X10UQRTIGhVH4fRUho2NP24Oy1DhloxE3mjq5L8tdu4dnkayTsP28MJB6ArwVn0dP1tUgFmrZle4DDHOZqK0s6sfCbswxaoEMI3dp4znUO/1U+XxIwLTewUwB1oU/pZBMzB/60SgE0UmWU18aXey0e6MBjAxRi2FKVZKGrvZXNUxqA/OyULE8DZLryWuVyltd51yn4xItgxpKU0QBH43PJ3zth3greZOtOHs4PcyEwleaOHaZ77i5U097t3fDRPET1fEaNT8X2EW1+2uZFltK+cn2JgeY+Y9ezeSXgWeIFs2NlBcmMnINCuPfVGG3R2gINnClZMzv+7DPy6cCWBOEnySREl4gBzfazXR5Q5Q3a4MsGPTbahNWuKuGIFlbjpdn1bjLenAu//YSXSiSY3KplN+YpTfoQ4v3nI7oY7+XQWyX0b29+UsBFs8ePd3sF9Vj1PjxCToyaw00F5cQqDBSbD96LoTBJ0K3TAr2hwbulwb2nQzsi9Ex5sH8JYon80wLpHYy/IQB4n6TSoVt2ck8ueqJv5xsJnFSTGIgkCcScu107J4Zn0Vf9haTUm6Dq0g8NCMfFThfcm2dGhtJVdj5zcX9gg1ybLMl19+ybp16wAl+xIJzgbDtGnT2L9/Pzqdro8yb4TIu2+QTqQjGQP6JIlVYc+nKQEVNfvaQQC3P0TDATuZkshEg4HGii5ElYBKLSCKIqJKCWh97gAeZwCvw4/bEcDr9ONxBvB7QgR8wT6/Pa4AwUGUbWUZXHYfLrtCwD0UggCxqSYSMy0k51gZPjEJo/X0WYWOsxiZajOxucvFiw1t3JSeQK3XjwCM0Gmo6PDx5tY65o1IxOENoteIzEqy8kZnFyWHBJ+FERL2KVTkjbRPj7UY+3ShRfkvw2IP60dzvJgRY+ZLu5MNdic39uHBxFHSqPBgTnUAE8lYj7P0XfDtqbcDyng51BhvNXJ5cixvNXfil2XGWhRxOCHGzIKCRFaVKnYCc/ITyE8+/Qn1Z8dbWZoax38aO/jJ/hpeHzecx2qUEvtEt0BxQOZ/3tzFY0sn8MLGagB+dWHRKeV8DSXOBDDHiP2bGmmu7MbrDuBzBfC6gvjcyu9QQMJg0WCK0dGUpCEwTMYqC3h2deAZFY/BomV3+GbMijP2SUdqkk0k3DgKf60Df003sgTIsqJ3Ev6RfCEkV4BQWEBPcgYIufwQlJFcQSRXUBFIOxQqAW2WBX1eLLr8GNQ2HXJQQg7Jyu+ghOyXCDS58Fbb2V2+EYDR/ky8W1r77sqqRZNmRpNiRJZQjsMdPh5XgJDDj+wL4S3txFuq8FcErYigUSG5AqBWtFFMU1OOyIe4OT2Bx2taKHV5+ayti/PDpZrb5ubywlc1lMQpA/4PspLINSolmE6XnzcrZBYAmaouYvXK4B8IBPjggw+iKsFTpkzp45I9GDQaDbfeemu/x8dYjKgExRG5zusnI2wwOC5coy9tdtDp8g+acv6y04krJGELQMnDe+ntj3tFWKdh4zMlRzy+o4VaI5KYZSEx20JStpWkbAu2RAMeZwBnhw9npxdnpw9HpyLjH59mJjHLQkKGGY3u682yHAm3ZChiXi/Ut1NkUjJjeUYdt0zP4Vfv7uGFDdVY9MpQNybdxthwRrTY5UGW5eh1OFAb/MlGhP8y+1D+SziAGeryUQSH8mAi52B6bjzPb6j+WvRgdoX1X8Za+hJJIxmYoeK/HIr/zU3lw1Y7XknmtozE6Ln41QVFrDnQiiSfPsJ1R4P78tJZ2+Gg2uPn/G0HcIUkxluMPDcpi/OK2ylp7OaKpzYQCMnMHZHIvBGnZ6PA0eBMAHOMqNnbTtnWlkGfd3b6cHb62KrSwTATiU0+Vq4tQRAgZbiNRpNATEiIGskdCm2mBW3m0Uf6siwje4IE7T5C3X5Cdh+hLuVHNGrQ5cWgy7EhHsUkpM+LoSbWTne5G71Oz4zvzIdmPyqTBk2qCU2qCZX58KtvWZIJNLrwVXXhq+zCX92l6J/4JVTxeuKvKUKbfnT8AptGzXczEvnHwWYeOdjMeQmKemqyVU/uzFR26WX0AbmPG+pfl5dy0KPBa9Cjl7xUVFSQkZHBa6+9Rn19PYIgcP755zN16tSjOobBYFWrmGgxsaXbxeoOB9eFtRiSrHoKki2UNjv4sqJtwFVsMBDixW11oIHhB72o1AJxqSZkGeo73Tg8ivNwnFGLFJKQQjJSSCYU/lsOyeiMavQWLQazBoNFi8GiwWDWoDWo0erVaPQqtDo1GoMKnUGNLdGAOAA/xWTTYbLpSM755thZHIoLEmJI1TXQ6Avw56pGQPE/Wjw8jT9/UkJNh5unViveWOMzYxhu1KERBLqDEnW+AJnh4DPSiVTX6aHbG8Cq7+sFs73bxUetXSxNjesjOripsp3Vpa1cNz2LjNhj468Nxn/5KtzKPNQE3ggmWo3oRIEWf5AKjy/6eabmKGXSshYnbU7fUWmuuEMSbzV18H6LnctTYlmaqhyz0xfkjS21fLqviZtnDuP8I5TlogReS18C794wgXfsSQpgMvRanh41jL0OD5cm9fDc8pMtPHzVeJq6vMw/TbsBB4JVreLhwiyu3FVBR0DJvN6Xl0ayVc8Di8fww1e30+kOIArw6wuOvIg7nXEmgDlG5IxPxJZsRG/UoDep0Zk06E0adEY1KrWI2+HH3eVnU2cb4GOcyUBCJrTVOmksV27E29Aj7HSyVjygvNagTDi68OSj0ohIkpJ9if4OyegMamJTTX1S+IIgIBg1aI0aOGSulCWZrlYPdXvaoryH1loHoYCEWqtCo1Wh1qnQaEU0ehV5k5L4skRpLZ46bSoxE49dUlwQBbTpZrTpZiyz05ElmWCLm2CHF91w26Alo8FwW0Yiy2pb2OXwsKbTwfw4K+VuL/sMMsgQ2tNBWVE34zJj2FVr5z+bawCBoqIiqop3sHHjRux2O93d3ej1eq688spBDSaPFfPjLGzpdrGqozsawICSbi5tVnxFDg1gaos7WPVaKV9O04FGZJZKx9LfjiUm2YgvGGLS/Z/j1AR59weTvhEqzacDNKLATWkJPFjVGBUGnGA1YtSquXpqFsvWVlLZpmQmx2XGoBVF8o06il1eSpyeaAATY9SSatPT2OXlQJODycOUyfyAy8tfqhr5KKw383JDO8+NzsHmCfHQp6WsOaBkKd/aVsezN00+as7KQY+POm8AtUDUowiU8mOXJ4BZp2bMSRIV06tEJlqNbLS72Gh3RgOYOJOWwhQL+5scbK7qOCwXqMHr57n6Nl5uaKczrKWypctFFiq+2NbAG1tqcfiUtvXihm4mZseSbB2YGNwZCFITNnMd0ysDU9fpodMdQKNSrDpOFhYl2Pp0AkbwddsqHC/mxlm4KT2B5+vbuDDRxrRwgHzh2FQ+3ZfGB7sauGpK1kk9p6cCZwKYY0T+5MMTK61hifKDX7WDGxbPyuLcS2x0t3uo3t3GG+8dIMkHqq4Ae1bVHdcxGCwa4lJNxKWa0MVKaPRqxKAOjyOAx6FwIdzdfuxNLvzegbkPQb+El0Cfx6qqquiKa0StVkdbiE8UgiigSTGhSelP1D0aJGjVXJcWz7/q2nikupl5sRZ+daCOoAxpPmhv9fL4qnKevG4Sv31/L7IMl01IZ8H0OKqKd1BTU6PsJyGBpUuXEh8/dCva+XEW/lrdxPpOJ15/EEeTh/YGJ/lNQc5xa/BvbGOluwQkGVkGt8NPbXEHDbEqHEYDBgTuvmEMhjD3YVNlB05fkCSLjnFDTNz8tuO6tHj+frAJX1hxNKIjcv30bJ5ZV0nEeSLiQD3SbKDY5aXY6eHcXhNXYYqFxi4vJU0OUlPM/K26idcbO5BQNCfS9VpqvX4u31GGak8nqkYPalHJCtbbPVz19Cb+ec0EFhYdmYAdKR9NsJj6uC5vCrcxTxkWe1JVmGfEmNlod7Gh08n1aYfwYDpd/LmhhZclN4laDYlaNQkaNWJAYvnuRio0Mk1GETlcBU4QRYyiSE0wyBVf7keztR0ByE00IQoC5S1O/vBhMY9fM3HAY9kdLh/lGLTE9CLwRtqnC1Os6NSndynzdMMDeemcFWfpo+4M8LcrxnLx2FTmfYOySoPhTABzEuAMhigLy3RHvFis8QaSJyXy8me7MejhjUsm4Gh04/cG8XuC+L2h8O8gwYCEKApRwqYY7kTyOPx0t3vxOALUO+zUlrXRkbgZkIltm4xK6p/uValF4jPMJGVZFA5ElgWdUU3AFyLgDxH0hQj6JVoOdrNig8IPidNkoVGdmFz3UOKOzCSer29nU5eLe8vrWdvpRCcKPDw6k5vW1LO8uJn7Pyxmd10XFp2a/72gkASTFovFgsPhIC8vj8svvxy9/sTaQv3eYLhEqPBF/B0eTAboIsR9f9hARlswuu141OCH/Rsa++xDEMA+Kx4IclaiNRq8AHwebp9eWJR8zNL6/78jXqvmsuRY/tPYgV4UolyYzDgjZxcls7y4mQSzLuogXGQ2QHNn1AE6gsJUK1+Ut/FCp53ffGWPBkTnJ9j4YWoC73xZzQseD6FkA9LYOApzZJbNyCfBouMHr2xnXVkbt724lfsuGc3107MPe8wRAu+p5r9EMDPGzN9pZqPd1YcHE5NpwadL4oBW5kDHAITmGAFQthU7fKiqnThavXQbVDArGSleT/74JH4zIZu5+YmUNHVz8WPr+Wh3I1dObh2QcxEpH421HCpgd3L5L99mqEWhT3AegU6t4txRKQO84puHIQ9gfv/733Pffff1eaygoID9+xWaotfr5X/+53947bXX8Pl8LFq0iCeeeILk5J4VS01NDXfccQerVq3CbDZz44038uCDD/ZTSz1dsdvhQQbSdZo+zrK7IjoUKRbGzDw+hn/AF6KzyUVHg4u9+/bQXqdMmqphTYwdNguDOcyHsGixJhiITTUelTaHJt5HYJsdZAgeTOC9v+/gwh+OxWQ7uYGMx+nH5w4qAZU3hN+r/C1LMrEpJmJTjaTptVyVEsfLje38q04x6ftxVjLzMuK4cEQSu/a2sW1VLVNkNedkJFDy4UF87iDp4iRcBjvWjmGser4MlUZErRGjv9VaFRqdKvxb+V8Kybi7/EopsNun/N3tx2X34XMH+x1/1gwTJVk6KpI1DHdDfLqZ+DQTH5e1UN3hZn5BIlNz45VSnwiZRXFcUV8HrmCf9mlZlnu1T59e4nXfFHw/M4n/ttg5J96KplcAeMf84aw+0Mr5o3uI4yPDrcOHdiLFJBrwT09kr14GCabbTPxmeBoGV5DvP7uF2g4PaiBjegoHbSr2WASe6OjkwfgMnr1pCr9+dw9vbK3jt+/tpa7TzS8WFQ4YjMqyHM3A9F4hB0MSm6P8l8PrCJ0oJllNaAWBJn+AKo+fHIOWJ2tb+VtXJ+hUCI4Avx+ThUeQWVHeyo4WB2hV6E0aRpv1jAuqUGk0tKVqaLP4sLsDCH6RHQaZlkwDE3LjEEWBUWk2bpqZw7NfVnHv+3v57Cdz+7VDRwKYwTqQTlYp7Qy+2TgpEcGoUaOi2hlAn8Djpz/9KR999BFvvvkmNpuNH/3oR1x22WV8+eWXAIRCIS688EJSUlLYsGEDjY2N3HDDDWg0Gv70pz+djMMdcuwYTP8lvJoYl3n8N6NGpwp3kVjZUb06+nir+yA5M88hIyPjuPYbOf/5wwvxuS201jh46y9buehH44hP60sw9HQrk7osy4gqMZotUtp9RYxW7YA6MBF4nH4ObG6mdFMTrTWHb1kVRIGYZCPjso28mgWSAMkhgZyPmni+tpyiLj9F9ARZgb1d7O1l2QBWahm6jgqtXoU5Tq/opsTpOTdJpAQ33VPjuOVHI6IT5IF1Gl77qASdLsj3F/WsxA96fBQf8KIS6ON0u7e+m8YuLwaNalDn7DM4PApMenbPHIX+kIB9QlYs2397DsZek2ZEx6fC7cMTkjCoRD5qtfMXpx3ZrEH0hXhhUh5nJ1h5Z3s9v3p3D76gRGacgb9cNpaZeQk8U9fKb8vqeamhnQZvgGWjs/nLkrFkxBr5+4oDPL2mkvpOD3+/cny/VuhKj48mfwCtIDCpl+HkvoZuHL4gVr2akWknl1htCPNgNnW5WN7WxZYwSRkgpsOPZ1sbluRk1u9tZF9ZG1rghhnZ/OackYO2dvskiYVbSsNiak08OEIZj+4+dwQf72nkYLubJ1aVc/e5fcUxewKYHv6LLMs9GZgzAcwZDICTEsCo1WpSUvqnqLq6uvj3v//Nq6++yllnnQXAc889R1FREZs2bWL69OksX76c4uJiPv/8c5KTkxk/fjz3338/v/jFL/j973+PVjtwF4zP58Pn80X/j/jXDDXe3F7Lh61djFdridFrsOg1mHVqrHo1KTY9uYnmqJ7B+ENWExEhraEQpvL5fFRUKJ0VmZmZ1NbW8sknn3DLLbccUdvkUOzatYvi4mIAzl60AP35Fj78527szW7eeWgbGUVxUa0QV5dfae0+DHRGNYlZFpKHWUkapgRbBquGg3vaKd3URPWeNqRQzz40OhUavZIN0erVaHQqZFmmo9GFzxWks9EFjS4m+o1sz9WxcF03Tc092RC/XqRBCjB5RAKpiSZ0BjU6oxqdUYNaIxIMSISCEqGARDAQIhiQCPolguESWsAfIuBT/hdEAaNVi9GmxWjVYrJqMVi1mGxKwKIz9L1lRnj9/GNjMbvdHrqCoWj9fk5+IlDCV1XteAOh6Ipzedj7aKrN1Eesa0U4+zJ3RMKQinX9/wbTIDyJQ1VGE7Vq4jVq2gNBSpwePm7r4p9hvQxVpw/1zg5yJhZw7/v7oi7ECwoSeeSqCdiMSlb11oxE0nUaflB8kJUd3TxY2cgD+RncuTCf9BgDv3h7Nx/ubiQvycxPzh7R5/0j2ZdJNiOGXgFXxD5gak78KdHmmBFjZlOXi99XNACgEQTuz0+nYmsjL0kyv3p3DwB6jcifLxvL4gmHJ7XqRJE/j8jg8p0VPF/fxpUpcUywGjHr1Pzu4pHc8cp2nlxTwSUT0hkeVjtu8wep8yp8vN4lpIPtbhzeIFq1yIhvgAbLGZx6nJQApqysjLS0NPR6PTNmzODBBx8kKyuLbdu2EQgEOPvss6PbFhYWkpWVxcaNG5k+fTobN25kzJgxfUpKixYt4o477mDfvn19XIN748EHH+xXujoZ+F1rGx1agZV7W1HXu6OPxwsuslSd/HDpRT0KvIe0A0YzMEMQwJSVlREMBomNjeXKK6/kscceo76+nj179kSdlI8En8/Hxx9/zK5duwD6nPclP5/Ex0/uprG8i8odfbVgBAEMVi2iKERbfKWQhCTJhAISPneQuv2d1O3v8TFSqcU+Zn+JWRYKZ6SSPyUJwyCt2bKslHPa6py01zsZXufAvi9AamEy8QvNJGSYiUszodKq8AZCmL4GKex0vZZ8o44yt491nU4uTooBYESymRSrnqZuL1uqO8IBDXw2iPpuxD7gnJHfjtr06YaDu3eSmD0Moy0GULr3Rpr1rOt0cuPeKlr9SkD8vcxEtuyppNgvsfRfm2h1KIuiuxbmc9fC/H7loPMTY3hmtMi1uyt5ob6d2zISyTboWDIpA5Uo8JPXd7JsbSXXTssm0dKTKVwf5r/Miuk7MZ8q/ksEM2PMPHxQufbSdRr+NXoYE60mPmoP8NJGhQCfk2Diyesm9lO0HgyzYy1RcbhflNbyyeQRqASB80anRMXhfvveXl65dRqCILA7PF4ON+iw9ApAd4cJvEWp1pMi5ncG33wM+Yg/bdo0nn/+eQoKCmhsbOS+++5jzpw57N27l6amJrRaLTExMX1ek5ycTFOTYgPe1NTUJ3iJPB95bjD88pe/5O67747+393dTWbm0Msjz7eZecfjwlIYy4J4Gy5vEKfHR17rbgz4eG3FGmrHKa7DvQWZDra76fIE0KrEIWldKylRRM5GjhyJxWJhzpw5rFy5ks8//5zCwkJ0usNzVxobG3nrrbdob29HEATmzZvH3Llzo8/rTRouuWsCpZubCPolzDE6TOEfo1UzoKYIQCgk0VHvouVgN83V3bRUO+hocBIKShisWgqmJlM4I5X4o9CCEQQh+p7Zow8/oH8dwUsEC+KslLlbWd3RHQ1gBEFgTn4Cb26rY11ZG3PyE7EHgmwcxLyxpLEbUeC0M2/8NqB65zbefvB3ZI+dwOW/vj/6+EiTgXWdTlr9QYwqkb8XZLI4OZb/SWmjuKGbVocPq17NI1eP56zCwbuKFsZbmR9rYXWngwcrG3lq1DAALhmfxnNfVrGrrotHV5Zx/2LFk0mWZTYMIGAXCElsqT65+i+HYmqMidkxZmwaFQ+NyCReq9xH8woSmZAVw7B4E/ddMqqfJs6R8Lu8NFa0d7Pb6eG5+jZuDQvE3fed0Wx4eA0bKtp5f2cDiyekRwOYcYeU3KP6L2fKR2cwCIZ81D///POjf48dO5Zp06aRnZ3NG2+8gcFw8qy6dTrdESftocCfJuXwyYZiOtUS15yfz+xYC1u3buXDD5WVmldQOB3DDTpsvUoEkfJRUdqJryYCgQBlZYoJV0RNdvr06Wzfvp3Ozk7Wr1/PwoULB3ytLMts3ryZ5cuXEwqFsFgsLFmyhGHDhvXbVqURGTnr2MjGKpUY7XYaNUdJNwd8IbrbPcQmGwcNfL7JmB9nYVldK6s7HH26OeaMSOTNbXWsPdDKry4o4osOByFZ4WoM62XeuLIkYt4YR9xpbBb3TUXNPqW7rmbPLjxOBwazsoCYHmPi6bpWcg06nh0zjMJw59K0nDje3l5HUaqVp66bSHb8kSUAfjM8lTVbHbzXYuf7mW7GW40IgsD/nl/E0n9t4j+ba/ju7BxyEkyUur20BYIYRCHapQiwu86O2x8i1qiJiuqdbOhEkbcm5PV73KxT8+4PZh33fhO1Gn6Vm8ovDtTx58pGLkqMIUWnISveyJ0L8/nrZ6U88FExCwqSogq84/op8NqBMx1IZzA4TvpsEhMTw4gRIygvLyclJQW/34/dbu+zTXNzc5Qzk5KSQnNzc7/nI8993YjRqLkqVRG4WlbbSjAYjHrryAjYrUqadTAC7/ghuBkrKirw+/1YrVbS05UgQaPRcO655wKKWWFnZ2e/1zU2NvLqq6/yySefEAqFGDFiBN///vcHDF6GEhqdivg087cyeAHFhE8nCtT7ApS5e3hYs/MSEATY3+Sgpds7qHnjiiE2bzyDvmiqUIJ9WZao3rkt+vh5CTY+mzyCz6cURIMXgMsnZfD+D2fx3g9nHlXwAjDaYmRJ2HD0gYoG5LAB4Izh8SwoSCQoyfz1M6UTM8J/mWIzoevFV4uUj6bnxn8r2uivT4tnotWIMyTxx8qG6OO3zcklL8lMm9PPP1aWDdiBJEkye+sVvtjJUuA9g28+TvqM4nQ6qaioIDU1lUmTJqHRaFi5cmX0+dLSUmpqapgxYwYAM2bMYM+ePbS09Mj1r1ixAqvVysiRI0/24R4Vbs1QukRWtHfz0bYddHV1YTabGTttLq0WZRDLPYRMuHsICbyR8lFRUVEfP6HCwkJycnIIhUIsX74cUDIuZWVlvPDCCzz99NOUlZWhUqk477zzWLp0KSbT8QnMnUEPjCqRaWEl1dUdPeTxOJM22j2x8kArX7Qrzy1K6OESdHkCUd+Zs88EMEMOWZJoriyP/l+xbXP0b0EQGGcxYjwksBZFxerjWIXTfp6TglYQWG93Ro06AX5xfiGCAB/vaWJ9dTuvNyrfd2/+S6fLz3s7lUn+VPFfTjZEQeCP+UoX0jvNndR4lOBeqxb57UXKWP7KjjoafAEEYIy5J4isanfh9Cnmm3mJR2c9cgb//2HIA5if/exnrFmzhurqajZs2MCll16KSqVi6dKl2Gw2brnlFu6++25WrVrFtm3buPnmm5kxYwbTp08H4Nxzz2XkyJFcf/317Nq1i88++4zf/OY3/PCHPzwlJaKjQZ5Rz8I4KzLwdLXCy5kzZw4XL5xFSziAqdl5ILp9MCRFFSVPpIUaIBgMUlpaCtDPjFAQBM477zwEQaCkpISVK1fyxBNP8Morr1BVVYUgCIwePZrbb7+d6dOnH9FM8QyOHvPjlKBk9SHCX3PD5N0XGtpwhiSStOo+5O5/ra0kKMnkJZnJSTgTTA41Opsa8Ht6yPbVu7YRCvbX8xkKZBl03Bxe3DxQ0UAonIUpTLGyZGIGskbg5pKD7HZ6sKhEFifHANDi8HL1sk2UtziJM2k571siMgaKkOe8WAshGZ6s7WkGmJufwJh0G26jMgXlGXV9Osj2hDPWo9JsJ1WN+Ay+2RjyK6Ouro6lS5dSUFDAlVdeSXx8PJs2bSIxURnIH374YS666CKWLFnC3LlzSUlJ4Z133om+XqVS8eGHH6JSqZgxYwbXXXcdN9xwA3/4wx+G+lBPCLdnKp9nd3wqmphYJk6cSKsEHq0OQZaQ92/HE+5sKGtx4g1ImHVqchNObDVRXV2N1+vFZDKRlZXV7/nk5GQmT54MwLp162htbUWr1TJjxgzuuusuLr/88n4k6TM4cSyIU1bTG+1OvKGebqs5+QlINg07w7HJT4elIIYDx3VlrTy+WskO3LUw/9Qe8EmGLEkEA4Ejb3iSESkfpeYVYLBY8blcNBwYOpfvQ3FXdjJWtUixy8vbzT1l3Bvn5xKYkohDL2IWBN6ekEe2QUe93cOVT22ktNlBkkXH67dPJ2kQv6BvKn6crRDT/9PYTqtfuSYEQeCO+cORbQrna5TpUP7LGf2XMzgyhpzE+9prrx32eb1ez+OPP87jjz8+6DbZ2dl8/PHHQ31oQ4oZZh3xHiftBjPuKbPRaDTsDHN7Yl0O4iQn//l8M9+9YGa0fDQ63XrCte2IXkthYeGgei8LFiygvLycUCjEtGnTmDRp0gnL6J/B4VFo0pOi1dDkD7C5y8XccEAzPN1KcFw8iAJzzEZuCps+tnR7+clrO5FlWDo1i4vHHZ8y8+mGoN/P7pWfsfm9N0AQuPZPf8cSd2Rhvj1fLEdUqRg1b2Dy+fGiqULJhKbkjyA2LZ3itV9QuX0LmSPHDOn7RBCnUfPjrGT+WNnIXyob+U5iDB2BIN+vqEOyaMAXIq3Kzai5BqraXFz3zFfU2z2kxxh49bZpR825+SZhVoyZCRYjOxxunqlr45e5ikHkolEp6MvrcAL+tr6KyGcUeM/gaHAmN3ec2L59O6NqlNXdZ4KeoCRHBezSwkJve7dvRpZldkUVeGNO6D0lSYpaMhyOD2Q0Grnzzju5++67mTVr1png5RRAEATmhYOWVWEejCzL/PxAHZJBheAOMt0pIAgCIUnmrtd20u7yU5hi4XcXnx7crhNBKBhg14qP+fdPbmfV80/jsnfi6uxg3asvHPG1VTu3sfzpR/n0iYc5uHvnkB5XJAOTkptP7sSpQF8ezMnArRmJpOk01PsCPFDZwCU7yqnw+EjTaojfZafmYDd//ayUK5/eSL3dQ26iibfumDFo8LJ39ed8/u8nCfi8Az5/ukMQBO4MZ2Geq2/FEXauVokCYqySgdm2pxlf+PHQGQLvGRwlzgQwxwG/38+6desY0VKLVZCp8wX4rL0rKmD3nYJhSLKANWjns83F0QzMiQrY1dTU4Ha70ev1R+wcOsNvOfWYHw5gIjyYf9e38XFbFypAs6uDzQcUH6dHV5axsbIdo1bF49dO/EYr74aCQfZ8sZxnf/I9Pn/mCZztbZjjE5i+5GoQBErWrTpsySbo9/PFs09F/1/xzD+HbKIOBYO0VlUCkDw8n2HjJiKqVHQ21NHZWD8k7zEQDCqRe3IUHsszdW3UehWfof9OyucnM3IAeGpNBa0OH4UpFt743gxSbQNLTHS3trBi2T/ZtfwjNr71n5N2zCcbixJs5Bt1dAclnq9X7oMmX4BuWQZZxt7k5t3tyndS0erEEwhh1KrIPUPgPYPD4EwAcxzYunUrLpeLBKuVGzOUlcXTta3RdsCz0pMgTuGnrFy9lv2NyoR2oquJ3uUjleqbO+l9WzE31oIAlLi8LG/r4r5ypavkrrRExO4A22s6WVHczKNfKFmBP106Jiqn/k3FBw//meVPP0p3awum2DjOuvl73PLIMmZdeR2j5yuK26ueX4YsSQO+fst/38be3Ig5Ng5zfAJdzU1sePPVITm29roaggE/WoORuNR0dEYjGUWKmFzl9q1D8h6D4cqUOArDhpEFJj3vTcgnQ6/l+hnZUUfscZkxvHb7dBLMgzcnfPXuG0ghhUu39cN3aamuPKnHfbIgCgI/ylK4d8vqWvGGpKiAXbKgQgjJPL22kpAkRwm8o9NsQ26nUL+/mK/ee/OkEbnP4NTiTABzjPD7/axfvx6AuXPn8t3MRNQCbO5y0R2U0IsCBSYD31m0AACjuxmj7CHepI0OXMcDSZL6tE+fwemHeK06qr58y95qArLMBQk27hmRRlackUBI5gevbEOW4arJmUf0lTnd0VZTTcXWTQiiyLzrb+GWR//FhPMuRh32K5t99Q1oDQaaKsooXreq3+vtzU1sfu9NAObdcCtn33IHANs+eq9P6/PxIlI+Ss7NQwjzxSJlpMrtJ7eMpBIEXhyTw73D03h3Qh7JOkXJVq9R8cJ3p/KbC4t45dZpxBgHFy7samlm7+oVACQNG44sSSx/+jEkKXRSj/1k4bLkWNJ1Glr9QV5v6ohmrGcnWokxaqhqc/HJ3sZox+ZQC9jJksSHjz7E+v+8wM7PPhzSfZ/B14MzAcwxYsuWLbjdbmJjYxk3bhypOi3fSYqNPj/abEAjCkwuHIbLkIQgwChVE2MzbCdU1qmvr8fhcKDVasnNzR2Kj3IGJwELwu3UAVkmU6/l4cJMBEFg7v9r7z7Do6rWBgw/M5NJ752QRugkgdAJHYEggggooqAgFqzH3lBE8BwOlk+s2PWgKArYKAJKr6GF0AKEJCQESCe9TV3fjyEDQxKSQCqsm2uuGfZeu7zZk8w7a6/SwdSQVWcQdPRxYu640KY8zXpxeON6ANr27EuvsRNQW1vWJDi4utFv4j0A7Fi62KI7sxCCLYu/RK/TEhgeQcfIQbTt2ZcOkYMufVAbru+DOrOi/UvbSz28Qnr2BuDciWNoSkuua/8VTu7axk+vPVcp6Qq0s+GJQG+LSTsB2nk78vCgkEoTTF5pz+/LMBoMBIZHMOHVN7GxdyDzdAKx61bXy3k3NrVSweOBphrrRalZHCwwvR96uDowPTIYgM+3Jl026W39JjDn449TfMF0+2rvH8st3o9SyyQTmDoQQpjHYBkyZIj5Nk7FwHZgOQLvwAGmobjbqXII976+IeIral86dOiAWl23eUmkxlPRnVqtUPBlaJB5OomhHUx/uO3UKhZN7Y6ddcu+BajTlHPiYq1KtxG3Vluu++hxuPq2oiQ/j71/LDcvTzqwl9MH96NUWTH8wcfMyf0tD8zExsGBrJQkYtauvK5zzKgigXHz9cPdzx+jwUDK4djr2j+Ykpe1n7xPRlICe35fdt37q5CfkU7cto0A9J80FUc3dwbfNwOAncuWUJCVebXNm60prTxwV6tILdeyNc90az3CyZ4H+gdjb60iLq2Q2NR8oP57IJ3cvcP8uqyokJi/ru/9JTU9mcDUgUKh4IEHHmDSpEmEh1/qhtnD2YF+F0dijXS91KZh3ICuFKpcUCkEjheufeyJ3Nxcjh41TWsvbx81b31cHHi7gz9Lurahh/OlXiW3dPJm9pjO/PBQH9p5N848Nw0pfvcONKUluHj7ENS16hniAazUaoZOexgw3RrKz0hHV17O5sVfAtDr9gm4+/mbyzu4ujHk/ocA2L38J/Izq5/A9Wr0Wi05Z1MA8G3bwWJdSM/6uY10au8u1n76PkIYL+5vP2VFhTVsVTt7fl+GMBoJ7taD1h1Nv/Phw6Lw7xyGXqNh47efmacraEnsVUoe8fcy/1+lgC6Odrg5WHNvn0vjWjnZWBFcj13KjQYDp/aYbv2HDRsJwIE1v1NaWFBvx5Aan0xg6kipVBIaGlqpEe03YW1YHNaG2y6b50ahUDD97jsASDsdT0pKSp2PFx8fz1dffUVRURHOzs60b39jDXh2o1EoFDzQ2tM8Mm8FpVLBw4NC6B3s3kRnVr+ObDLdPgq/ZZS5fUl1Qnr0Iahrdwx6Pdt+/JY9fyyjKCcbZy9v+k2cXKl82NCRBIR2Ra/VsOHrT6/pgzor5TRGgwE7J2ecPL0s1oX0MN1GSo49cM3tSZJi9vLXR+8ijEZChwzHO7gtRoOek7u21bjt6dj9rPnoXXLOnqlyfV5GGsd3bAZMtS8VFEolI2c+hcrKipRDMZzcvb3K7cuLi+vt9lhDmNHaE4eLo+t2crDF7uLrhwe1Qa0y1cSF1sOYWZc7G3eUssICbJ2cGfHwE3gHt0VbVsb+Vb/V2zGkxicTmHriaW3FrV6V27l06xhCz549AVi7di2GWt7XNxqNbN68mZ9//pny8nJat27NQw89hLW1nK1YalrZZ5JJT4hHqVKZv81ejUKhYNj0R1AolSTu32P+0Bg2fSZqm8pjFCkUCkY+8iRWamtSjx7i+PbNdT7HzNOXbh9d+Tvp16EzNg4OlBUVkp5wqqrNryr5UAyrFy7AaDDQacAQoh57mtAhtwAQt+3q56rTlLNu0QfE797O0tkvkrB3d6Uye377BWE00iaiJ63ad7RY5+7nT9+LSd+WxV9RVlSIEIKc1BT2/rGcn994iUUP38t3zz5KSX7lCV2bA1e1FQ+0Nt127+Nyqca6lYsdd/Yw1cb1qedEvyLZ69C3PyorNQPvuR+AQ+vXUJSbU6/HkhqPTGAawfDhw7GzsyMrK4t9+2quti4pKeGnn35i+3bTL13v3r2ZMWMGLi5yUCep6VU03m3Xqx8Orm41lDbx8A8kImoMYOoNEtKjN2179a22vFur1vS7614Ati35ts4NLs09kK64fQSgsrKiTYRpuo263kY6c/QQK//vPxj0ejr0HcDoJ59HqVTRaeBQlCoVmacTuHAutdrtj23dSPnF20y68jJWLfwvu5YtMXczz007x4kdWwHL2pfL9bnjLjz8AykrLOC3/87hm389zPcvPcXOX34wjbkjBKUF+Wz/8bs6xdaYXm3Tii+6BPFKG8t5n+aOC+WjeyJ4bGjbejuWQa8jYd8uADr1HwxAcERPWncKRa/Tsue3q48eLzVfMoFpBPb29owYcXFMjC1bKCoqqrbsuXPn+Oqrr0hKSsLKyooJEyYwZswYrKzqfdYHSaozXfmlxrtdR4yu07aRk6bg4OqG2taOYQ88WmOvvF5jJ+DWyo+yokJi19et22tVDXgvV3Eb6fTB/bXe57mTcfz57r8x6HS07dWX255+CeXFW8n2zi606W5KiuK2bapye6PBQMyaPwAYNv0Retxmur285/dl/Pnev9GUlphqX4QpwfNtVzn5AlBZqYl69F+gUJB5OpHC7Eys1NaE9OjNiIefYPzLb4BCwfEdWzh34lit42tMaqWC8T5uuF7RQ0uh09Am5yhWBm29HSvlcCyakhIcXN1o3dnU+0+hUJhrYY5t2UBeRlq9HU9qPDKBaSTdu3endevWaLVa/vnnn0rrhRDs2bOH7777joKCAtzd3XnkkUfo1q1bE5ytVKEoN4efXnuOpW+8SGFOds0b3OBO7t6OtqwUV59WBIZ1rdO2do5OTHv3E2Z88DmuPjXPuKyysiLyTlMtzIHVv6MprV0tjLaslNy0c0D1CUxwRE8UCiU5qSkUZmfVuM/sM8n8+c5b6LUa2kT0ZOyzr6K64ktF6GDTPE4ndmypsm1Nwr7dFGRlYuvkTPjwUQyb/gijn3welVrN6YP7+fHVZzm5y1TrWl3tSwW/Dp0Z9ejTRIway/iX3+CJb5cy4ZU36TbyNtr27EvXW0YBsOm7L667O3pjiv71JzZ8/Sl/f/Fxve0zPtrU+6hD5ECUykttF/07h9EmoidGg4Hdy3+qt+NJjUcmMI1EqVQyZoypCv3o0aMkJyeb15WVlbFs2TLWr1+P0WikU6dOzJw5U84a3cQKc7JZPncWGUkJpJ86yc9vvEh2akpTn1aTOrJxHQDhw2tuvFsVexfXWk3uWKHjgMG4+/lTXlLMwXW16/aaeToRhMDJw6vaW1x2jk74XezdkxSz96r7K8jK4LcFb6IpLaF1py7c/sJrWFUxlEGbHr2xdXSiOC+X1CvmdBJCmNv+dB811tz2p8vgW7hn3rs4eniSn5mOEEba9uqHT0i7GuMMGzaS4Q8+RtuefSu1JRp47zRsHZ3ISU1pUYO2VdSIndqzk/Mnj1/3/nRaDYn79wCXbh9dbsA90wBTYp59JrnSeql5kwlMI/Lz86N3b1PVdUWD3nPnzvHll19y8uRJlEolo0ePZvLkyXICxiZWkJXJ8nmvkp+Zjou3Dx7+gRTnXuCXOS+TeuxIU59ek8hMTiIjKQGlyso8TUBDUypVRF5sCxOz5k/KS4pr3Kam20cVKtrgbP/xfxzesLbK3k6lF9uZlOTl4hkQxPiX5lQasK+ClVpNpwFDAIi7ouHx2bgjZJ5OxMrahohRYyzW+bZtz/0LPiSoa3fsnF0YOPm+GmOsiZ2TM4PunQ7AruU/NdsGvZfLz0gnL/3SrZytS76pdgqK2kqOPYCuvAwnTy9ate9Uab1Pm7Z0iBwEQrBz2ZLrOpbU+GQC08huueUW7O3tyc7O5ueff+a7774jPz8fNzc3HnroIfr27SsnYmxi+ZkZLJv3KgVZmbj6tuLuN9/mnnnv0rpTKNqyUn5fMKdW3WVvNBW1L+37RGLv4tpox+0QORAP/0A0pSUcrMXgdhkXR8StqRYjYuRtBHXtjl6nZeM3n7Hy/+ZbjAtiutZzyUtPw9nLmztfewtbx6vPXRU6xHQbKXFftEVX5oral7BhI7F3rtwY397Flbte/zePffkDnoHBNcZYG2G3jMQnpD3astJm3aC3QsrhgwB4BgajtrUjI/HUdf+exV+8JdcxclC1f1cH3H0fCqWS0zH7OBt3c345aalkAtPI7OzsGDnS1PU0MTERo9FIly5dePTRR2ndumXPjXMjyEs/z7J5r1KUk41bq9bc/eYCnD29sHV05K7X/02HfgMx6PX89fF7HFj9e4scTOxaaMtKObHT9GFS18a710upVNF/0hQAYv5aSVlx9Y3gATKTTF2jrxzA7kpqW1vunDWPodMeRmVlRdKBPfzw8r84c/QQBr2Ole//l8zTCdg5OXPna//G0d2jxnP1CWmHh38gep2W+GjTwGlZKadJOXwQhUJJr7Hja4y1viiVKtP8Us28QW+F5MMxgOlWT9/xkwDY/vP31zw7ubaslNOxB8z7rI67X2u6DjeNJr3puy/kRI8tiExgmkC3bt1o164dVlZW3HbbbUyaNEneMmpCBr2O0sIC0k6dZPm8WRRfyMG9dQCT575t0V7Dytqasc+8bO49su3H79i25JtGT2LKi4uvu2q9rk7u2o6uvAy3Vq0JCA2veYN61r5Pf7wCg9GWlRKz5s9qy5UWFpiH2fdpW3M7EoVSSc8x45kyfyHufv6U5OXy6/w3+GnWc6QePYTaxpaJr87F3a92Xy4UCgVdBpvGhDm+3dQb6cDq3wFTTZKLd82Nl+uTb7sOhN8SBdStQW9+ZgbfPTuTxS88wY6li0lPiG/Q95xBr+PsxVuzwRE96THmDpw8vSi+kHPNQ/4nxexDr9Xg6tsK7zZX75Y98J5p2Dm7cOFcaq1q+aTmQfbNbQJKpZIpU6ZgNBpl9+hGlpVymoNrV5GRdApNSTHlpSXoNRqLMp4BQUx6Y36Vt0kUSiXDpj+Ck4cn25Z8S8xfK1GqrBg05YFGufV3fMcW1i1aiFdAEP3uvIf2ffpfU2Paukg7dcLcPqDr8FFNcotToVQSefdUVv3ffA6uW0WP28ZVeSumYkJFt1Z+2Dpc/XbP5byDQ7jv7Q/ZtuRbDm9YR3ZqCkqVFeNefL3a7szV6TJoGDt//oHzJ4+TeuyweRC13rdPrNN+6svAe6aRsHe3qUHvP3/RY/S4q5bXlpXy57tvmdujXDiXyr6Vv+Lg5k7bHn1o27svgWERVTZkvlbnT55ApynH3sUV76A2KJRKBk15gLUfv8e+P1cQNmwkjm51G9yu4ufeqf/gGt+zto6ODJ46g78//5Ddvy6lY//BOF8xgrPU/MgamCaiVCpl8tJIhBAkxx5gxX9ms+SVp4nbtpEL51Ipzsu1SF6s7ewJ6tqdSXP+W2Mbj15jJzBy5lOAqX3Dnt8bfjCssqJCtiz+CoQgOzWF1R+8zfcvPcXJXduueUj8mpzYtY3lb71GWWEB3sFtCR9e/cSNDa1dr354B7dFV17GgYvjqVwp4+LtI5+Quk+5obaxZcTDTzLuxdcJ6BLO2OdeIfgq8zxVx9Hdg6CuEQCsXrgAYTQSGNatVj2LGoK9swsDL/a22fHz95y5oofU5YTRyNpPF3LhXCoOrm5EPfY0HSIHYW1nR0leLkc2reePt+fx+SNTWbdoIacP7seg1133OaZcvH0U3K2HOSHv1H8wrdp3RKcpZ9eyH+u0v/LiYlIOmdrUdLzK7aMKcRfiSPArwrN9O/QaDVt/+LqOEUhNQX6CSjcsvU7HiR1biPnrT/PoqAqlkg59BxA6ZDj2rm7YOjhgY++Itb1dndsfdB1+K7py0x+73ct/Qm1jS6+xExoiFAB2/vwD5cVFeAYE0b5vfw6uXcWFc6n89fF77P71Z/pNuJtOA4fUSzsKIQTRv/5M9K9LAWjbqx+3/esFrG3trnvf10qhUND/7qn8+e5bHFq/hl5jxldKNC/1QKpbrcnl2veOpH3vyOs5VboMGU7K4YPmXlO9x915Xfu7XuHDo0g6sIfkQzH88c5cxjzzMu379K9UbvevS0k6sAeVlRXjXngdvw6dCB8WhV6n49zxoyQe2EvSgT0U517g+PbNHN++GRsHB9r1jqRj5CCCwiPMg/vVRcqhiwlMRE/zMoVCwdBpD/PzGy9xbOsGut86Fu/gkFrtL3F/NEaDHs+AIDwDgqotV64v5+PYj/nx+I8IBK4+au5I9CNh7242bV3O0MF3oqrh90kIQXHuBS6cP0te2jk8/AMJDLMcvyujxDQpqa9D495CvNHJBEZqdoxGA3lp53FvHXDNtyuE0chv898wN1y0trMj/JYoeoy+A2cv73o7155j7rj4DXEJ25Z8i9rGhm4jb6u3/VfISDzFkc1/AzD8ocfx7xxGj9vuIHb9ag7+tZK8tHOsW7SQpIP7GfOvF6/pQ6SCXqvl7y8+MvcA6XX7RAZNmV6vDUyvVUiP3vi2bU9GUgLrP/uA9v0G4NE6AHe/AGwdHc23kGrqQt3Q2vXuh7WdPdqyUryC2lx1xu7GoFSqGPfibNZ+8h4Je3ezeuHbjHr8GXOvKYD46J3mYfVHzvwXfh0udTu2UqsJ7taD4G49GD7jUdJOnSQ+egen9uykJD+PuK0bidu6Ea/AYEY9/mydapuKcy+YxldSKAgKj7BY59ehMx0jBxEfvYNtS77hrtnza/ybUFqQz76VvwKm3kfVOZJ9hNd3vk5KYYqprFtHEhWJxAUXEpbszPbvv+Wt859wS8gIZobPpJVjK8DUXuf4ji2cizvKhfPnyE07h668zLxfhVLJ/W9/hFdQGzJLMvk49mNWJ61GIAj1CCUqOIqRQSMJcAqo9c+oIRVqC0kuSCa5IJnc8lz6+PYh1CO0RfSGVYgbtBtFYWEhLi4uFBQU4OzsXPMGUrMgjEZWvj+fpAN7CQzrxshHnsLVt1Wd93Nk099s+OoT1LZ2RN51L12Hj8LG3qEBztj0DWznz9+b/mgqFIx+4jlzQ876YDQaWPr6i2SeTqDLoGGMfuoFi/Wa0lIO/b2G3SuWYjTo6dh/MLc99cI1JTGlhQWsfO8/pJ06gVKlYvhDT9B1+Kj6CqVeJB+K4fcFb1Zabu/iSmlBPgqFkn8tXo66iRvGb//pf+xf/Tt3vDibdleZ96kxGQ0GNnz9Kce2bABg2AMz6TF6HFkpp/l5zkvoNRp6jhnP0GkP125/RgNpJ09wMnoH8bu2UV5SjEKppM8dk+h35z21aidzbMsG/v7iI3zbdWDq/IWV1hdkZfK/5x/DoNNxy4OP0X3U2Gr3VVZcxIp5s8hOTcHRw5P7/vtBpcEMtQYtnx/+nO+OfYdRGPG282Zu/7kM8h9EgaaAbac3c/K971GV6Iltn8/h9gVYK62Z0uEeBhV04PCqlZVGb1Yolbj6+oEwkpeehk+79hTc1Y7FcYspN5h6USkVSoziUkPozu6diQqO4rY2t+Hn6Ffjz+l6CSFILUolNiuWYznHOF1wmuSCZHLKKk9m2dqxNaOCRzEqeBSd3TubkxmdUUdiXiJHc45yLOcYR3OOMn/gfLp4dKnXc63t57dMYKRmJfq3ny2G9baytqH/pCn0HDO+1h/I5cXFfPfsTMqKChk67WF6jhnfQGd7iRCCLYu/Inb9ahQKJWOffZkO/QbWy76PbFzPhq8/xdrOngc//LLa0WUTD+y9OEuynk4DhjD6qefrVGtSWljA8nmzuHAuFRsHB8Y9/1qlqvDmIj56J+dPxnHh/Flyz5+lOPeCeV2r9h2Z8p/3m/DsTIxGA+XFxVU2Nm5KQgi2LfnG3Lun97g7iY/eQWF2FkFduzPx1bnXlvwW5LPpuy84tcfUfdzDP5BRjz9DiYcKa5U1rRxaoVZVTmhWf/gOp6J34DeyPwW93UkpTMHNxo1WDq1o5diKVg6tyPgnmsMrTecbMWoMQ6c9UmkqB01pCSv+PZvM0wk4uLoxee7buLUy9R4r15eTUphCUn4S3x77loQ8063GsSFjebXPq7jYWF6j+OgdrPnwHRRWKhLucCU9IZ5uiS44l5rO397Vla7DR+Md1Ab31gG4+vqislJTeCGbb56bidDoiA69QHxQMd29u/NSr5do5diKzamb+efMP+zP2G9OZpQKJcMChjG181R6+fSqt5oPg9HA0ZyjHMw6yKGsQxzOPkxueW6VZb3tvWnj0gY7Kzv2pu+lTH+pRinQKZBevr1ILkjmxIUT5oSswuy+s5ncaXK9nHMFmcA0UAJTlJuDpqQEYTRiNBgwGg3m12pbO7yDQ1pE1VtzlHwoht/fngtCMPCeaaQeO2Qe9da7TVuiHn0anxq6Q4Kpu+ihv9fg4R/I/e98XOkPXUMRRiP/fPUJx7ZswEptzT3/fq9W53s1pYUF/O+5xygvLjJ/W76ahP3RrPngbYwGA10GDWPUE8/WKonRlJay4t+vk3k6AUd3D+6a/R88WjePKu7a0JaVknv+HPlZGfh16NwgPUiEEORr8nG2dq6xXURzJIQw/20SQrDn918sviy4tfJjyn8W1jhYX01O7d3Fpm8/p7QgH6GAY20KONQ+H2GlxMfeh9aOrfF38sfTzpPkvNP4/C8Ra52CvyIzyHbTVL1TAT1PexAebzo3n44dmfjiHHNyqCsv59f/ziEt/jjWjg60fngsiVbp5lsjacVpCC591LnbujOn3xyGBw2v+nBC8Ov8N0g9egilygqjwTQ2TJm1gaMhBeR3diCq3a2U6ErI1+RToCkgX5NPTlkO3id19Dvujk4tCH/5YW4LH1/pMyG3PJfNqZtZl7yOfRmXZkTv6NaRqZ2nclvIbdioqh7xuSbxufGsOb2GtafXklVmWVNkrbQm1DOUCK8I2ru1p41LG4Kdg3FQO5CZlMD5+OPYuruSZJPF1oJotp/fgcZgeU2c1E6EeYYR5hlGuGc4Ed4RuNnWblb62pIJTAMlMGs+fMc8OVhVvAKD6Tl2Ap0GDEZlVX/dDG90BVkZ/Pjqs5SXFNN1xK2MfOQphBDEbd3ItiXfmqume42dwIDJ91X7s80+k8ySV55BCCN3zf5PpXvqDc1oNPDnu/8mOfYAzl4+3LfgA+ycrv39989Xn3B00994BQZz39sf1eqbccLe3az+8G2E0UjokOFEPfb0VZMYnVbD7wve5NzxY9g6OXPP3Hfw8G85yUtjiMuJY2HMQvZl7MNJ7USEdwQ9fHrQ06cnoR6hWKus6+U4GoOGzJJMMkoyyCjNMD2XZKAxaPB18KWVQyv8HPzwdTS9trOqvlG13qjnZO5J9mfsZ3/Gfg5mHcRB7cCkDpO4q8NdeNp5cnDdarYs/hJrO3um/Of9ernuOWU5LNr9AVlrdhOSZrpte95bw4aeGXDFdzuvPGvGRLdCY2Vkz50quniF0s61HYWaQtJL0k2P4nSyy7IRCAIy7Rh02BNrvRKNgwKPe4cQ3qEfBz7/Dl1yFjq1YF2fdHJdKveMcrFxIcQlhFCPUB7p+gjutlfvlp2bdp4fXnoSg16PrZMzvcZOIK2Dgs+Of2lulFsVJysnJh9oiz4tjw6Rg7j92Veuepyk/CR+OvETq5NWm2s33KzdCPcOx0HtgIPaAUe1I/ZqexzVjqaHtaN5uaO1KanbkrqFNafXkJifeOlcrJ3o49uHCK8IIrwj6OLRxeK9WlpYwIkdWzm25R9yzp6xOC9rOzvc/QPRuFuR72IgKDyCHh0HEOQchFLRsB2YZQLTQAnMhq8+JWHfbpQqFQqlEqVKhVJpel2Um2Puluvo5k730ePoOuJWi/EojEYDxRcuUJCdiVJlhV+HTjd9jY1Oq+GXOS+TlZyEb9v2TJ73rsW985L8PDYv/opTFxPHwPAIxj3/Gjb29hb7EUKwbO6rnD8ZR4e+A7j9+VmNGkeF8uJifnztWQoyMwju1oMJr755TQ1g0xPiWfrGiyAEk+e9g3+n0Fpve2rPTtZ89K4piRk6gqiZ/6oy+THo9ax6fz6nD+7H2s6Ou+csaLLuvs3RuaJzfBz7MeuS11VbxlppTVevrgwLGEZUcFS1PU2EEBzLOca6lHUcyT5Cmb6Mcn055YZyNAYN5frySt92a+Jk7YSztbPpYeNsfp1VmsXBrIOU6Eqq3E6tVDMqeBRTOk3Bt9QRGwfH66610hq0/HjiR7468pX5uOONA/DYlIlBp6PHpEm4De7G+eLznCs6R3ZZNu4xhZRtP05In35MeGF2tfvWGXQkFSSx7ew2oo9uInBTAS6lavRKI3nOOrzybdCpjPzTJ5M8dyOdPTrT1asrbV3b0sa5DSGuITUmLFU5c/QQeelpdBk0FGs7098bjUHDb6d+I6UwBVcbV4uHi60Lwc7BFJ/L4KfXnkMYjUx49U1CuveutG9hNJJ4YA85Z89QkpdLXk4m5zOSKcnLxVoDWrWRAgcdRfZ6Chx0FDroKbLXIRSg1itRGxRY6ZWo9QqsDAqK7Q3kOmnR2SsZGjiUMW3GMMh/kEXCIoSgrKiQjMRTxG3dSOKBvebaJSu1NQFhXSnOyyX3XGqVIxK7tfIjuFtPgiN6ENAlvNIkovVFJjBN0AamvLiYwxvXEbt+NSV5pnuNals72nTvRXmRaYTQogs5FqNhegWH0Hf83bTvG9ksenk0NiEEf3/xEXFbN2Ln5Mx9b3+Is2fVvYQS9u1m3acL0WnK8Q5uy8RZcy3ag5zYtY21H7+HlbUNMz74vNr9NIbsM8ksnf0ieq2GfhMnM2Dy/XXa3tRw9wUyTycSOmQ4tz7xXJ3PIT56B399/B7CaMTBzZ0ug28hbOgI3P38zcdY9+lCTu7ahpXamjtfewv/LmF1Ps6NKL88ny+PfMkv8b+gN+pRoGBsyFgej3icQm0hBzMPmh5ZByu1K+ju3d3cANLD1oOE/ATWJ69nXfI6zhWfq/HYtipbfB188XHwwdfeF18HX2ytbMkoySCtOI30knTSitMo1ZfWuC8nayd6+fSil08vevr2JKUghaUnl3Ik+9KcP+Ge4YwJGUOYZxgd3Tpia1Xzh1J+eT4phSkkFySTUpjCmcIzHM05Slap6ZZFmEcYr/R5hQjvCHODeoVSyd1z/ot/50vvsaWvv0B6YjxRjz1N+LCoGo9b4Wx2Mqs+XEB5ommwPaNKgdOUAfTqNYIwz7BaxdDQti75lpg1f+Ds5c0D//eZRaPy8yePs+X7r8y95uqTtb093kEheAYG4+7XmtLCAvNEmfmZaWhKLJNan5D2hA0bSacBg81ftg16PXnp58k+k0x2agrpp06SduqExWeXSq3Gv3MYvW6feE3jJV2NTGCasBGvXqcjfvd2Dqz+vVK1HIBSZYWzlxcl+fnm7ndufv70ueMuOg8c2mhtNpqDI5vWs+GrT1EolNz5+ls13vLJSErg97fnUlZYgIuPL3fOmodbq9Zoy0r533OPUZyXy4DJ99NvYv02KrsWJ3ZsYe2npsakd7z0Rq16ogghSDl8kF3LlpB5OhEbewdmfPBFtQ13a3Jqz042fvMZZUWF5mV+HToTOnQEmacTOLJxPUqVijteml3lt8SbTWphKsvjl/N7wu8U6UxzLkW2iuS5ns/R2aNzpfJCCFIKU9idtpu/U/4mNivWvE6BAl8HX9JL0s3L7KzsGBowlKH+Q3G1dcVWZYutlS22KltsrGxwVDvibO1cY62sEIJCbSEXyi5QqC2kUFtIgabA/NpJ7UQv3160d21fZXuduJw4lp5cyrrkdeiMl263qBQq2rm2I9QzlC7uXVAqlWSVZpFVmkVmSSaZpaZHkbbq+ai87bx5tuezjAkZY77NIIRg3aKFnNixBUc3d+5/52PsXVwpKyrks0emghDM/HyxxbQdtWE0Gti9fCkJ+3ZzywOPmgcPbC605WUsfuEJinKy6T3uTgZPnUFhTjY7li42D1FgbWdPh34DcHT3xNHNHUd3dxxc3bF3caW8uIi89PPkpaddfD5PQVYmCoUCta0tals7rG3tsLa1RWmlpiAzndy0c7WaLsLJ04v2vSMJGzYSr6A2tYpHU1pKatxhUmJjSD4cQ1FONgC3P/dqvXVYqCATmGbQC0kIwZkjsWQmJ+Hk4YmzlzcuXj44urmjUCopKy4idt0qYtetNg945ezlTZfBw7Gxt8dKbY1KrTY9rNQYL87ZU1pYQGlBAaWF+ZQVFqC2tcOnTVu827TFJ6Qdrt6+VQ4vL4RAr9Wg1+kQBsPFRshGhNH07OTuiZV1/dzLr4muvJyT0dvZ9M1nGPR6Bt473TyBW03yMtL47b9zKMjMwM7ZhYmvvMmpvbvYv+o3XHx8eeD/Pmu0OGqy+X9fErt+NdZ29ty34ANzr4iqnDtxjJ2/LOH8yTjAVHs36rFn6Bh5fX8cDHodp2P2c2zrBpJjYxCXdeVEoWDM0y9ddbK7G53BaGD7ue0si1/GrrRd5uUd3TryfM/n6d+68oBv1ckoyeCflH/4O+VvjuSYajmsldYM8h/ErW1uZXDrwdir7WvYS+O5UHaBlUkricmM4VjOsWp7qVTF18GXYOdg08MlmDbObeju073Kdjna8jJ+mvUcuWnnTD2dZs3lVPRO/vr4PTwDg5n+3qf1GVazkRSzjz/ffQuFUkn3UWM5sulv9FoNKBSE3xLFwMn31+vM7nqdjtzzZ801J/kZaTi4uOHq2wrXVn64+bTCxbcVautrayBcQQhB7vlzpByOIXToiDpN21EbMoFpBglMbWnLSjn0z1pi/vqT0oL8696ftZ093m1CsLF3pLy4yPQoKaa8uAiDrvphv63U1vh16kJQeASBYd3wbhNivq0ljEbyM9PJSjlNZnISF86ewcbBERdvX1x9fHHx8cXV2xcHV7dq5+YRRiPnThwjbttmTu3dZa59atc7knEvvFantkAl+Xn8/vZcspKTUNvYYtDrMRr0jH95Dm179qnDT6thGfQ6lr/1Omnxx/HwD2TK/PdRqqww6LTotVr0Wg1FF3LY9+cKki+ORqpSq4mIGkOf8ZPqvQtucV4ux7dv5tjWjeRnpDHi4Seb3TgvDaFQW0heeR7F2mIKtYUU64op0haRVpzGqqRV5loSBQoGth7I5I6TGeQ/6LoaK54vPs/p/NNEeEfgZO1UX6E0GCEEmaWZxF2IIy4njhO5J1AqTD2HvO298bH3Mb/2c/SrcyKWc/YMP732PHqthv53T6UgM4O4bZvodftEhtz3YANF1fRWLfwvCXt3m//fulMowx6Yed09FG9kN0QCs2jRIt577z0yMjLo1q0bn3zyCX361O7DqTETmIySDPak7yG5IBkXGxfcbd0tHh52HrXqEqfTaojbuomMpFMYdDoMeh0GnQ69zvSsslJh5+yKvYsL9s6u2Du7YOfsTFlREVnJSWQmJ5J9JvmqScrlFEolSqUShUoFAtM3g8vYOjjSunMYmpJislKS0JaVVbOnS1RqNQ6ubtg7u2Dv4ordxWeEID56J4XZmeayrj6tCB06gp5j7rimxmDaslJWLVzAmSOmavs23Xsx8dW5dd5PQyvOy+XHV5+hJD/vquWUKhVhw0bSb+I9OHnUrTq9roQQ6DWaJh/srSHklOVw4sIJTuSe4PiF45y4cIK0krSrbuNq48qE9hOY1GFSsxkh9UYUt20T6z/7AIVCiZWNDbryMia9Mb/ZjjdUH4pyc1g6+0UUCgWDp86gY+Sgm77jRk1afAKzbNkypk2bxhdffEHfvn358MMPWbFiBfHx8Xh719w4syETmGJtMQcyDxCdFk10ejTJBclXLW+lsCLMM4zevr3p7dubCO+Iq3Z/vB4GvZ7c82fJTE7CoNNh6+iEraMjto5O2Dk6YePgiNrWBoVCafFLZKoSPMuZo4dJPXaIs3FH0ZZZNhJUqdV4BQbj3aYtnoHBaMvKKMjKoCAzg4KsDApzshFG45WnZMHazp6OkQMJHTICv46dr/sX2aDXsWXx16QnxHP787Nw9Wmec42cOxnHb/PnWCaJCgVWamvUNja0iehJ5F1TrmnU4easoq1Gdmk22WXZ5JTlkFeeR4G2wNRmQ1Nofq1AgYuNi/nhauOKi40LOoOOnLIcLpRfIKcsh5yyHHLLc9EatICp1qSie64QgmJdcZXnYm9lj5O1k8XD2dqZSL9IRgWPuuZxN6S6Wf/5h8Rt3QiYJtB84tuf63Vm6+bIoNebeq7KxKVWWnwC07dvX3r37s2nn5rujRqNRgICAvjXv/7Fq6++WuP2DZXAvLbjNdYmr8UgLjWUUiqUhHqEEuoRSqm+lAvlF8gtyyVPk0duWS5ao9ZiH2qlmnDPcMI9w3GwdsBOZYedlR12atOzwWgguyzb/Ec/uyybnNIcyg3lqJVqrFXWWCutTc8qawQCnUGH1qBFa9Sang1aU2MvpRoblQ3WKmvztmqlGpVChUqpsngWCAxGAwZhQKfXYpVZhk1GOdirMXo7oPRwxMba1nxslVKFEiVKhemhEECRBmWpHmWZHkWpHkq1iFItCq0Bx3YBeIZ3wtbOARuVDTYqG9Sqi+dy8aFUKE3PSiVGoxGDMGAUl54FAgUKc1nzsS//w3DZO9ogDOiMOrRGLTqDDp1Rh86gw4gRtVKNldIKK6WV+bVKoTJ/ICpQmPZ98dNRIKj4dREX/1Wco96oR2fUoTfqMQiD+edoFEbzQ1tWCnqjOWlRWV065uX7FEKYB91SKpTmn7FKeenno7hyQI1qzg8wL6v4+VWMAFrxf/PxhGk7vVFfuWGoppByQ7l5XIqKMSgc1A4oFAryy/PJ0+RZPFckHJc3Em0MChQEuwTT2b0zXTy60MWjCx3dO+Js3bxvJd8sdJpylr7+AjlnzxDSozcTXqk8LYR0c6vt53ez7O6i1WqJiYlh1qxL43golUpGjBhBdHR0ldtoNBo0mkvfbgsLC6ssd70crR0xCAOBToFE+kXSr1U/evv2rjQUdQUhBOeLz5sHk9qXsY/M0kwOZpm6YDZ7FXcyii8+6srm4gOgCNh9lbLSDcvFxgUvOy887Dxwt3XH1cYVZ2tnc21LRXJRMappxcim+Zp81Eq1eVtPO0887DzwsPXA1srWItmrSMJ8HHxwUDfMvFfS9VPb2DL+5TfY+8dyut96e1OfjtSCNcsEJicnB4PBgI+Pj8VyHx8fTp48WeU2CxYsYN68eQ1+btNDpzM9dDqtHavvTXI5hUKBv5M//k7+TGg/ASEEZ4vOsi9jH8kFyZTpyyo9lAolXnZeeNl74WXnhaedJ172Xthb2VeqZdEYNCgUCosamYrXAmEud/l2FbUERmFEb9SbazgUKFApVVgprCxqZ/RGPRqDxny8itcVNQsV3+orHpfXSBiMl15X1IJcvg+NQWPexiBMvaH0Qm8e9tyiVuZiTYtRGBFCYBCGS8+XVbtU1E4oFAqUKFGr1KiVaosaKKVCaa4xufz5ylqJimfzPi+7XVFRE2SltDL/3CpqdK4874oHgF7oMRgv/vwvvq7Yn0Jx8XFZrU9VNVHVvt8ui/3y/1f87CqOoeRSrZV5+cV1KoXKYmC0igTDWmVNmb6MEl2JxcNgNOBq64qbrRtuNm642lx8betmfv/W10i10o3BxduXqEefburTkFq4ZpnAXItZs2bx/PPPm/9fWFhIQED9N8arbeJSHYVCQaBzIIHOgfV0RpIkSZJ082mWCYynpycqlYrMzEyL5ZmZmfj6Vt1I08bGBhsb2QhPkiRJkm4GDTsj0zWytramZ8+ebNq0ybzMaDSyadMmIiMjm/DMJEmSJElqDpplDQzA888/z/Tp0+nVqxd9+vThww8/pKSkhBkzZjT1qUmSJEmS1MSabQIzefJksrOzmTNnDhkZGURERLB+/fpKDXslSZIkSbr5NNtxYK5XS5pKQJIkSZIkk9p+fjfLNjCSJEmSJElXIxMYSZIkSZJaHJnASJIkSZLU4sgERpIkSZKkFkcmMJIkSZIktTgygZEkSZIkqcWRCYwkSZIkSS2OTGAkSZIkSWpxZAIjSZIkSVKL02ynErheFQMMFxYWNvGZSJIkSZJUWxWf2zVNFHDDJjBFRUUABAQENPGZSJIkSZJUV0VFRbi4uFS7/oadC8loNJKWloaTkxMKhaLe9ltYWEhAQABnz569KeZYupnilbHeuG6meGWsN66bJV4hBEVFRfj5+aFUVt/S5YatgVEqlfj7+zfY/p2dnW/oN9CVbqZ4Zaw3rpspXhnrjetmiPdqNS8VZCNeSZIkSZJaHJnASJIkSZLU4sgEpo5sbGx48803sbGxaepTaRQ3U7wy1hvXzRSvjPXGdbPFW5MbthGvJEmSJEk3LlkDI0mSJElSiyMTGEmSJEmSWhyZwEiSJEmS1OLIBEaSJEmSpBbnpkxgtm/fzu23346fnx8KhYI///zTYn1mZiYPPPAAfn5+2Nvbc+utt5KQkGBRZujQoSgUCovHY489ZlEmNTWVMWPGYG9vj7e3Ny+99BJ6vb6hw6ukPuIFiI6O5pZbbsHBwQFnZ2cGDx5MWVmZeX1ubi5Tp07F2dkZV1dXHnroIYqLixs6PAvXG2tKSkql61rxWLFihblcc7i29XFdMzIyuP/++/H19cXBwYEePXrw22+/WZRpDtcV6ifepKQkJkyYgJeXF87Oztx9991kZmZalGkO8S5YsIDevXvj5OSEt7c348ePJz4+3qJMeXk5Tz75JB4eHjg6OnLnnXdWiqU279OtW7fSo0cPbGxsaNeuHYsXL27o8CzUV6xPP/00PXv2xMbGhoiIiCqPdeTIEQYNGoStrS0BAQG8++67DRVWleoj1sOHD3PvvfcSEBCAnZ0dnTt35qOPPqp0rKa+ro3hpkxgSkpK6NatG4sWLaq0TgjB+PHjOX36NCtXriQ2NpagoCBGjBhBSUmJRdlHHnmE9PR08+PyXwaDwcCYMWPQarXs3r2b77//nsWLFzNnzpwGj+9K9RFvdHQ0t956K1FRUezbt4/9+/fz1FNPWQzzPHXqVOLi4tiwYQNr1qxh+/btzJw5s1FirHC9sQYEBFhc0/T0dObNm4ejoyOjR48Gms+1rY/rOm3aNOLj41m1ahVHjx5l4sSJ3H333cTGxprLNIfrCtcfb0lJCVFRUSgUCjZv3syuXbvQarXcfvvtGI1G876aQ7zbtm3jySefZM+ePWzYsAGdTkdUVJTFtXvuuedYvXo1K1asYNu2baSlpTFx4kTz+tq8T5OTkxkzZgzDhg3j0KFDPPvsszz88MP8/fffLSrWCg8++CCTJ0+u8jiFhYVERUURFBRETEwM7733HnPnzuWrr75qsNiuVB+xxsTE4O3tzY8//khcXByvv/46s2bN4tNPPzWXaQ7XtVGImxwg/vjjD/P/4+PjBSCOHTtmXmYwGISXl5f4+uuvzcuGDBkinnnmmWr3u3btWqFUKkVGRoZ52eeffy6cnZ2FRqOp1xjq4lrj7du3r5g9e3a1+z1+/LgAxP79+83L1q1bJxQKhTh//nz9BlFL1xrrlSIiIsSDDz5o/n9zvLbXGquDg4P44YcfLPbl7u5uLtMcr6sQ1xbv33//LZRKpSgoKDCXyc/PFwqFQmzYsEEI0XzjzcrKEoDYtm2bEMJ03mq1WqxYscJc5sSJEwIQ0dHRQojavU9ffvllERoaanGsyZMni1GjRjV0SNW6llgv9+abb4pu3bpVWv7ZZ58JNzc3i9/RV155RXTs2LH+g6il6421whNPPCGGDRtm/n9zvK4N4aasgbkajUYDgK2trXmZUqnExsaGnTt3WpT96aef8PT0JCwsjFmzZlFaWmpeFx0dTXh4OD4+PuZlo0aNorCwkLi4uAaOovZqE29WVhZ79+7F29ub/v374+Pjw5AhQyx+HtHR0bi6utKrVy/zshEjRqBUKtm7d28jRXN1dbm2FWJiYjh06BAPPfSQeVlLuLa1jbV///4sW7aM3NxcjEYjv/zyC+Xl5QwdOhRoGdcVahevRqNBoVBYDAJma2uLUqk0l2mu8RYUFADg7u4OmN6XOp2OESNGmMt06tSJwMBAoqOjgdq9T6Ojoy32UVGmYh9N4VpirY3o6GgGDx6MtbW1edmoUaOIj48nLy+vns6+buor1oKCAvM+oHle14YgE5grVLxZZs2aRV5eHlqtlnfeeYdz586Rnp5uLjdlyhR+/PFHtmzZwqxZs1iyZAn33XefeX1GRobFHw7A/P+MjIzGCaYWahPv6dOnAZg7dy6PPPII69evp0ePHgwfPtzcxiAjIwNvb2+LfVtZWeHu7t5s4q3ttb3ct99+S+fOnenfv795WUu4trWNdfny5eh0Ojw8PLCxseHRRx/ljz/+oF27dkDLuK5Qu3j79euHg4MDr7zyCqWlpZSUlPDiiy9iMBjMZZpjvEajkWeffZYBAwYQFhYGmM7T2toaV1dXi7I+Pj7m86zN+7S6MoWFhRbt2xrLtcZaG83t97a+Yt29ezfLli2zuM3Z3K5rQ5EJzBXUajW///47p06dwt3dHXt7e7Zs2cLo0aMt2nvMnDmTUaNGER4eztSpU/nhhx/4448/SEpKasKzr7vaxFvRPuDRRx9lxowZdO/enQ8++ICOHTvy3XffNeXp10ltr22FsrIyli5dalH70lLUNtY33niD/Px8Nm7cyIEDB3j++ee5++67OXr0aBOefd3VJl4vLy9WrFjB6tWrcXR0xMXFhfz8fHr06FHl9W8unnzySY4dO8Yvv/zS1KfS4GSsdXPs2DHuuOMO3nzzTaKiourx7FoGq6Y+geaoZ8+eHDp0iIKCArRaLV5eXvTt29eiWvlKffv2BSAxMZG2bdvi6+vLvn37LMpUtCT39fVtuJO/BjXF26pVKwC6dOlisV3nzp1JTU0FTDFlZWVZrNfr9eTm5jareOtybX/99VdKS0uZNm2axfKWcm1rijUpKYlPP/2UY8eOERoaCkC3bt3YsWMHixYt4osvvmgx1xVqd22joqJISkoiJycHKysrXF1d8fX1JSQkBGh+7+OnnnrK3JDY39/fvNzX1xetVkt+fr7Ft/XMzEzzedbmferr61upN09mZibOzs7Y2dk1REjVup5Ya6O6WCvWNab6iPX48eMMHz6cmTNnMnv2bIt1zem6NqTm+7WjGXBxccHLy4uEhAQOHDjAHXfcUW3ZQ4cOAZc+7CMjIzl69KjFH8MNGzbg7OxcKRFoLqqLNzg4GD8/v0rd/U6dOkVQUBBgijc/P5+YmBjz+s2bN2M0Gs3JXXNSm2v77bffMm7cOLy8vCyWt7RrW12sFW22rqx9UKlU5lq3lnZdoXbX1tPTE1dXVzZv3kxWVhbjxo0Dmk+8Qgieeuop/vjjDzZv3kybNm0s1vfs2RO1Ws2mTZvMy+Lj40lNTSUyMhKo3fs0MjLSYh8VZSr20RjqI9baiIyMZPv27eh0OvOyDRs20LFjR9zc3K4/kFqor1jj4uIYNmwY06dPZ/78+ZWO0xyua6No4kbETaKoqEjExsaK2NhYAYiFCxeK2NhYcebMGSGEEMuXLxdbtmwRSUlJ4s8//xRBQUFi4sSJ5u0TExPFW2+9JQ4cOCCSk5PFypUrRUhIiBg8eLC5jF6vF2FhYSIqKkocOnRIrF+/Xnh5eYlZs2a1uHiFEOKDDz4Qzs7OYsWKFSIhIUHMnj1b2NraisTERHOZW2+9VXTv3l3s3btX7Ny5U7Rv317ce++9LS5WIYRISEgQCoVCrFu3rtK65nJtrzdWrVYr2rVrJwYNGiT27t0rEhMTxf/93/8JhUIh/vrrL3O55nBd6yNeIYT47rvvRHR0tEhMTBRLliwR7u7u4vnnn7co0xziffzxx4WLi4vYunWrSE9PNz9KS0vNZR577DERGBgoNm/eLA4cOCAiIyNFZGSkeX1t3qenT58W9vb24qWXXhInTpwQixYtEiqVSqxfv75FxSqE6Xc2NjZWPProo6JDhw7m90pFr6P8/Hzh4+Mj7r//fnHs2DHxyy+/CHt7e/Hll1+2qFiPHj0qvLy8xH333Wexj6ysLHOZ5nBdG8NNmcBs2bJFAJUe06dPF0II8dFHHwl/f3+hVqtFYGCgmD17tkXXu9TUVDF48GDh7u4ubGxsRLt27cRLL71k0T1TCCFSUlLE6NGjhZ2dnfD09BQvvPCC0Ol0jRmqEOL6462wYMEC4e/vL+zt7UVkZKTYsWOHxfoLFy6Ie++9Vzg6OgpnZ2cxY8YMUVRU1BghmtVXrLNmzRIBAQHCYDBUeZzmcG3rI9ZTp06JiRMnCm9vb2Fvby+6du1aqVt1c7iuQtRPvK+88orw8fERarVatG/fXrz//vvCaDRalGkO8VYVJyD+97//mcuUlZWJJ554Qri5uQl7e3sxYcIEkZ6ebrGf2rxPt2zZIiIiIoS1tbUICQmxOEZjqK9YhwwZUuV+kpOTzWUOHz4sBg4cKGxsbETr1q3F22+/3UhRmtRHrG+++WaV+wgKCrI4VlNf18agEEKI+qrNkSRJkiRJagyyDYwkSZIkSS2OTGAkSZIkSWpxZAIjSZIkSVKLIxMYSZIkSZJaHJnASJIkSZLU4sgERpIkSZKkFkcmMJIkSZIktTgygZEkSZIkqcWRCYwkSZIkSS2OTGAkSZIkSWpxZAIjSdJNxWAwmGfbliSp5ZIJjCRJTeaHH37Aw8MDjUZjsXz8+PHcf//9AKxcuZIePXpga2tLSEgI8+bNQ6/Xm8suXLiQ8PBwHBwcCAgI4IknnqC4uNi8fvHixbi6urJq1Sq6dOmCjY0NqampjROgJEkNRiYwkiQ1mUmTJmEwGFi1apV5WVZWFn/99RcPPvggO3bsYNq0aTzzzDMcP36cL7/8ksWLFzN//nxzeaVSyccff0xcXBzff/89mzdv5uWXX7Y4TmlpKe+88w7ffPMNcXFxeHt7N1qMkiQ1DDkbtSRJTeqJJ54gJSWFtWvXAqYalUWLFpGYmMjIkSMZPnw4s2bNMpf/8ccfefnll0lLS6tyf7/++iuPPfYYOTk5gKkGZsaMGRw6dIhu3bo1fECSJDUKmcBIktSkYmNj6d27N2fOnKF169Z07dqVSZMm8cYbb+Dl5UVxcTEqlcpc3mAwUF5eTklJCfb29mzcuJEFCxZw8uRJCgsL0ev1FusXL17Mo48+Snl5OQqFogkjlSSpPlk19QlIknRz6969O926deOHH34gKiqKuLg4/vrrLwCKi4uZN28eEydOrLSdra0tKSkpjB07lscff5z58+fj7u7Ozp07eeihh9Bqtdjb2wNgZ2cnkxdJusHIBEaSpCb38MMP8+GHH3L+/HlGjBhBQEAAAD169CA+Pp527dpVuV1MTAxGo5H3338fpdLUpG/58uWNdt6SJDUdmcBIktTkpkyZwosvvsjXX3/NDz/8YF4+Z84cxo4dS2BgIHfddRdKpZLDhw9z7Ngx/vOf/9CuXTt0Oh2ffPIJt99+O7t27eKLL75owkgkSWossheSJElNzsXFhTvvvBNHR0fGjx9vXj5q1CjWrFnDP//8Q+/evenXrx8ffPABQUFBAHTr1o2FCxfyzjvvEBYWxk8//cSCBQuaKApJkhqTbMQrSVKzMHz4cEJDQ/n444+b+lQkSWoBZAIjSVKTysvLY+vWrdx1110cP36cjh07NvUpSZLUAsg2MJIkNanu3buTl5fHO++8I5MXSZJqTdbASJIkSZLU4shGvJIkSZIktTgygZEkSZIkqcWRCYwkSZIkSS2OTGAkSZIkSWpxZAIjSZIkSVKLIxMYSZIkSZJaHJnASJIkSZLU4sgERpIkSZKkFuf/AaFoRR+6+Zl0AAAAAElFTkSuQmCC", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "timeseries.set_index('year').sort_index().plot.line()\n" - ] - }, - { - "cell_type": "markdown", - "id": "4b15e937", - "metadata": {}, - "source": [ - "### Downloading to Local Pandas (Optional Handoff)\n", - "\n", - "If you need to use local Python libraries that are not supported by BigFrames (such as custom plotting libraries or local ML frameworks), you can explicitly download the final transformed remote DataFrame into a standard local Pandas DataFrame using `.to_pandas()`:\n" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "97757974", - "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", - "
Time periodBeginning stocksProductionImports 2Total supply 3Food useSeed useFeed and residual useTotal domestic use 3Exports 2Total disappearance 3Ending stocks
year
1950-01-01 00:00:00+00:00MY Jun-May496.01019.011.01526.0580.0--109.0689.0345.01034.0492.0
1951-01-01 00:00:00+00:00MY Jun-May492.0988.030.01510.0585.0--110.0695.0485.01180.0330.0
1952-01-01 00:00:00+00:00MY Jun-May330.01306.024.01660.0578.0--78.0656.0332.0988.0672.0
1953-01-01 00:00:00+00:00MY Jun-May672.01173.06.01851.0556.0--87.0643.0214.0857.0994.0
1954-01-01 00:00:00+00:00MY Jun-May994.0984.03.01981.0552.0--53.0605.0267.0872.01109.0
.......................................
2022-01-01 00:00:00+00:00MY Jun-May674.4311649.713121.5852445.729971.67768.36975.5031115.549760.6121876.161569.568
2023-01-01 00:00:00+00:00MY Jun-May569.5681803.942137.7982511.308961.30362.04685.6171108.966705.9081814.874696.434
2024-01-01 00:00:00+00:00MY Jun-May696.4341978.697148.9542824.085969.49361.1112.8631143.456825.8951969.351854.734
2025-01-01 00:00:00+00:00MY Jun-May854.7341984.537125.02964.271960.059.7100.01119.7910.02029.7934.571
2026-01-01 00:00:00+00:00MY Jun-May934.5711561.322140.02635.893960.05980.01099.0775.01874.0761.893
\n", - "

77 rows × 12 columns

\n", - "
" - ], - "text/plain": [ - " Time period Beginning stocks Production \\\n", - "year \n", - "1950-01-01 00:00:00+00:00 MY Jun-May 496.0 1019.0 \n", - "1951-01-01 00:00:00+00:00 MY Jun-May 492.0 988.0 \n", - "1952-01-01 00:00:00+00:00 MY Jun-May 330.0 1306.0 \n", - "1953-01-01 00:00:00+00:00 MY Jun-May 672.0 1173.0 \n", - "1954-01-01 00:00:00+00:00 MY Jun-May 994.0 984.0 \n", - "... ... ... ... \n", - "2022-01-01 00:00:00+00:00 MY Jun-May 674.431 1649.713 \n", - "2023-01-01 00:00:00+00:00 MY Jun-May 569.568 1803.942 \n", - "2024-01-01 00:00:00+00:00 MY Jun-May 696.434 1978.697 \n", - "2025-01-01 00:00:00+00:00 MY Jun-May 854.734 1984.537 \n", - "2026-01-01 00:00:00+00:00 MY Jun-May 934.571 1561.322 \n", - "\n", - " Imports 2 Total supply 3 Food use Seed use \\\n", - "year \n", - "1950-01-01 00:00:00+00:00 11.0 1526.0 580.0 -- \n", - "1951-01-01 00:00:00+00:00 30.0 1510.0 585.0 -- \n", - "1952-01-01 00:00:00+00:00 24.0 1660.0 578.0 -- \n", - "1953-01-01 00:00:00+00:00 6.0 1851.0 556.0 -- \n", - "1954-01-01 00:00:00+00:00 3.0 1981.0 552.0 -- \n", - "... ... ... ... ... \n", - "2022-01-01 00:00:00+00:00 121.585 2445.729 971.677 68.369 \n", - "2023-01-01 00:00:00+00:00 137.798 2511.308 961.303 62.046 \n", - "2024-01-01 00:00:00+00:00 148.954 2824.085 969.493 61.1 \n", - "2025-01-01 00:00:00+00:00 125.0 2964.271 960.0 59.7 \n", - "2026-01-01 00:00:00+00:00 140.0 2635.893 960.0 59 \n", - "\n", - " Feed and residual use Total domestic use 3 \\\n", - "year \n", - "1950-01-01 00:00:00+00:00 109.0 689.0 \n", - "1951-01-01 00:00:00+00:00 110.0 695.0 \n", - "1952-01-01 00:00:00+00:00 78.0 656.0 \n", - "1953-01-01 00:00:00+00:00 87.0 643.0 \n", - "1954-01-01 00:00:00+00:00 53.0 605.0 \n", - "... ... ... \n", - "2022-01-01 00:00:00+00:00 75.503 1115.549 \n", - "2023-01-01 00:00:00+00:00 85.617 1108.966 \n", - "2024-01-01 00:00:00+00:00 112.863 1143.456 \n", - "2025-01-01 00:00:00+00:00 100.0 1119.7 \n", - "2026-01-01 00:00:00+00:00 80.0 1099.0 \n", - "\n", - " Exports 2 Total disappearance 3 Ending stocks \n", - "year \n", - "1950-01-01 00:00:00+00:00 345.0 1034.0 492.0 \n", - "1951-01-01 00:00:00+00:00 485.0 1180.0 330.0 \n", - "1952-01-01 00:00:00+00:00 332.0 988.0 672.0 \n", - "1953-01-01 00:00:00+00:00 214.0 857.0 994.0 \n", - "1954-01-01 00:00:00+00:00 267.0 872.0 1109.0 \n", - "... ... ... ... \n", - "2022-01-01 00:00:00+00:00 760.612 1876.161 569.568 \n", - "2023-01-01 00:00:00+00:00 705.908 1814.874 696.434 \n", - "2024-01-01 00:00:00+00:00 825.895 1969.351 854.734 \n", - "2025-01-01 00:00:00+00:00 910.0 2029.7 934.571 \n", - "2026-01-01 00:00:00+00:00 775.0 1874.0 761.893 \n", - "\n", - "[77 rows x 12 columns]" - ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "pddf = timeseries.set_index('year').sort_index().to_pandas()\n", - "pddf\n" - ] - }, - { - "cell_type": "markdown", - "id": "9c1242ab", - "metadata": {}, - "source": [ - "## Conclusion: The Power of Hybrid Chaining\n", - "\n", - "By leveraging BigQuery DataFrames and the `%%bqsql` magic, you have built a powerful, interoperable pipeline that seamlessly transitions between SQL and Python.\n", - "\n", - "This hybrid approach offers several key benefits:\n", - "- **Optimal Tool Selection**: Use SQL for what it does best (complex queries, window functions, regex extractions on large sets) and Python for what it does best (visualization, statistical analysis, ML, orchestrating workflow).\n", - "- **Improved Readability**: Instead of massive, unreadable SQL queries with dozens of CTEs, or long, complex Pandas method chains, you can split your pipeline into logical steps, alternating between SQL and Python.\n", - "- **Seamless Scaling**: The exact same `%%bqsql` code can scale from a tiny local Pandas DataFrame to billions of rows in a production BigQuery table. You only need to swap the initial local Pandas DataFrame with a BigQuery DataFrame reference.\n", - "\n", - "\n", - "## Next Steps\n", - "\n", - "In addition to the `%%bqsql` cell magic, BigFrames also registers a **BigQuery Accessor** on standard Pandas DataFrames, allowing you to run SQL scalar functions directly on local pandas data. \n", - "\n", - "For example, you can call powerful Google Cloud community UDFs from [BigQuery Utils](https://github.com/GoogleCloudPlatform/bigquery-utils/tree/master/udfs#bigquery-udfs), [BigFunctions](https://unytics.io/bigfunctions/bigfunctions/#function-categories), or [CARTO Analytics Toolbox for BigQuery](https://docs.carto.com/data-and-analysis/analytics-toolbox-for-bigquery) using `df.bigquery.sql_scalar(...)`:\n" - ] - }, - { - "cell_type": "markdown", - "id": "6a7928bd", - "metadata": {}, - "source": [ - "### Scaling Up: Advanced BigQuery Features\n", - "\n", - "While the BigQuery sandbox offers a powerful environment to test these hybrid Python-SQL workflows for free, some advanced features like BigQuery Machine Learning (BQML) are restricted. By connecting a billing account to your Google Cloud project, you can unlock advanced capabilities such as `ML.FORECAST` (or the `AI.FORECAST` function) to predict time-series data using Google's state-of-the-art foundational models directly from your SQL/Python chain.\n", - "\n", - "### Feedback & Community\n", - "\n", - "The BigFrames team would love to hear your feedback on the hybrid Python-SQL experience:\n", - "* **Email**: [bigframes-feedback@google.com](mailto:bigframes-feedback@google.com)\n", - "* **Issues**: File bug reports or feature requests on the [open-source BigFrames repository](https://github.com/googleapis/google-cloud-python/issues).\n", - "* **Updates**: To receive news and updates, subscribe to the [BigFrames email list](https://docs.google.com/forms/d/10EnDyYdYUW9HvelHYuBRC8L3GdGVl3rX0aroinbRZyc/edit?resourcekey=0-QUsnpzF91gm9hsp04rSA6Q).\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "bc1a6dbe-170e-4380-83da-779f37e1c00a", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3 (ipykernel)", - "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.14.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/experimental/ai_operators.ipynb b/notebooks/experimental/ai_operators.ipynb index e054484a0bf..977f7b9d741 100644 --- a/notebooks/experimental/ai_operators.ipynb +++ b/notebooks/experimental/ai_operators.ipynb @@ -1,13 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "id": "title-cell", - "metadata": {}, - "source": [ - "# AI Operators (Experimental)" - ] - }, { "cell_type": "code", "execution_count": 1, @@ -37,11 +29,3111 @@ "id": "rWJnGj2ViouP" }, "source": [ - "All AI functions have moved to the `bigframes.bigquery.ai` module.\n", + "# BigFrames AI 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": 2, + "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.42.0`" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "LTPpI8IpiouQ" + }, + "outputs": [], + "source": [ + "from packaging.version import Version\n", + "\n", + "assert Version(bigframes.__version__) >= Version(\"1.42.0\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "agxLmtlbiouR" + }, + "source": [ + "Turn on the AI 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": 4, + "metadata": { + "id": "1wXqdDr8iouR" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/_config/experiment_options.py:55: PreviewWarning: AI operators are still under experiments, and are subject to change in\n", + "the future.\n", + " warnings.warn(msg, category=bfe.PreviewWarning)\n" + ] + } + ], + "source": [ + "bigframes.options.experiments.ai_operators = True" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "W8TPUvnsqxhv" + }, + "source": [ + "Specify your GCP project and location." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "id": "vCkraKOeqJFl" + }, + "outputs": [], + "source": [ + "bpd.options.bigquery.project = 'bigframes-dev'\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": 6, + "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 AI operator.\n", + "\n", + "This tutorial uses the \"gemini-2.0-flash-001\" 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": 7, + "metadata": { + "id": "tHkymaLNiouR" + }, + "outputs": [], + "source": [ + "from bigframes.ml import llm\n", + "gemini_model = llm.GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", + "text_embedding_model = llm.TextEmbeddingGenerator(model_name=\"text-embedding-005\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "mbFDcvnPiouR" + }, + "source": [ + "**Note**: AI operators could be expensive over a large set of data. As a result, our team added this option `bigframes.options.compute.ai_ops_confirmation_threshold` at `version 1.42.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": 8, + "metadata": { + "id": "F4dZm4b7iouR" + }, + "outputs": [], + "source": [ + "if Version(bigframes.__version__) >= Version(\"1.42.0\"):\n", + " bigframes.options.compute.ai_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.ai_ops_threshold_autofail` to `True`:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "id": "BoUK-cpbiouS" + }, + "outputs": [], + "source": [ + "# if Version(bigframes.__version__) >= Version(\"1.42.0\"):\n", + "# bigframes.options.compute.ai_ops_threshold_autofail = True" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "hQft3o3OiouS" + }, + "source": [ + "# API Examples" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "dt5Kl-QGiouS" + }, + "source": [ + "You will learn about each AI operator by trying some examples." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "J7XAT459iouS" + }, + "source": [ + "## AI Filtering" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9d5HUIvliouS" + }, + "source": [ + "AI 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": 10, + "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", - "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", + "Note that this is not a Python f-string, so you shouldn't prefix your instruction with an `f`." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 127 + }, + "id": "ipW3Z_l4iouS", + "outputId": "ad447459-225a-419c-d4c8-fedac4a9ed0f" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/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" + ] + }, + { + "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.ai.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": [ + "## AI Mapping" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "vT6skC57iouS" + }, + "source": [ + "AI mapping allows to you to combine values from multiple columns into a single output based your instruction.\n", "\n", - "For `ai.forecast`, see https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb" + "Here is an example:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "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 output column name." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you are using BigFrames version `2.5.0` or later, the column name is specified with the `output_schema` parameter. This parameter expects a dictionary input in the form of `{'col_name': 'type_name'}`." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/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" + ] + }, + { + "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 PattyHamburger
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 Hamburger\n", + "1 Soy Bean Bittern Tofu\n", + "2 Sausage Long Bread Hotdog\n", + "\n", + "[3 rows x 3 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.ai.map(\"What is the food made from {ingredient_1} and {ingredient_2}? One word only.\", model=gemini_model, output_schema={\"food\": \"string\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "If you are using BigFrames version 2.4.0 or prior, the column name is specified wit the `output_column` parameter. The outputs are always strings." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 190 + }, + "id": "PpL24AQFiouS", + "outputId": "e7aff038-bf4b-4833-def8-fe2648e8885b" + }, + "outputs": [], + "source": [ + "# df.ai.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": {}, + "source": [ + "### AI Extraction\n", + "\n", + "AI mapping is also able to extract multiple pieces of information based on your prompt, because the output schema keys can carry semantic meanings:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/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" + ] + }, + { + "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", + "
textpersonaddress
0Elmo lives at 123 Sesame Street.Elmo123 Sesame Street
1124 Conch Street is SpongeBob's homeSpongeBob124 Conch Street
\n", + "

2 rows × 3 columns

\n", + "
[2 rows x 3 columns in total]" + ], + "text/plain": [ + " text person address\n", + "0 Elmo lives at 123 Sesame Street. Elmo 123 Sesame Street\n", + "1 124 Conch Street is SpongeBob's home SpongeBob 124 Conch Street\n", + "\n", + "[2 rows x 3 columns]" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({\n", + " \"text\": [\n", + " \"Elmo lives at 123 Sesame Street.\", \n", + " \"124 Conch Street is SpongeBob's home\",\n", + " ]\n", + "})\n", + "df.ai.map(\"{text}\", model=gemini_model, output_schema={\"person\": \"string\", \"address\": \"string\"})" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "70WTZZfdiouS" + }, + "source": [ + "## AI Joining" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "u93uieRaiouS" + }, + "source": [ + "AI joining can join two dataframes based on the instruction you provided.\n", + "\n", + "First, you prepare two dataframes:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "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": 15, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 221 + }, + "id": "WPIOHEwCiouT", + "outputId": "976586c3-b5db-4088-a46a-44dfbf822ecb" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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.ai.join(continents, \"{city} is in {continent}\", model=gemini_model)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "4Qc97GMWiouT" + }, + "source": [ + "!! **Important:** AI join can trigger probihitively expensitve operations! This operation first cross joins two dataframes, then invokes AI 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": 16, + "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": 17, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 284 + }, + "id": "UHfggdhBiouX", + "outputId": "a439e3aa-1382-4244-951f-127dc8da0fe3" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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.ai.join(animals, \"{left.animal} generally weighs heavier than {right.animal}\", model=gemini_model)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sIszJ0zPiouX" + }, + "source": [ + "## AI Search" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "e4ojHRKAiouX" + }, + "source": [ + "AI search searches the most similar values to your query within a single column. Here is an example:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "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": 20, + "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": 21, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 159 + }, + "id": "CkAuFgPYiouY", + "outputId": "723c7604-f53c-43d7-c754-4c91ec198dff" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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", + "/usr/local/google/home/sycai/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", + "/usr/local/google/home/sycai/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": [ + "
\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": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df.ai.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": [ + "## AI 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": 22, + "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": 23, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 253 + }, + "id": "wPV5EkfpiouY", + "outputId": "4be1211d-0353-4b94-8c27-ebd568e8e104" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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", + "/usr/local/google/home/sycai/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": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df1.ai.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 AI 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": "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": 24, + "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><NA><NA><NA>2010-04-16 19:52:51+00:00comment
1<NA>I&#x27;d agree about border control with a cav...bandrami<NA>2023-06-04 06:12:00+00:00comment
2<NA>So 4 pickups? At least pickups are high margin...seanmcdirmid<NA>2023-09-19 14:19:46+00:00comment
3Workplace Wellness Programs Don’t Work Well. W...<NA>anarbadalov22018-08-07 12:17:45+00:00story
4<NA>Are you implying that to be a good developer y...ecesena<NA>2016-06-10 19:38:25+00:00comment
5<NA>It pretty much works with other carriers. My s...toast0<NA>2024-08-13 03:11:32+00:00comment
6<NA><NA><NA><NA>2020-06-07 22:43:03+00:00comment
7<NA>&quot;not operated for profit&quot; and &quot;...radford-neal<NA>2020-03-19 00:24:47+00:00comment
8<NA>It&#x27;s a good description of one applicatio...dkarl<NA>2024-10-07 13:38:18+00:00comment
9<NA>Might be a bit high, but....<p><i>&quot;For ex...tyingq<NA>2017-01-23 19:49:15+00:00comment
10Taiwan’s Tech King to Nancy Pelosi: U.S. Is in...<NA>dlcmh112023-02-18 02:51:11+00:00story
11Android’s new multitasking is terrible and sho...<NA>wowamit12018-10-22 09:50:36+00:00story
12<NA>SEEKING WORK | REMOTE | US Citizen<p>Location:...rasikjain<NA>2024-08-01 16:56:49+00:00comment
13<NA>I had a very similar experience last month tea...tmaly<NA>2020-01-22 18:26:36+00:00comment
14<NA><NA>mrtweetyhack<NA>2022-02-26 19:34:00+00:00comment
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
17<NA>I’ve knowingly paid the premium in the past, j...zwily<NA>2020-06-17 14:26:43+00:00comment
18<NA>&gt; Any sufficiently complicated C or Fortran...wavemode<NA>2025-02-07 06:42:53+00:00comment
19<NA>It&#x27;s similar to a lot of Japanese &quot;t...TillE<NA>2022-11-06 17:15:10+00:00comment
20<NA>Engineers are just people paid to code. If you...rchaud<NA>2023-04-12 14:31:42+00:00comment
21<NA>So don&#x27;t use itCyberDildonics<NA>2015-12-29 22:01:16+00:00comment
22<NA>Sure, but there are degrees of these things. T...dang<NA>2021-11-11 23:42:12+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
24<NA>I’m not sure why responsible riders wouldn’t w...mjmahone17<NA>2021-11-09 01:36:01+00:00comment
\n", + "

25 rows × 6 columns

\n", + "
[3000 rows x 6 columns in total]" + ], + "text/plain": [ + " title \\\n", + "0 \n", + "1 \n", + "2 \n", + "3 Workplace Wellness Programs Don’t Work Well. W... \n", + "4 \n", + "5 \n", + "6 \n", + "7 \n", + "8 \n", + "9 \n", + "10 Taiwan’s Tech King to Nancy Pelosi: U.S. Is in... \n", + "11 Android’s new multitasking is terrible and sho... \n", + "12 \n", + "13 \n", + "14 \n", + "15 \n", + "16 \n", + "17 \n", + "18 \n", + "19 \n", + "20 \n", + "21 \n", + "22 \n", + "23 \n", + "24 \n", + "\n", + " text by score \\\n", + "0 \n", + "1 I'd agree about border control with a cav... bandrami \n", + "2 So 4 pickups? At least pickups are high margin... seanmcdirmid \n", + "3 anarbadalov 2 \n", + "4 Are you implying that to be a good developer y... ecesena \n", + "5 It pretty much works with other carriers. My s... toast0 \n", + "6 \n", + "7 "not operated for profit" and "... radford-neal \n", + "8 It's a good description of one applicatio... dkarl \n", + "9 Might be a bit high, but....

"For ex... tyingq \n", + "10 dlcmh 11 \n", + "11 wowamit 1 \n", + "12 SEEKING WORK | REMOTE | US Citizen

Location:... rasikjain \n", + "13 I had a very similar experience last month tea... tmaly \n", + "14 mrtweetyhack \n", + "15 > Just do what most American cities do with... AnthonyMouse \n", + "16 It's not a space. The l and the C are at ... antninja \n", + "17 I’ve knowingly paid the premium in the past, j... zwily \n", + "18 > Any sufficiently complicated C or Fortran... wavemode \n", + "19 It's similar to a lot of Japanese "t... TillE \n", + "20 Engineers are just people paid to code. If you... rchaud \n", + "21 So don't use it CyberDildonics \n", + "22 Sure, but there are degrees of these things. T... dang \n", + "23 I wish this would happen. There's a &quo... coredog64 \n", + "24 I’m not sure why responsible riders wouldn’t w... mjmahone17 \n", + "\n", + " timestamp type \n", + "0 2010-04-16 19:52:51+00:00 comment \n", + "1 2023-06-04 06:12:00+00:00 comment \n", + "2 2023-09-19 14:19:46+00:00 comment \n", + "3 2018-08-07 12:17:45+00:00 story \n", + "4 2016-06-10 19:38:25+00:00 comment \n", + "5 2024-08-13 03:11:32+00:00 comment \n", + "6 2020-06-07 22:43:03+00:00 comment \n", + "7 2020-03-19 00:24:47+00:00 comment \n", + "8 2024-10-07 13:38:18+00:00 comment \n", + "9 2017-01-23 19:49:15+00:00 comment \n", + "10 2023-02-18 02:51:11+00:00 story \n", + "11 2018-10-22 09:50:36+00:00 story \n", + "12 2024-08-01 16:56:49+00:00 comment \n", + "13 2020-01-22 18:26:36+00:00 comment \n", + "14 2022-02-26 19:34:00+00:00 comment \n", + "15 2021-10-04 23:10:50+00:00 comment \n", + "16 2013-07-13 09:48:34+00:00 comment \n", + "17 2020-06-17 14:26:43+00:00 comment \n", + "18 2025-02-07 06:42:53+00:00 comment \n", + "19 2022-11-06 17:15:10+00:00 comment \n", + "20 2023-04-12 14:31:42+00:00 comment \n", + "21 2015-12-29 22:01:16+00:00 comment \n", + "22 2021-11-11 23:42:12+00:00 comment \n", + "23 2018-02-12 16:03:37+00:00 comment \n", + "24 2021-11-09 01:36:01+00:00 comment \n", + "...\n", + "\n", + "[3000 rows x 6 columns]" + ] + }, + "execution_count": 24, + "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": 25, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "mQl8hc1biouY", + "outputId": "2b4ffa85-9d95-4a20-9040-0420c67da2d4" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "2533" + ] + }, + "execution_count": 25, + "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": 26, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "PZeg4LCUiouZ", + "outputId": "05b67cac-6b3d-42ef-d6d6-b578a9734f4c" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "393.2356889064355" + ] + }, + "execution_count": 26, + "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.42.0\"):\n", + " bigframes.options.compute.ai_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": 28, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 253 + }, + "id": "rditQlmoiouZ", + "outputId": "2b44dcbf-2ef5-4119-ca05-9b082db9c0c1" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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": [ + "

\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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
445<NA>If I want to manipulate a device, I&#x27;ll bu...exelius<NA>2017-09-21 17:39:37+00:00comment
967<NA><a href=\"https:&#x2F;&#x2F;archive.ph&#x2F;nnE...blinding-streak<NA>2023-04-30 19:10:16+00:00comment
975<NA>I&#x27;ve had my 6S Plus now for 36 months and...throwaway427<NA>2019-01-03 18:06:33+00:00comment
1253<NA>Apple is far more closed and tyrannical with i...RyanMcGreal<NA>2012-12-21 00:45:40+00:00comment
1274<NA>An iOS version was released earlier this year....pls2halp<NA>2017-12-09 06:36:41+00:00comment
1548<NA>I’m not sure how that fits with Apple pursuing...alphabettsy<NA>2021-12-26 19:41:38+00:00comment
1630<NA>Not sure if you’re being ironic, but I use an ...lxgr<NA>2025-03-29 03:57:25+00:00comment
1664<NA>Quoting from the article I linked you:<p>&gt;&...StreamBright<NA>2017-09-11 19:57:34+00:00comment
1884<NA>&gt; Not all wireless headsets are the same, h...cptskippy<NA>2021-11-16 13:28:44+00:00comment
2251<NA>Will not buy any more apple product, iphone 4s...omi<NA>2012-09-11 14:42:52+00:00comment
2877<NA>I&#x27;ve been an iPhone user since the OG in ...vsnf<NA>2024-04-15 06:28:09+00:00comment
\n", + "

11 rows × 6 columns

\n", + "
[11 rows x 6 columns in total]" + ], + "text/plain": [ + " title text \\\n", + "445 If I want to manipulate a device, I'll bu... \n", + "967 I've had my 6S Plus now for 36 months and... \n", + "1253 Apple is far more closed and tyrannical with i... \n", + "1274 An iOS version was released earlier this year.... \n", + "1548 I’m not sure how that fits with Apple pursuing... \n", + "1630 Not sure if you’re being ironic, but I use an ... \n", + "1664 Quoting from the article I linked you:

>&... \n", + "1884 > Not all wireless headsets are the same, h... \n", + "2251 Will not buy any more apple product, iphone 4s... \n", + "2877 I've been an iPhone user since the OG in ... \n", + "\n", + " by score timestamp type \n", + "445 exelius 2017-09-21 17:39:37+00:00 comment \n", + "967 blinding-streak 2023-04-30 19:10:16+00:00 comment \n", + "975 throwaway427 2019-01-03 18:06:33+00:00 comment \n", + "1253 RyanMcGreal 2012-12-21 00:45:40+00:00 comment \n", + "1274 pls2halp 2017-12-09 06:36:41+00:00 comment \n", + "1548 alphabettsy 2021-12-26 19:41:38+00:00 comment \n", + "1630 lxgr 2025-03-29 03:57:25+00:00 comment \n", + "1664 StreamBright 2017-09-11 19:57:34+00:00 comment \n", + "1884 cptskippy 2021-11-16 13:28:44+00:00 comment \n", + "2251 omi 2012-09-11 14:42:52+00:00 comment \n", + "2877 vsnf 2024-04-15 06:28:09+00:00 comment \n", + "\n", + "[11 rows x 6 columns]" + ] + }, + "execution_count": 28, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iphone_comments = hacker_news_with_texts.ai.filter(\"The {text} is mainly focused on iPhone\", gemini_model)\n", + "iphone_comments" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "yl24sJFIiouZ" + }, + "source": [ + "The performance of the ai operators depends on the length of your input as well as your quota. Here are our benchmarks for running the previous operation with Gemini Flash 1.5 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", + "\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": 29, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 253 + }, + "id": "IlKBrNxUiouZ", + "outputId": "818d01e4-1cdf-42a2-9e02-61c4736a8905" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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": [ + "

\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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
445<NA>If I want to manipulate a device, I&#x27;ll bu...exelius<NA>2017-09-21 17:39:37+00:00commentPragmatic, slightly annoyed
967<NA><a href=\"https:&#x2F;&#x2F;archive.ph&#x2F;nnE...blinding-streak<NA>2023-04-30 19:10:16+00:00commentI lack the ability to access external websites...
975<NA>I&#x27;ve had my 6S Plus now for 36 months and...throwaway427<NA>2019-01-03 18:06:33+00:00commentGenerally positive, impressed.
1253<NA>Apple is far more closed and tyrannical with i...RyanMcGreal<NA>2012-12-21 00:45:40+00:00commentNegative towards Apple
1274<NA>An iOS version was released earlier this year....pls2halp<NA>2017-12-09 06:36:41+00:00commentNeutral, factual statement.
1548<NA>I’m not sure how that fits with Apple pursuing...alphabettsy<NA>2021-12-26 19:41:38+00:00commentSkeptical and critical.
1630<NA>Not sure if you’re being ironic, but I use an ...lxgr<NA>2025-03-29 03:57:25+00:00commentWants interoperability, frustrated.
1664<NA>Quoting from the article I linked you:<p>&gt;&...StreamBright<NA>2017-09-11 19:57:34+00:00commentExtremely positive review
1884<NA>&gt; Not all wireless headsets are the same, h...cptskippy<NA>2021-11-16 13:28:44+00:00commentSkeptical and critical
2251<NA>Will not buy any more apple product, iphone 4s...omi<NA>2012-09-11 14:42:52+00:00commentNegative, regretful.
2877<NA>I&#x27;ve been an iPhone user since the OG in ...vsnf<NA>2024-04-15 06:28:09+00:00commentMildly annoyed, resigned
\n", + "

11 rows × 7 columns

\n", + "
[11 rows x 7 columns in total]" + ], + "text/plain": [ + " title text \\\n", + "445 If I want to manipulate a device, I'll bu... \n", + "967
I've had my 6S Plus now for 36 months and... \n", + "1253 Apple is far more closed and tyrannical with i... \n", + "1274 An iOS version was released earlier this year.... \n", + "1548 I’m not sure how that fits with Apple pursuing... \n", + "1630 Not sure if you’re being ironic, but I use an ... \n", + "1664 Quoting from the article I linked you:

>&... \n", + "1884 > Not all wireless headsets are the same, h... \n", + "2251 Will not buy any more apple product, iphone 4s... \n", + "2877 I've been an iPhone user since the OG in ... \n", + "\n", + " by score timestamp type \\\n", + "445 exelius 2017-09-21 17:39:37+00:00 comment \n", + "967 blinding-streak 2023-04-30 19:10:16+00:00 comment \n", + "975 throwaway427 2019-01-03 18:06:33+00:00 comment \n", + "1253 RyanMcGreal 2012-12-21 00:45:40+00:00 comment \n", + "1274 pls2halp 2017-12-09 06:36:41+00:00 comment \n", + "1548 alphabettsy 2021-12-26 19:41:38+00:00 comment \n", + "1630 lxgr 2025-03-29 03:57:25+00:00 comment \n", + "1664 StreamBright 2017-09-11 19:57:34+00:00 comment \n", + "1884 cptskippy 2021-11-16 13:28:44+00:00 comment \n", + "2251 omi 2012-09-11 14:42:52+00:00 comment \n", + "2877 vsnf 2024-04-15 06:28:09+00:00 comment \n", + "\n", + " sentiment \n", + "445 Pragmatic, slightly annoyed\n", + " \n", + "967 I lack the ability to access external websites... \n", + "975 Generally positive, impressed.\n", + " \n", + "1253 Negative towards Apple\n", + " \n", + "1274 Neutral, factual statement.\n", + " \n", + "1548 Skeptical and critical.\n", + " \n", + "1630 Wants interoperability, frustrated.\n", + " \n", + "1664 Extremely positive review\n", + " \n", + "1884 Skeptical and critical\n", + " \n", + "2251 Negative, regretful.\n", + " \n", + "2877 Mildly annoyed, resigned\n", + " \n", + "\n", + "[11 rows x 7 columns]" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "iphone_comments.ai.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": 30, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 880 + }, + "id": "CbGwc_uXiouZ", + "outputId": "138acca0-7fb9-495a-e797-0d42495d65e6" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/venv/lib/python3.11/site-packages/IPython/core/interactiveshell.py:3577: UserWarning: Reading cached table from 2025-04-02 18:00:55.801294+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", + " exec(code_obj, self.user_global_ns, self.user_ns)\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", + "
titletextbyscoretimestamptype
0<NA><NA><NA><NA>2010-04-16 19:52:51+00:00comment
1<NA>I&#x27;d agree about border control with a cav...bandrami<NA>2023-06-04 06:12:00+00:00comment
2<NA>So 4 pickups? At least pickups are high margin...seanmcdirmid<NA>2023-09-19 14:19:46+00:00comment
3Workplace Wellness Programs Don’t Work Well. W...<NA>anarbadalov22018-08-07 12:17:45+00:00story
4<NA>Are you implying that to be a good developer y...ecesena<NA>2016-06-10 19:38:25+00:00comment
5<NA>It pretty much works with other carriers. My s...toast0<NA>2024-08-13 03:11:32+00:00comment
6<NA><NA><NA><NA>2020-06-07 22:43:03+00:00comment
7<NA>&quot;not operated for profit&quot; and &quot;...radford-neal<NA>2020-03-19 00:24:47+00:00comment
8<NA>It&#x27;s a good description of one applicatio...dkarl<NA>2024-10-07 13:38:18+00:00comment
9<NA>Might be a bit high, but....<p><i>&quot;For ex...tyingq<NA>2017-01-23 19:49:15+00:00comment
10Taiwan’s Tech King to Nancy Pelosi: U.S. Is in...<NA>dlcmh112023-02-18 02:51:11+00:00story
11Android’s new multitasking is terrible and sho...<NA>wowamit12018-10-22 09:50:36+00:00story
12<NA>SEEKING WORK | REMOTE | US Citizen<p>Location:...rasikjain<NA>2024-08-01 16:56:49+00:00comment
13<NA>I had a very similar experience last month tea...tmaly<NA>2020-01-22 18:26:36+00:00comment
14<NA><NA>mrtweetyhack<NA>2022-02-26 19:34:00+00:00comment
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
17<NA>I’ve knowingly paid the premium in the past, j...zwily<NA>2020-06-17 14:26:43+00:00comment
18<NA>&gt; Any sufficiently complicated C or Fortran...wavemode<NA>2025-02-07 06:42:53+00:00comment
19<NA>It&#x27;s similar to a lot of Japanese &quot;t...TillE<NA>2022-11-06 17:15:10+00:00comment
20<NA>Engineers are just people paid to code. If you...rchaud<NA>2023-04-12 14:31:42+00:00comment
21<NA>So don&#x27;t use itCyberDildonics<NA>2015-12-29 22:01:16+00:00comment
22<NA>Sure, but there are degrees of these things. T...dang<NA>2021-11-11 23:42:12+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
24<NA>I’m not sure why responsible riders wouldn’t w...mjmahone17<NA>2021-11-09 01:36:01+00:00comment
\n", + "

25 rows × 6 columns

\n", + "
[3000 rows x 6 columns in total]" + ], + "text/plain": [ + " title \\\n", + "0 \n", + "1 \n", + "2 \n", + "3 Workplace Wellness Programs Don’t Work Well. W... \n", + "4 \n", + "5 \n", + "6 \n", + "7 \n", + "8 \n", + "9 \n", + "10 Taiwan’s Tech King to Nancy Pelosi: U.S. Is in... \n", + "11 Android’s new multitasking is terrible and sho... \n", + "12 \n", + "13 \n", + "14 \n", + "15 \n", + "16 \n", + "17 \n", + "18 \n", + "19 \n", + "20 \n", + "21 \n", + "22 \n", + "23 \n", + "24 \n", + "\n", + " text by score \\\n", + "0 \n", + "1 I'd agree about border control with a cav... bandrami \n", + "2 So 4 pickups? At least pickups are high margin... seanmcdirmid \n", + "3 anarbadalov 2 \n", + "4 Are you implying that to be a good developer y... ecesena \n", + "5 It pretty much works with other carriers. My s... toast0 \n", + "6 \n", + "7 "not operated for profit" and "... radford-neal \n", + "8 It's a good description of one applicatio... dkarl \n", + "9 Might be a bit high, but....

"For ex... tyingq \n", + "10 dlcmh 11 \n", + "11 wowamit 1 \n", + "12 SEEKING WORK | REMOTE | US Citizen

Location:... rasikjain \n", + "13 I had a very similar experience last month tea... tmaly \n", + "14 mrtweetyhack \n", + "15 > Just do what most American cities do with... AnthonyMouse \n", + "16 It's not a space. The l and the C are at ... antninja \n", + "17 I’ve knowingly paid the premium in the past, j... zwily \n", + "18 > Any sufficiently complicated C or Fortran... wavemode \n", + "19 It's similar to a lot of Japanese "t... TillE \n", + "20 Engineers are just people paid to code. If you... rchaud \n", + "21 So don't use it CyberDildonics \n", + "22 Sure, but there are degrees of these things. T... dang \n", + "23 I wish this would happen. There's a &quo... coredog64 \n", + "24 I’m not sure why responsible riders wouldn’t w... mjmahone17 \n", + "\n", + " timestamp type \n", + "0 2010-04-16 19:52:51+00:00 comment \n", + "1 2023-06-04 06:12:00+00:00 comment \n", + "2 2023-09-19 14:19:46+00:00 comment \n", + "3 2018-08-07 12:17:45+00:00 story \n", + "4 2016-06-10 19:38:25+00:00 comment \n", + "5 2024-08-13 03:11:32+00:00 comment \n", + "6 2020-06-07 22:43:03+00:00 comment \n", + "7 2020-03-19 00:24:47+00:00 comment \n", + "8 2024-10-07 13:38:18+00:00 comment \n", + "9 2017-01-23 19:49:15+00:00 comment \n", + "10 2023-02-18 02:51:11+00:00 story \n", + "11 2018-10-22 09:50:36+00:00 story \n", + "12 2024-08-01 16:56:49+00:00 comment \n", + "13 2020-01-22 18:26:36+00:00 comment \n", + "14 2022-02-26 19:34:00+00:00 comment \n", + "15 2021-10-04 23:10:50+00:00 comment \n", + "16 2013-07-13 09:48:34+00:00 comment \n", + "17 2020-06-17 14:26:43+00:00 comment \n", + "18 2025-02-07 06:42:53+00:00 comment \n", + "19 2022-11-06 17:15:10+00:00 comment \n", + "20 2023-04-12 14:31:42+00:00 comment \n", + "21 2015-12-29 22:01:16+00:00 comment \n", + "22 2021-11-11 23:42:12+00:00 comment \n", + "23 2018-02-12 16:03:37+00:00 comment \n", + "24 2021-11-09 01:36:01+00:00 comment \n", + "...\n", + "\n", + "[3000 rows x 6 columns]" + ] + }, + "execution_count": 30, + "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": 31, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 880 + }, + "id": "9dzU8SNziouZ", + "outputId": "da8815c1-c411-4afc-d1ca-5e44c75b5b48" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/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": [ + "

\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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
15<NA>&gt; Just do what most American cities do with...AnthonyMouse<NA>2021-10-04 23:10:50+00:00comment
16<NA>It&#x27;s not a space. The l and the C are at ...antninja<NA>2013-07-13 09:48:34+00:00comment
23<NA>I wish this would happen. There&#x27;s a &quo...coredog64<NA>2018-02-12 16:03:37+00:00comment
27<NA>Flash got close, but was too complex and expen...surfingdino<NA>2024-05-08 05:02:37+00:00comment
36<NA>I think the &quot;algo genius&quot; type of de...poisonborz<NA>2024-06-04 07:39:08+00:00comment
150<NA>No one will be doing anything practical with a...NeutralCrane<NA>2025-02-01 14:26:25+00:00comment
160<NA>I think this is more semantics than anything.<...superb-owl<NA>2022-06-08 16:55:54+00:00comment
205<NA>Interesting to think of sign language localisa...robin_reala<NA>2019-02-01 11:49:23+00:00comment
231<NA>Probably because of their key location.ape4<NA>2014-08-29 14:55:40+00:00comment
250<NA>I realize this is a bit passe, but there were ...FeepingCreature<NA>2023-10-15 11:32:44+00:00comment
320Protest against Bill C-11, Canada's SOPA, plan...<NA>magikarp12012-01-29 02:14:12+00:00story
344<NA>What? Are you suggesting we cannot criticize p...chickenpotpie<NA>2020-12-02 18:24:19+00:00comment
348The flu vaccine this year is only 10% effective<NA>maryfoxmarlow32018-02-02 02:19:42+00:00story
360<NA>Bomb ownership is okay AFAIK. Intent to commi...Ferret7446<NA>2023-06-25 20:04:30+00:00comment
3981 + 1 = 3<NA>oscar-the-horse22012-08-05 22:18:51+00:00story
407<NA>No (almost certainly), but you will become fru...AnimalMuppet<NA>2023-09-15 16:11:08+00:00comment
454<NA>48h is less than 5 kWh of batteries, one quart...tigershark<NA>2021-07-23 05:12:52+00:00comment
457Brazilian Rails Websites<NA>akitaonrails12008-07-27 17:27:47+00:00story
472<NA>&gt; When most people start as programmers, th...PavlovsCat<NA>2018-12-23 20:37:20+00:00comment
493<NA>Related anecdata + a study I found useful. Aft...TrainedMonkey<NA>2023-02-02 16:14:23+00:00comment
497<NA>That &quot;civilized&quot; country has too man...rantanplan<NA>2017-02-17 12:51:51+00:00comment
514<NA>The current Go 2 drafts do.tapirl<NA>2020-08-12 02:37:41+00:00comment
535<NA>Having walked this same path, this blog resona...curiousllama<NA>2020-10-07 20:35:18+00:00comment
607<NA>If people thought the reward for talking to a ...slapfrog<NA>2021-09-08 20:58:13+00:00comment
672<NA>Given that you say you&#x27;re 38 and looking ...strix_varius<NA>2023-08-04 02:41:50+00:00comment
\n", + "

25 rows × 6 columns

\n", + "
[112 rows x 6 columns in total]" + ], + "text/plain": [ + " title \\\n", + "15 \n", + "16 \n", + "23 \n", + "27 \n", + "36 \n", + "150 \n", + "160 \n", + "205 \n", + "231 \n", + "250 \n", + "320 Protest against Bill C-11, Canada's SOPA, plan... \n", + "344 \n", + "348 The flu vaccine this year is only 10% effective \n", + "360 \n", + "398 1 + 1 = 3 \n", + "407 \n", + "454 \n", + "457 Brazilian Rails Websites \n", + "472 \n", + "493 \n", + "497 \n", + "514 \n", + "535 \n", + "607 \n", + "672 \n", + "\n", + " text by \\\n", + "15 > Just do what most American cities do with... AnthonyMouse \n", + "16 It's not a space. The l and the C are at ... antninja \n", + "23 I wish this would happen. There's a &quo... coredog64 \n", + "27 Flash got close, but was too complex and expen... surfingdino \n", + "36 I think the "algo genius" type of de... poisonborz \n", + "150 No one will be doing anything practical with a... NeutralCrane \n", + "160 I think this is more semantics than anything.<... superb-owl \n", + "205 Interesting to think of sign language localisa... robin_reala \n", + "231 Probably because of their key location. ape4 \n", + "250 I realize this is a bit passe, but there were ... FeepingCreature \n", + "320 magikarp \n", + "344 What? Are you suggesting we cannot criticize p... chickenpotpie \n", + "348 maryfoxmarlow \n", + "360 Bomb ownership is okay AFAIK. Intent to commi... Ferret7446 \n", + "398 oscar-the-horse \n", + "407 No (almost certainly), but you will become fru... AnimalMuppet \n", + "454 48h is less than 5 kWh of batteries, one quart... tigershark \n", + "457 akitaonrails \n", + "472 > When most people start as programmers, th... PavlovsCat \n", + "493 Related anecdata + a study I found useful. Aft... TrainedMonkey \n", + "497 That "civilized" country has too man... rantanplan \n", + "514 The current Go 2 drafts do. tapirl \n", + "535 Having walked this same path, this blog resona... curiousllama \n", + "607 If people thought the reward for talking to a ... slapfrog \n", + "672 Given that you say you're 38 and looking ... strix_varius \n", + "\n", + " score timestamp type \n", + "15 2021-10-04 23:10:50+00:00 comment \n", + "16 2013-07-13 09:48:34+00:00 comment \n", + "23 2018-02-12 16:03:37+00:00 comment \n", + "27 2024-05-08 05:02:37+00:00 comment \n", + "36 2024-06-04 07:39:08+00:00 comment \n", + "150 2025-02-01 14:26:25+00:00 comment \n", + "160 2022-06-08 16:55:54+00:00 comment \n", + "205 2019-02-01 11:49:23+00:00 comment \n", + "231 2014-08-29 14:55:40+00:00 comment \n", + "250 2023-10-15 11:32:44+00:00 comment \n", + "320 1 2012-01-29 02:14:12+00:00 story \n", + "344 2020-12-02 18:24:19+00:00 comment \n", + "348 3 2018-02-02 02:19:42+00:00 story \n", + "360 2023-06-25 20:04:30+00:00 comment \n", + "398 2 2012-08-05 22:18:51+00:00 story \n", + "407 2023-09-15 16:11:08+00:00 comment \n", + "454 2021-07-23 05:12:52+00:00 comment \n", + "457 1 2008-07-27 17:27:47+00:00 story \n", + "472 2018-12-23 20:37:20+00:00 comment \n", + "493 2023-02-02 16:14:23+00:00 comment \n", + "497 2017-02-17 12:51:51+00:00 comment \n", + "514 2020-08-12 02:37:41+00:00 comment \n", + "535 2020-10-07 20:35:18+00:00 comment \n", + "607 2021-09-08 20:58:13+00:00 comment \n", + "672 2023-08-04 02:41:50+00:00 comment \n", + "...\n", + "\n", + "[112 rows x 6 columns]" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "hacker_news.ai.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" ] } ], diff --git a/notebooks/experimental/semantic_operators.ipynb b/notebooks/experimental/semantic_operators.ipynb index 22927e6ef94..fc46a43e7bd 100644 --- a/notebooks/experimental/semantic_operators.ipynb +++ b/notebooks/experimental/semantic_operators.ipynb @@ -1,13 +1,5 @@ { "cells": [ - { - "cell_type": "markdown", - "id": "title-cell", - "metadata": {}, - "source": [ - "# Semantic Operators (Experimental)" - ] - }, { "cell_type": "code", "execution_count": null, @@ -35,9 +27,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "Semantic Operators have been deprecated since version 1.42.0. Please use AI functions instead.\n", + "Semantic Operators have been deprecated since version 1.42.0. Please use AI Operators instead.\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" + "The tutorial notebook for AI operators is located [here](https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/experimental/ai_operators.ipynb)." ] } ], diff --git a/notebooks/generative_ai/ai_functions.ipynb b/notebooks/generative_ai/ai_functions.ipynb deleted file mode 100644 index 0831ea0412b..00000000000 --- a/notebooks/generative_ai/ai_functions.ipynb +++ /dev/null @@ -1,567 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "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 = \"\" # @param {type:\"string\"}\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": null, - "id": "471a47fe", - "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/swast/src/github.com/googleapis/google-cloud-python/packages/bigframes/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/swast/src/github.com/googleapis/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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": [ - "
0    {\"result\":\"Salad\",\"full_response\":{\"candidates...\n",
-       "1    {\"result\":\"Hotdog\",\"full_response\":{\"candidate...
" - ], - "text/plain": [ - "0 {\"result\":\"Salad\",\"full_response\":{\"candidates...\n", - "1 {\"result\":\"Hotdog\",\"full_response\":{\"candidate...\n", - "Name: 0, dtype: string" - ] - }, - "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": null, - "id": "4a3229a8", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "
0    \n",
-       "1    
" - ], - "text/plain": [ - "0 \n", - "1 \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": null, - "id": "718c6622", - "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", - "
creaturecategory
0Catmammal
1Salmonfish
\n", - "

2 rows × 2 columns

\n", - "
[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": null, - "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
0tiger7.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 7.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": null, - "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": null, - "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": 8, - "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", - "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.14.3" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/generative_ai/ai_movie_poster.ipynb b/notebooks/generative_ai/ai_movie_poster.ipynb deleted file mode 100644 index 8f309fa7c49..00000000000 --- a/notebooks/generative_ai/ai_movie_poster.ipynb +++ /dev/null @@ -1,762 +0,0 @@ -{ - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "id": "7add2e44", - "metadata": { - "id": "XZpKUoHjXw3_" - }, - "outputs": [], - "source": [ - "# Copyright 2026 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": "ee509844", - "metadata": { - "id": "SEKzWP6jW9Oj" - }, - "source": [ - "# Analyzing movie posters with BigQuery Dataframe AI functions" - ] - }, - { - "cell_type": "markdown", - "id": "81b8de8d", - "metadata": {}, - "source": [ - "\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": "256b6c02", - "metadata": { - "id": "c9CCKXG5XTb-" - }, - "source": [ - "BigQuery Dataframe provides a Pythonic way to use AI functions directly with your dataframes. In this notebook, you will use these functions to analyze old\n", - "movie posters. These posters are images stored in a public Google Cloud Storage bucket: `gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters`" - ] - }, - { - "cell_type": "markdown", - "id": "3f71d3cb", - "metadata": { - "id": "CUJDa_7MPbL9" - }, - "source": [ - "## Set up" - ] - }, - { - "cell_type": "markdown", - "id": "547145f5", - "metadata": { - "id": "D3iYtBSkYpCK" - }, - "source": [ - "Before you begin, you need to\n", - "\n", - "* Set up your permissions for generative AI functions with [these instructions](https://docs.cloud.google.com/bigquery/docs/permissions-for-ai-functions)\n", - "* Set up your Cloud Resource connection by following [these instructions](https://docs.cloud.google.com/bigquery/docs/create-cloud-resource-connection)\n", - "\n", - "Once you have the permissions set up, import the `bigframes.pandas` package, and\n", - "set your cloud project ID." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "d9cd6da8", - "metadata": { - "id": "6nqoRHYbPAx3" - }, - "outputs": [], - "source": [ - "import bigframes.pandas as bpd\n", - "\n", - "MY_PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", - "LOCATION = \"us\" # @param {type:\"string\"}\n", - "\n", - "bpd.options.bigquery.project = MY_PROJECT_ID\n", - "bpd.options.bigquery.location = LOCATION" - ] - }, - { - "cell_type": "markdown", - "id": "015a63c1", - "metadata": { - "id": "2XHcNHtvPhNW" - }, - "source": [ - "## Load data" - ] - }, - { - "cell_type": "markdown", - "id": "254561e0", - "metadata": { - "id": "eS-9A7DijfoQ" - }, - "source": [ - "First, you load the data from the GCS bucket to a BigQuery Dataframe:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "47acbbfe", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, - "id": "ZNPzFjCyPap0", - "outputId": "346d20b2-d615-4094-d24e-2d40e5c90ee2" - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - " Query processed 0 Bytes in 18 seconds of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in 8 seconds of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - " \n", - "
poster
0
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Replace with your own connection name.\n", - "MY_CONNECTION = 'bigframes-default-connection' # @param {type:\"string\"}\n", - "FULL_CONNECTION_ID = f\"{MY_PROJECT_ID}.{LOCATION}.{MY_CONNECTION}\"\n", - "\n", - "import gcsfs\n", - "import bigframes\n", - "import bigframes.pandas as bpd\n", - "import bigframes.bigquery as bbq\n", - "import json\n", - "from IPython.display import HTML, display\n", - "\n", - "session = bpd.get_global_session()\n", - "\n", - "# Configure global display parameters \n", - "bigframes.options.display.blob_display_width = 200\n", - "\n", - "def get_runtime_json_str(series, mode=\"R\", with_metadata=False):\n", - " s = bbq.obj.fetch_metadata(series) if with_metadata else series\n", - " runtime = bbq.obj.get_access_url(s, mode=mode)\n", - " return bbq.to_json_string(runtime)\n", - "\n", - "def get_read_url(series):\n", - " runtime = bbq.obj.get_access_url(series, mode=\"R\")\n", - " return bbq.json_value(runtime, \"$.access_urls.read_url\")\n", - "\n", - "def render_images(df):\n", - " \"\"\"Helper to display BigFrames DataFrame with rendered image previews.\"\"\"\n", - " from bigframes import dtypes\n", - " if isinstance(df, bpd.Series):\n", - " df = df.to_frame()\n", - " \n", - " object_cols = [col for col, dtype in zip(df.columns, df.dtypes) if dtype == dtypes.OBJ_REF_DTYPE]\n", - " if not object_cols:\n", - " display(df)\n", - " return\n", - "\n", - " limit = bigframes.options.display.max_rows or 10\n", - " view_df = df.head(limit)\n", - " runtime_cols = {\n", - " col: get_runtime_json_str(view_df[col], mode=\"R\", with_metadata=False) \n", - " for col in object_cols\n", - " }\n", - " \n", - " pandas_json_df = bpd.DataFrame(runtime_cols).to_pandas()\n", - " final_pd = view_df.to_pandas()\n", - " width = bigframes.options.display.blob_display_width or 200\n", - " \n", - " def format_cell_html(raw_json):\n", - " if not raw_json: return \"\"\n", - " try:\n", - " obj_rt = json.loads(raw_json)\n", - " if \"access_urls\" not in obj_rt: return \"Error fetching URL\"\n", - " uri = obj_rt.get(\"objectref\", {}).get(\"uri\", \"\")\n", - " url = obj_rt[\"access_urls\"][\"read_url\"]\n", - " if str(uri).lower().endswith((\".png\", \".jpg\", \".jpeg\", \".webp\")):\n", - " return f''\n", - " return f'
{uri}'\n", - " except: return \"Format Error\"\n", - "\n", - " for col in object_cols:\n", - " final_pd[col] = pandas_json_df[col].map(format_cell_html)\n", - " display(HTML(final_pd.to_html(escape=False)))\n", - "\n", - "# List files using gcsfs\n", - "fs = gcsfs.GCSFileSystem(anon=True)\n", - "uris = fs.glob(\"gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/*\")\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "# Read the URIs into a BigQuery DataFrame\n", - "movies = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "\n", - "# Create the object reference column using the fully qualified connection ID\n", - "movies['poster'] = bbq.obj.make_ref(movies['uri'], authorizer=FULL_CONNECTION_ID)\n", - "movies = movies[['poster']]\n", - "render_images(movies.head(1))" - ] - }, - { - "cell_type": "markdown", - "id": "f1096d2f", - "metadata": { - "id": "EfkdDH08QnYw" - }, - "source": [ - "## Extract titles from posters" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "bb30d47c", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 1000 - }, - "id": "6CoZZ5tSQm1r", - "outputId": "1b3915ce-eb83-4be9-b1c1-d9a326dc9408" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - " Query processed 0 Bytes in 23 seconds of slot time. [Job bigframes-dev:US.job_ZKfuxLQE1U49whg7fgakYFYfiz34 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in 40 seconds of slot time. [Job bigframes-dev:US.job_VwLv_BxDFdE4adNx1bpnvvM5vfZd details]\n", - " " - ], - "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", - "
postertitle
0The movie title for this poster image is **Au Secours!** (Help!).
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import bigframes.bigquery as bbq\n", - "\n", - "movies['title'] = bbq.ai.generate(\n", - " (\"What is the movie title for this poster image?\", get_read_url(movies['poster']))\n", - ").struct.field(\"result\")\n", - "render_images(movies.head(1))" - ] - }, - { - "cell_type": "markdown", - "id": "eb9eb261", - "metadata": { - "id": "cFQHQ9S2lr6t" - }, - "source": [ - "Notice that `ai.generate()` has a `struct` return type, which holds not only the LLM response, but also the status. If you do not provide a field name for your answer, `\"result\"` will be the default name. You can access LLM response content with the struct accessor (e.g. `my_response.struct.filed(\"result\")`);." - ] - }, - { - "cell_type": "markdown", - "id": "ea29eb21", - "metadata": { - "id": "R8kkUhgoS5Xz" - }, - "source": [ - "## Get movie release year\n", - "\n", - "In the example below, you will use `ai.generate_int()` to find the release year for each movie poster:" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "bf426247", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 976 - }, - "id": "cKZdHq0XS1iW", - "outputId": "72cbad57-4518-4e1e-97bb-333d424dba73" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/core/logging/log_adapter.py:229: ApiDeprecationWarning: The blob accessor is deprecated and will be removed in a future release. Use bigframes.bigquery.obj functions instead.\n", - " return prop(*args, **kwargs)\n" - ] - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in 51 seconds of slot time. [Job bigframes-dev:US.3cf4ab5b-c360-4b7c-9def-4cd03135a547 details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 1.2 kB in a moment of slot time.\n", - " " - ], - "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", - "
postertitleyear
0The movie title is **Au Secours!**1924
\n", - "

1 rows × 3 columns

\n", - "
[1 rows x 3 columns in total]" - ], - "text/plain": [ - " poster \\\n", - "0 {\"access_urls\":{\"expiry_time\":\"2026-05-09T03:1... \n", - "\n", - " title year \n", - "0 The movie title is **Au Secours!** 1924 \n", - "\n", - "[1 rows x 3 columns]" - ] - }, - "execution_count": 5, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "movies['year'] = bbq.ai.generate_int(\n", - " (\"What is the release year for this movie?\", movies['title']),\n", - " endpoint='gemini-2.5-pro'\n", - ").struct.field(\"result\")\n", - "\n", - "movies.head(1)" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "8bf12352", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 250 - }, - "id": "yqRiNRY8_8fs", - "outputId": "efa60107-6883-4f5c-8e40-43c7287ea7fb" - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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": [ - "poster structSQL
WITH `bfcte_0` AS (\n",
-       "  SELECT\n",
-       "    *\n",
-       "  FROM UNNEST(ARRAY<STRUCT<`bfcol_0` STRING, `bfcol_1` INT64, `bfcol_2` INT64>>[STRUCT(\n",
-       "    'gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/au_secours.jpeg',\n",
-       "    0,\n",
-       "    0\n",
-       "  ), STRUCT(\n",
-       "    'gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/barque_sortant_du_port.jpeg',\n",
-       "    1,\n",
-       "    1\n",
-       "  ), STRUCT(\n",
-       "    'gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/battling_butler.jpg',\n",
-       "    2,\n",
-       "    2\n",
-       "  ), STRUCT(\n",
-       "    'gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/brown_of_harvard.jpeg',\n",
-       "    3,\n",
-       "    3\n",
-       "  ), STRUCT(\n",
-       "    'gs://cloud-samples-data/vertex-ai/dataset-management/datasets/classic-movie-posters/der_student_von_prag.jpg',\n",
-       "    4,\n",
-       "    4\n",
-       "  )])\n",
-       ")\n",
-       "SELECT\n",
-       "  `bfcol_1` AS `bfuid_col_60`,\n",
-       "  TO_JSON_STRING(\n",
-       "    OBJ.GET_ACCESS_URL(OBJ.MAKE_REF(`bfcol_0`, 'bigframes-dev.us.bigframes-default-connection'), 'R')\n",
-       "  ) AS `bfuid_col_66`\n",
-       "FROM `bfcte_0`\n",
-       "WHERE\n",
-       "  AI.IF(\n",
-       "    prompt => (\n",
-       "      'The movie ',\n",
-       "      AI.GENERATE(\n",
-       "        prompt => (\n",
-       "          'What is the movie title for this poster image?',\n",
-       "          JSON_VALUE(\n",
-       "            OBJ.GET_ACCESS_URL(OBJ.MAKE_REF(`bfcol_0`, 'bigframes-dev.us.bigframes-default-connection'), 'R'),\n",
-       "            '$.access_urls.read_url'\n",
-       "          )\n",
-       "        ),\n",
-       "        request_type => 'UNSPECIFIED'\n",
-       "      ).`result`,\n",
-       "      ' was made in US'\n",
-       "    ),\n",
-       "    optimization_mode => 'MINIMIZE_COST'\n",
-       "  )\n",
-       "ORDER BY\n",
-       "  `bfcol_2` ASC NULLS LAST\n",
-       "LIMIT 1
\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in 3 minutes of slot time. [Job bigframes-dev:US.job_NBILG5qU14Aitas81nPCCtYM9KdM details]\n", - " " - ], - "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", - "
postertitleyear
2The movie title for the poster image is **Battling Butler**.1926
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "us_movies = movies[bbq.ai.if_(\n", - " (\"The movie \", movies['title'], \" was made in US\")\n", - ")]\n", - "render_images(us_movies.head(1))" - ] - } - ], - "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.13.0" - } - }, - "nbformat": 4, - "nbformat_minor": 0 -} diff --git a/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb index 6f8c95d3a48..f84f0b5d26b 100644 --- a/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb +++ b/notebooks/generative_ai/bq_dataframes_ai_forecast.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ @@ -60,12 +60,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Setup" + "### Setup" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -76,7 +76,11 @@ "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\"" + "bpd.options.bigquery.ordering_mode = \"partial\"\n", + "\n", + "import bigframes.exceptions\n", + "import warnings\n", + "warnings.filterwarnings(\"ignore\", category=bigframes.exceptions.AmbiguousWindowWarning)" ] }, { @@ -88,7 +92,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": 4, "metadata": {}, "outputs": [ { @@ -138,39 +142,39 @@ " \n", " \n", " 0\n", - " 20171215164722144\n", - " 501\n", - " 2017-12-15 16:47:22+00:00\n", - " 10th St at Fallon St\n", - " 201\n", - " 2017-12-15 16:55:44+00:00\n", + " 201802092135083596\n", + " 788\n", + " 2018-02-09 21:35:08+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 144\n", + " 2018-02-09 21:48:17+00:00\n", + " 10th Ave at E 15th St\n", + " 222\n", + " 3596\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.797673\n", - " -122.262997\n", + " 37.792714\n", + " -122.24878\n", " 37.792714\n", " -122.24878\n", " 1984\n", " Male\n", - " <NA>\n", - " POINT (-122.263 37.79767)\n", + " Yes\n", + " POINT (-122.24878 37.79271)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 1\n", - " 201708052346051585\n", - " 712\n", - " 2017-08-05 23:46:05+00:00\n", + " 201708152357422491\n", + " 965\n", + " 2017-08-15 23:57:42+00:00\n", " 10th St at Fallon St\n", " 201\n", - " 2017-08-05 23:57:57+00:00\n", + " 2017-08-16 00:13:48+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 1585\n", + " 2491\n", " <NA>\n", " ...\n", " <NA>\n", @@ -186,183 +190,183 @@ " \n", " \n", " 2\n", - " 201711111447202880\n", - " 272\n", - " 2017-11-11 14:47:20+00:00\n", - " 12th St at 4th Ave\n", - " 233\n", - " 2017-11-11 14:51:53+00:00\n", + " 201802281657253632\n", + " 560\n", + " 2018-02-28 16:57:25+00:00\n", + " 10th St at Fallon St\n", + " 201\n", + " 2018-02-28 17:06:46+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 2880\n", + " 3632\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.795812\n", - " -122.255555\n", + " 37.797673\n", + " -122.262997\n", " 37.792714\n", " -122.24878\n", - " 1965\n", - " Female\n", - " <NA>\n", - " POINT (-122.25555 37.79581)\n", + " 1984\n", + " Male\n", + " Yes\n", + " POINT (-122.263 37.79767)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 3\n", - " 201804251726273755\n", - " 757\n", - " 2018-04-25 17:26:27+00:00\n", - " 13th St at Franklin St\n", - " 338\n", - " 2018-04-25 17:39:05+00:00\n", + " 201711170046091337\n", + " 497\n", + " 2017-11-17 00:46:09+00:00\n", + " 10th St at Fallon St\n", + " 201\n", + " 2017-11-17 00:54:26+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 3755\n", + " 1337\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.803189\n", - " -122.270579\n", + " 37.797673\n", + " -122.262997\n", " 37.792714\n", " -122.24878\n", - " 1982\n", - " Other\n", - " No\n", - " POINT (-122.27058 37.80319)\n", + " <NA>\n", + " <NA>\n", + " <NA>\n", + " POINT (-122.263 37.79767)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 4\n", - " 20180408155601183\n", - " 1105\n", - " 2018-04-08 15:56:01+00:00\n", - " 13th St at Franklin St\n", - " 338\n", - " 2018-04-08 16:14:26+00:00\n", + " 201802201913231257\n", + " 596\n", + " 2018-02-20 19:13:23+00:00\n", + " 10th St at Fallon St\n", + " 201\n", + " 2018-02-20 19:23:19+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 183\n", + " 1257\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.803189\n", - " -122.270579\n", + " 37.797673\n", + " -122.262997\n", " 37.792714\n", " -122.24878\n", - " 1987\n", - " Female\n", - " No\n", - " POINT (-122.27058 37.80319)\n", + " 1984\n", + " Male\n", + " Yes\n", + " POINT (-122.263 37.79767)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 5\n", - " 201804191648501560\n", - " 857\n", - " 2018-04-19 16:48:50+00:00\n", - " 13th St at Franklin St\n", - " 338\n", - " 2018-04-19 17:03:08+00:00\n", + " 201708242325001279\n", + " 1341\n", + " 2017-08-24 23:25:00+00:00\n", + " 10th St at Fallon St\n", + " 201\n", + " 2017-08-24 23:47:22+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 1560\n", + " 1279\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.803189\n", - " -122.270579\n", + " 37.797673\n", + " -122.262997\n", " 37.792714\n", " -122.24878\n", - " 1982\n", - " Other\n", - " No\n", - " POINT (-122.27058 37.80319)\n", + " 1969\n", + " Male\n", + " <NA>\n", + " POINT (-122.263 37.79767)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 6\n", - " 20170810204454839\n", - " 1256\n", - " 2017-08-10 20:44:54+00:00\n", - " 2nd Ave at E 18th St\n", - " 200\n", - " 2017-08-10 21:05:50+00:00\n", + " 201801161800473291\n", + " 489\n", + " 2018-01-16 18:00:47+00:00\n", + " 10th St at Fallon St\n", + " 201\n", + " 2018-01-16 18:08:56+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 839\n", + " 3291\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.800214\n", - " -122.25381\n", + " 37.797673\n", + " -122.262997\n", " 37.792714\n", " -122.24878\n", - " <NA>\n", - " <NA>\n", - " <NA>\n", - " POINT (-122.25381 37.80021)\n", + " 1984\n", + " Male\n", + " Yes\n", + " POINT (-122.263 37.79767)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 7\n", - " 20171012204438666\n", - " 630\n", - " 2017-10-12 20:44:38+00:00\n", - " 2nd Ave at E 18th St\n", - " 200\n", - " 2017-10-12 20:55:09+00:00\n", + " 20180408155601183\n", + " 1105\n", + " 2018-04-08 15:56:01+00:00\n", + " 13th St at Franklin St\n", + " 338\n", + " 2018-04-08 16:14:26+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 666\n", + " 183\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.800214\n", - " -122.25381\n", + " 37.803189\n", + " -122.270579\n", " 37.792714\n", " -122.24878\n", - " <NA>\n", - " <NA>\n", - " <NA>\n", - " POINT (-122.25381 37.80021)\n", + " 1987\n", + " Female\n", + " No\n", + " POINT (-122.27058 37.80319)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 8\n", - " 201711181823281960\n", - " 353\n", - " 2017-11-18 18:23:28+00:00\n", - " 2nd Ave at E 18th St\n", - " 200\n", - " 2017-11-18 18:29:22+00:00\n", + " 201803141857032204\n", + " 619\n", + " 2018-03-14 18:57:03+00:00\n", + " 13th St at Franklin St\n", + " 338\n", + " 2018-03-14 19:07:23+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 1960\n", + " 2204\n", " <NA>\n", " ...\n", " <NA>\n", - " 37.800214\n", - " -122.25381\n", + " 37.803189\n", + " -122.270579\n", " 37.792714\n", " -122.24878\n", - " 1988\n", - " Male\n", - " <NA>\n", - " POINT (-122.25381 37.80021)\n", + " 1982\n", + " Other\n", + " No\n", + " POINT (-122.27058 37.80319)\n", " POINT (-122.24878 37.79271)\n", " \n", " \n", " 9\n", - " 20170806183917510\n", - " 298\n", - " 2017-08-06 18:39:17+00:00\n", + " 201708192053311490\n", + " 743\n", + " 2017-08-19 20:53:31+00:00\n", " 2nd Ave at E 18th St\n", " 200\n", - " 2017-08-06 18:44:15+00:00\n", + " 2017-08-19 21:05:54+00:00\n", " 10th Ave at E 15th St\n", " 222\n", - " 510\n", + " 1490\n", " <NA>\n", " ...\n", " <NA>\n", @@ -370,8 +374,8 @@ " -122.25381\n", " 37.792714\n", " -122.24878\n", - " 1969\n", - " Male\n", + " <NA>\n", + " <NA>\n", " <NA>\n", " POINT (-122.25381 37.80021)\n", " POINT (-122.24878 37.79271)\n", @@ -383,76 +387,76 @@ ], "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", + "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", - "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", + "201803141857032204 619 2018-03-14 18:57:03+00:00 \n", + "201708192053311490 743 2017-08-19 20:53:31+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", + " 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-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", + "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", "\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 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 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", + "10th Ave at E 15th St 222 2204 ... \n", + "10th Ave at E 15th St 222 1490 ... \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.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 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 \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", + " Male Yes POINT (-122.24878 37.79271) \n", " POINT (-122.263 37.79767) \n", - " Female POINT (-122.25555 37.79581) \n", - " Other No POINT (-122.27058 37.80319) \n", + " Male Yes POINT (-122.263 37.79767) \n", + " POINT (-122.263 37.79767) \n", + " Male Yes POINT (-122.263 37.79767) \n", + " Male POINT (-122.263 37.79767) \n", + " Male Yes POINT (-122.263 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", - " 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", @@ -470,7 +474,7 @@ "[1947417 rows x 21 columns]" ] }, - "execution_count": 3, + "execution_count": 4, "metadata": {}, "output_type": "execute_result" } @@ -496,7 +500,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -515,7 +519,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": 6, "metadata": {}, "outputs": [ { @@ -616,7 +620,7 @@ "[2842 rows x 2 columns]" ] }, - "execution_count": 5, + "execution_count": 6, "metadata": {}, "output_type": "execute_result" } @@ -636,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [ { @@ -671,92 +675,92 @@ " \n", " \n", " 0\n", - " 2018-04-24 12:00:00+00:00\n", - " 147.023743\n", + " 2018-04-26 19:00:00+00:00\n", + " 285.19986\n", " 0.95\n", - " 98.736624\n", - " 195.310862\n", + " 234.703086\n", + " 335.696633\n", " \n", " \n", " \n", " 1\n", - " 2018-04-25 00:00:00+00:00\n", - " 6.955032\n", + " 2018-04-29 11:00:00+00:00\n", + " 109.57991\n", " 0.95\n", - " -6.094232\n", - " 20.004297\n", + " 46.225666\n", + " 172.934155\n", " \n", " \n", " \n", " 2\n", - " 2018-04-26 05:00:00+00:00\n", - " -37.196533\n", + " 2018-04-26 17:00:00+00:00\n", + " 649.004272\n", " 0.95\n", - " -88.759566\n", - " 14.366499\n", + " 537.533474\n", + " 760.475071\n", " \n", " \n", " \n", " 3\n", - " 2018-04-26 14:00:00+00:00\n", - " 115.635132\n", + " 2018-04-26 20:00:00+00:00\n", + " 192.555222\n", " 0.95\n", - " 30.120832\n", - " 201.149432\n", + " 167.90051\n", + " 217.209933\n", " \n", " \n", " \n", " 4\n", - " 2018-04-27 02:00:00+00:00\n", - " 2.516006\n", + " 2018-04-29 21:00:00+00:00\n", + " 39.108562\n", " 0.95\n", - " -69.095591\n", - " 74.127604\n", + " -33.009109\n", + " 111.226234\n", " \n", " \n", " \n", " 5\n", - " 2018-04-29 03:00:00+00:00\n", - " 22.503326\n", + " 2018-04-25 07:00:00+00:00\n", + " 358.756592\n", " 0.95\n", - " -38.714378\n", - " 83.721031\n", + " 276.305603\n", + " 441.207581\n", " \n", " \n", " \n", " 6\n", - " 2018-04-24 04:00:00+00:00\n", - " -12.259079\n", + " 2018-04-27 22:00:00+00:00\n", + " 103.589096\n", " 0.95\n", - " -45.377262\n", - " 20.859104\n", + " 94.45235\n", + " 112.725842\n", " \n", " \n", " \n", " 7\n", - " 2018-04-24 14:00:00+00:00\n", - " 126.519211\n", + " 2018-04-28 04:00:00+00:00\n", + " 10.61972\n", " 0.95\n", - " 96.837778\n", - " 156.200644\n", + " 13.41772\n", + " 7.821721\n", " \n", " \n", " \n", " 8\n", - " 2018-04-26 11:00:00+00:00\n", - " 120.90567\n", + " 2018-04-28 17:00:00+00:00\n", + " 150.812927\n", " 0.95\n", - " 35.781735\n", - " 206.029606\n", + " 135.032989\n", + " 166.592866\n", " \n", " \n", " \n", " 9\n", - " 2018-04-27 13:00:00+00:00\n", - " 162.023026\n", + " 2018-04-24 10:00:00+00:00\n", + " 221.464111\n", " 0.95\n", - " 103.946307\n", - " 220.099744\n", + " 154.598621\n", + " 288.329602\n", " \n", " \n", " \n", @@ -766,28 +770,28 @@ ], "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", + "2018-04-26 19:00:00+00:00 285.19986 0.95 \n", + "2018-04-29 11:00:00+00:00 109.57991 0.95 \n", + "2018-04-26 17:00:00+00:00 649.004272 0.95 \n", + "2018-04-26 20:00:00+00:00 192.555222 0.95 \n", + "2018-04-29 21:00:00+00:00 39.108562 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", + "2018-04-28 04:00:00+00:00 10.61972 0.95 \n", + "2018-04-28 17:00:00+00:00 150.812927 0.95 \n", + "2018-04-24 10:00:00+00:00 221.464111 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", + " 234.703086 335.696633 \n", + " 46.225666 172.934155 \n", + " 537.533474 760.475071 \n", + " 167.90051 217.209933 \n", + " -33.009109 111.226234 \n", + " 276.305603 441.207581 \n", + " 94.45235 112.725842 \n", + " 13.41772 7.821721 \n", + " 135.032989 166.592866 \n", + " 154.598621 288.329602 \n", "\n", "ai_forecast_status \n", " \n", @@ -805,16 +809,14 @@ "[168 rows x 6 columns]" ] }, - "execution_count": 6, + "execution_count": 7, "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 = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \n", "result" ] }, @@ -827,7 +829,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -847,7 +849,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -856,13 +858,13 @@ "" ] }, - "execution_count": 8, + "execution_count": 9, "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/hBBCCCGEEEIIIcSfbROs0geOPfZY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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/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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAABREAAAKnCAYAAAARNgr5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzs/Xu8JVdd5o8/q2rvfbo7Tec2SXeiIQSJA4EAMfiFNo4wEBNCRIEgioxDNKM/eQUZyIDKGEkICMgPUISgqBBwEJ3BLzAYuSREAkhCuF8EBxSIHcyNEZJOk3SfvavW94+qVbXW2rXP6VprnbX2qfO8X69+7XPpXadq712raj3r+XweIaWUIIQQQgghhBBCCCGEkAVkqXeAEEIIIYQQQgghhBCy3FBEJIQQQgghhBBCCCGErAlFREIIIYQQQgghhBBCyJpQRCSEEEIIIYQQQgghhKwJRURCCCGEEEIIIYQQQsiaUEQkhBBCCCGEEEIIIYSsCUVEQgghhBBCCCGEEELImlBEJIQQQgghhBBCCCGErMko9Q64UpYlbr31VtzvfveDECL17hBCCCGEEEIIIYQQsqmQUuKee+7BiSeeiCxb22u4aUXEW2+9FSeddFLq3SCEEEIIIYQQQgghZFNzyy234Ad/8AfX/D+bVkS83/3uB6A6yF27diXeG0IIIYQQQgghhBBCNhf79+/HSSed1Ohsa7FpRURVwrxr1y6KiIQQQgghhBBCCCGEOHI4rQIZrEIIIYQQQgghhBBCCFkTioiEEEIIIYQQQgghhJA1oYhICCGEEEIIIYQQQghZk03bE/FwkFJiNpuhKIrUu0KIN3meYzQaHVafAkIIIYQQQgghhJCQDFZEXF1dxW233YZ777039a4QEowdO3bghBNOwGQySb0rhBBCCCGEEEII2UIMUkQsyxLf+ta3kOc5TjzxREwmE7q3yKZGSonV1VV85zvfwbe+9S2ceuqpyDJ2IyCEEEIIIYQQQkgcBikirq6uoixLnHTSSdixY0fq3SEkCNu3b8d4PMa//Mu/YHV1Fdu2bUu9S4QQQgghhBBCCNkiDNrKRKcWGRr8TBNCCCGEEEIIISQFVCQIIYQQQgghhBBCCCFrQhGREEIIIYQQQgghhBCyJhQRSVAuv/xyPPKRj0y9G4QQQgghhBBCCCEkIBQRybo87nGPw/Of//zD+r8vfOELcd11123sDhFCCCGEEEIIIYSQqAwynZnER0qJoiiwc+dO7Ny5M/XuEEIIIYQQQgghhJCAbBknopQS967OkvyTUh72fj7ucY/D8573PPzGb/wGjjnmGOzZsweXX345AODmm2+GEAJf+MIXmv9/1113QQiB66+/HgBw/fXXQwiBD33oQzjjjDOwfft2PP7xj8edd96JD3zgA3jIQx6CXbt24Rd+4Rdw7733rrs/F154IT760Y/i9a9/PYQQEELg5ptvbv7OBz7wAZx55plYWVnB3//938+VM1944YV4ylOegpe+9KU47rjjsGvXLvzar/0aVldXm//z13/91zj99NOxfft2HHvssTj77LPx/e9//7BfM0IIIYQQQgghhBCysWwZJ+J90wKnveRDSf72V684Fzsmh/9Sv/3tb8cll1yCm266CTfeeCMuvPBCnHXWWTj11FMPexuXX3453vjGN2LHjh14xjOegWc84xlYWVnBO9/5Thw4cABPfepT8YY3vAG/+Zu/ueZ2Xv/61+PrX/86Hvawh+GKK64AABx33HG4+eabAQC/9Vu/hde85jV44AMfiKOPProRM3Wuu+46bNu2Dddffz1uvvlm/NIv/RKOPfZY/O7v/i5uu+02PPOZz8SrX/1qPPWpT8U999yDj3/8472EV0IIIYQQQgghhBCysWwZEXEz8fCHPxyXXXYZAODUU0/FG9/4Rlx33XW9RMSXv/zlOOusswAAF110EV784hfjG9/4Bh74wAcCAJ7+9KfjIx/5yLoi4pFHHonJZIIdO3Zgz549c7+/4oor8JM/+ZNrbmMymeCtb30rduzYgYc+9KG44oor8KIXvQgve9nLcNttt2E2m+FpT3saTj75ZADA6aefftjHSQghhBBCCCGEEEI2ni0jIm4f5/jqFecm+9t9ePjDH258f8IJJ+DOO+903sbu3buxY8eORkBUP/vUpz7Va5tdPOpRj1r3/zziEY/Ajh07mu/37t2LAwcO4JZbbsEjHvEIPOEJT8Dpp5+Oc889F+eccw6e/vSn4+ijj/beN0IIIYQQQgghhBAShi0jIgohepUUp2Q8HhvfCyFQliWyrGphqZf6TqfTdbchhFi4TV+OOOIIr+fneY5rr70WN9xwA6655hq84Q1vwG//9m/jpptuwimnnOK9f4QQQgghhBBCCCHEny0TrDIEjjvuOADAbbfd1vxMD1nZKCaTCYqicH7+F7/4Rdx3333N95/85Cexc+dOnHTSSQAqQfOss87CS1/6Unz+85/HZDLBe97zHu/9JoQQQgghhBBCCCFh2BzWPAIA2L59Ox7zmMfgVa96FU455RTceeeduPTSSzf87z7gAQ/ATTfdhJtvvhk7d+7EMccc0+v5q6uruOiii3DppZfi5ptvxmWXXYbnPve5yLIMN910E6677jqcc845OP7443HTTTfhO9/5Dh7ykIds0NEQQgghhBBCCCGEkL7QibjJeOtb34rZbIYzzzwTz3/+8/Hyl798w//mC1/4QuR5jtNOOw3HHXcc9u3b1+v5T3jCE3DqqafiJ37iJ/BzP/dz+Omf/mlcfvnlAIBdu3bhYx/7GJ70pCfhh3/4h3HppZfita99Lc4777wNOBJCCCGEEEIIIYQQ4oKQeoO9TcT+/ftx5JFH4u6778auXbuM3x08eBDf+ta3cMopp2Dbtm2J9pAAwIUXXoi77roL733ve1PvyiDgZ5sQQgghhBBCCCGhWEtfs6ETkRBCCCGEEEIIIYQQsiYUEbc4+/btw86dOxf+61u6TAghhBBCCCGEELJsTIsS//sL/4rb7z6Yelc2LQxW2eKceOKJayY8n3jiiV7bf9vb3ub1fEIIIYQQQgghhBBfPvq17+C//tUX8ORHnIg3PPOM1LuzKaGIuMUZjUZ40IMelHo3CCGEEEIIIYQQQjaM7967Wj1+/1DiPdm8sJyZEEIIIYQQQgghhAwalSs8LTZlvvBSQBGREEIIIYQQQgghhAyastYOp0WZdkc2MRQRCSGEEEIIIYRsOAenBa7+0q24+95p6l0hhGxBytqJOKMT0RmKiIQQQgghhBBCNpx3f+5f8dx3fh5XXv/PqXeFELIFoRPRH4qIhBBCCCGEEEI2nO/VoQb/dmA18Z4QQrYibU9EioiuUEQkQbn88svxyEc+Murf2717N4QQeO973xvt7xJCCCGEEEL6UdY2oKLkBJ4QEh81Bs1KljO7QhGRrMvjHvc4PP/5zz+s//vCF74Q11133cbuUM0//uM/4qUvfSne/OY347bbbsN5550X5e9uBH1eY0IIIYQQQjYjat7OCTwhJAVNOfOMCxmujFLvABkGUkoURYGdO3di586dUf7mN77xDQDAz/zMz0AI4byd6XSK8XgcarcIIYQQQgghHRR1KaEKNyCEkJiosWfKhQxnto4TUUpg9ftp/vW4SD7ucY/D8573PPzGb/wGjjnmGOzZsweXX345AODmm2+GEAJf+MIXmv9/1113QQiB66+/HgBw/fXXQwiBD33oQzjjjDOwfft2PP7xj8edd96JD3zgA3jIQx6CXbt24Rd+4Rdw7733rrs/F154IT760Y/i9a9/PYQQEELg5ptvbv7OBz7wAZx55plYWVnB3//938+VM1944YV4ylOegpe+9KU47rjjsGvXLvzar/0aVlfbPih//dd/jdNPPx3bt2/Hsccei7PPPhvf//7319yvyy+/HE9+8pMBAFmWNSJiWZa44oor8IM/+INYWVnBIx/5SHzwgx9snqdew//5P/8nHvvYx2Lbtm34i7/4CwDAn/3Zn+EhD3kItm3bhgc/+MF405veZPzNb3/723jmM5+JY445BkcccQQe9ahH4aabbgJQCZo/8zM/g927d2Pnzp340R/9UXz4wx82nv+mN70Jp556KrZt24bdu3fj6U9/+pqvMSGEEEIIIUNCMhmVEJKQkj0Rvdk6TsTpvcArTkzzt//7rcDkiMP+729/+9txySWX4KabbsKNN96ICy+8EGeddRZOPfXUw97G5Zdfjje+8Y3YsWMHnvGMZ+AZz3gGVlZW8M53vhMHDhzAU5/6VLzhDW/Ab/7mb665nde//vX4+te/joc97GG44oorAADHHXdcI3L91m/9Fl7zmtfggQ98II4++uhGzNS57rrrsG3bNlx//fW4+eab8Uu/9Es49thj8bu/+7u47bbb8MxnPhOvfvWr8dSnPhX33HMPPv7xjzc3GIt44QtfiAc84AH4pV/6Jdx2223G/r72ta/Fm9/8Zpxxxhl461vfip/+6Z/GV77yFeP1+63f+i289rWvxRlnnNEIiS95yUvwxje+EWeccQY+//nP41d+5VdwxBFH4NnPfjYOHDiAxz72sfiBH/gBvO9978OePXvwuc99DmXdz+XAgQN40pOehN/93d/FysoK/vzP/xxPfvKT8bWvfQ33v//98ZnPfAbPe97z8D/+x//Aj/3Yj+G73/0uPv7xj6/5GhNCCCGEEDIk1AS+oAuIEJKApqUCFzKc2Toi4ibi4Q9/OC677DIAwKmnnoo3vvGNuO6663qJiC9/+ctx1llnAQAuuugivPjFL8Y3vvENPPCB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", "text/plain": [ "
" ] @@ -879,7 +881,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv (3.10.17)", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -893,7 +895,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.17" + "version": "3.10.16" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb b/notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb new file mode 100644 index 00000000000..a1bb1e9d89d --- /dev/null +++ b/notebooks/generative_ai/bq_dataframes_llm_claude3_museum_art.ipynb @@ -0,0 +1,1019 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": { + "id": "9A9NkTRTfo2I" + }, + "outputs": [], + "source": [ + "# Copyright 2024 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": "8fK_rdvvx1iZ" + }, + "source": [ + "## Overview\n", + "\n", + "## Objective\n", + "\n", + "This notebook shows how to conecct BigQuery dataset to Claude models on Vertex AI using BigQuery DataFrames.\n", + "\n", + "### Claude on Vertex AI\n", + "\n", + "Anthropic Claude models on Vertex AI offer fully managed and serverless models. To use a Claude model on Vertex AI, send a request directly to the Vertex AI API endpoint.\n", + "\n", + "For more information, see the [Use Claude](https://cloud.devsite.corp.google.com/vertex-ai/generative-ai/docs/third-party-models/use-claude) documentation.\n", + "\n", + "### BigQuery DataFrames\n", + "BigQuery DataFrames provides a Pythonic DataFrame and machine learning (ML) API powered by the BigQuery engine. BigQuery DataFrames is an open-source package.\n", + "\n", + "For more information, see this documentation\n", + "https://cloud.google.com/bigquery/docs/reference/bigquery-dataframes\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "nwYvaaW25jYS" + }, + "source": [ + "### Getting Started\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "hVi8v2mxBkeG" + }, + "source": [ + "#### Authenticate your notebook environment (Colab only)\n", + "If you are running this notebook on Google Colab, uncomment and run the following cell to authenticate your environment. This step is not required if you are using [Vertex AI Workbench](https://cloud.google.com/vertex-ai-workbench)." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "OHfMDNI76_Pz" + }, + "outputs": [], + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "gI3KlxQQ_F_T" + }, + "source": [ + "## Using Anthropic's Vertex SDK + BQ for *Python*" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "E0x3GO6M_O3_" + }, + "source": [ + "### Getting Started\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_CJrqUvqAfR7" + }, + "source": [ + "#### Install the latest bigframes package if bigframes version < 1.15.0\n", + "\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "collapsed": true, + "executionInfo": { + "elapsed": 11539, + "status": "ok", + "timestamp": 1724257409246, + "user": { + "displayName": "Annie Xu", + "userId": "11935526703047498014" + }, + "user_tz": 420 + }, + "id": "fi_HLdat_Pce", + "outputId": "020149f0-9fe8-45de-f160-abe488c0bed2" + }, + "outputs": [], + "source": [ + "# !pip install bigframes --upgrade" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "hUiAYUFbBCpR" + }, + "source": [ + "#### Restart current runtime\n", + "\n", + "To use the newly installed packages in this Jupyter runtime, you must restart the runtime. You can do this by running the cell below, which will restart the current kernel." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "id": "jcqgcj_DBFgt" + }, + "outputs": [], + "source": [ + "# # Restart kernel after installs so that your environment can access the new packages\n", + "# import sys\n", + "\n", + "# if \"google.colab\" in sys.modules:\n", + "# import IPython\n", + "\n", + "# app = IPython.Application.instance()\n", + "# app.kernel.do_shutdown(True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "shZgRl6qbZYP" + }, + "source": [ + "#### Define Google Cloud project and region information" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": { + "id": "JZLqMJ6va9fc" + }, + "outputs": [], + "source": [ + "# Input your project id\n", + "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "czcmJpKPBMVC" + }, + "source": [ + "#### Select Claude Model and Region Availability:\n", + "https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude#anthropic_claude_quotas_and_supported_context_length" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": { + "id": "wIBkGcFkK0Ci" + }, + "outputs": [], + "source": [ + "REGION = \"us-east5\" # @param {type:\"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "F3UmCLerH0t0" + }, + "source": [ + "### Load raw sample data to a bigquery dataset\n", + "\n", + "Create a BigQuery Dataset and table. You can use the sample museum data in CSV from [here](https://github.com/googleapis/python-bigquery-dataframes/tree/main/notebooks/generative_ai/museum_art.csv).\n", + "\n", + "The dataset should be in the **same region** as your chosen claude model. Let's say you selected us-east5 for claude 'haiku', then load the sample data to a dataset in us-east5." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "gijJ2vr5B5nV" + }, + "source": [ + "### Text generation for BQ Tables using Python BigFrames\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 1000 + }, + "collapsed": true, + "executionInfo": { + "elapsed": 756, + "status": "ok", + "timestamp": 1724260427446, + "user": { + "displayName": "Annie Xu", + "userId": "11935526703047498014" + }, + "user_tz": 420 + }, + "id": "cU3Gq7TqHFdi", + "outputId": "aa5ec159-a91b-4349-e56a-400e90935edc" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "object_number string[pyarrow]\n", + "is_highlight boolean\n", + "is_public_domain boolean\n", + "object_id Int64\n", + "department string[pyarrow]\n", + "object_name string[pyarrow]\n", + "title string[pyarrow]\n", + "culture string[pyarrow]\n", + "period string[pyarrow]\n", + "dynasty string[pyarrow]\n", + "reign string[pyarrow]\n", + "portfolio string[pyarrow]\n", + "artist_role string[pyarrow]\n", + "artist_prefix string[pyarrow]\n", + "artist_display_name string[pyarrow]\n", + "artist_display_bio string[pyarrow]\n", + "artist_suffix string[pyarrow]\n", + "artist_alpha_sort string[pyarrow]\n", + "artist_nationality string[pyarrow]\n", + "artist_begin_date string[pyarrow]\n", + "artist_end_date string[pyarrow]\n", + "object_date string[pyarrow]\n", + "object_begin_date Int64\n", + "object_end_date Int64\n", + "medium string[pyarrow]\n", + "dimensions string[pyarrow]\n", + "credit_line string[pyarrow]\n", + "geography_type string[pyarrow]\n", + "city string[pyarrow]\n", + "state string[pyarrow]\n", + "county string[pyarrow]\n", + "country string[pyarrow]\n", + "region string[pyarrow]\n", + "subregion string[pyarrow]\n", + "locale string[pyarrow]\n", + "locus string[pyarrow]\n", + "excavation string[pyarrow]\n", + "river string[pyarrow]\n", + "classification string[pyarrow]\n", + "rights_and_reproduction string[pyarrow]\n", + "link_resource string[pyarrow]\n", + "metadata_date timestamp[us, tz=UTC][pyarrow]\n", + "repository string[pyarrow]\n", + "dtype: object" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import bigframes\n", + "import bigframes.pandas as bpd\n", + "bigframes.options._bigquery_options.project = PROJECT_ID # replace to user project\n", + "bigframes.options._bigquery_options.location = REGION #choice a region which the claude model you choice allows\n", + "df = bpd.read_gbq(\"bigframes-dev.garrettwu_us_east5.museum_art\") # replace with your table\n", + "df.dtypes" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 461 + }, + "executionInfo": { + "elapsed": 4568, + "status": "ok", + "timestamp": 1724271168583, + "user": { + "displayName": "Annie Xu", + "userId": "11935526703047498014" + }, + "user_tz": 420 + }, + "id": "exWNXEzLHHaU", + "outputId": "1b33b64c-c8bd-42e6-ecc3-0ea0b5e492be" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 1998408a-4e29-4381-9229-cf8585a47dbe is DONE. 7.7 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 817a5321-9852-45da-8b14-004affc20c38 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 36aa1b30-acb5-4188-8377-b9f544443db8 is DONE. 955 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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
object_idtitle
0285844Addie Card, 12 years. Spinner in North Pownal ...
1437141Portrait of a Man
2670650[Snow Crystal]
3268450Newhaven Fisherman
4646996전(傳) 오원 장승업 (1843–1897) 청동기와 화초가 있는 정물화 조선|傳 吾...
5287958Bridge of Augustus at Nani
6435869Antoine Dominique Sauveur Aubert (born 1817), ...
755834<NA>
845087<NA>
956883<NA>
\n", + "

10 rows × 2 columns

\n", + "
[10 rows x 2 columns in total]" + ], + "text/plain": [ + " object_id title\n", + "0 285844 Addie Card, 12 years. Spinner in North Pownal ...\n", + "1 437141 Portrait of a Man\n", + "2 670650 [Snow Crystal]\n", + "3 268450 Newhaven Fisherman\n", + "4 646996 전(傳) 오원 장승업 (1843–1897) 청동기와 화초가 있는 정물화 조선|傳 吾...\n", + "5 287958 Bridge of Augustus at Nani\n", + "6 435869 Antoine Dominique Sauveur Aubert (born 1817), ...\n", + "7 55834 \n", + "8 45087 \n", + "9 56883 \n", + "\n", + "[10 rows x 2 columns]" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# @title query: select top 10 records from table and put into dataframe\n", + "\n", + "df = df[[\"object_id\", \"title\"]].head(10)\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "_UZNsP_WDlyr" + }, + "source": [ + "### Enable Claude model on Vertex AI and Create a BQ External Model Connection\n", + "\n", + "\n", + "* Step 1: Visit the Vertex AI Model Garden console and select the model tile for Claude model of your choice. Following this doc [link](https://cloud.google.com/vertex-ai/generative-ai/docs/partner-models/use-claude). Click on the **“Enable”** button and follow the instructions.\n", + "\n", + "* Step 2: Create a BQ External Connection\n", + "Follow the same process like this one: [link](https://cloud.google.com/bigquery/docs/generate-text#create_a_connection). Pay attention to the **supported region** of Claude models and make your conenction follow the same region for example us-east5 for Claude 3.5.\n", + "\n", + "\n", + "\n", + "\n", + "\n" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "8Q4aff5m9QOn" + }, + "source": [ + "### Use BigQuery DataFrames ML package with Claude LLM \n", + "\n", + "In this example, we are using the Claude3TextGenerator class from BigQuery DataFrames to translate title of art piece to english.\n", + "\n", + "Documentation for the Claude3TextGenerator Class: https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.ml.llm.Claude3TextGenerator" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 513 + }, + "executionInfo": { + "elapsed": 25662, + "status": "ok", + "timestamp": 1724271197922, + "user": { + "displayName": "Annie Xu", + "userId": "11935526703047498014" + }, + "user_tz": 420 + }, + "id": "1pdyI5KBTyTD", + "outputId": "8f1e976b-1fd0-49ba-e068-f480eafb1765" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 514f5afe-15e0-4474-9e09-fbf94f0fe8ca is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 5d4df544-e8a4-42f3-8a94-5f7e79b23562 is DONE. 635 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 25288d94-b10c-4b39-a272-3969ccb19af3 is DONE. 14 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job d5693878-1037-4798-8aa0-f568ec0be9e3 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 54080328-ba8b-4715-bf2b-3e5b7affa90b is DONE. 4.0 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", + "
ml_generate_text_llm_resultml_generate_text_statusprompt
0This text is already in English. It appears to...translate this into English: Addie Card, 12 ye...
1The phrase \"Portrait of a Man\" is already in E...translate this into English: Portrait of a Man
2The phrase \"[Snow Crystal]\" is already in Engl...translate this into English: [Snow Crystal]
3The phrase \"Newhaven Fisherman\" is already in ...translate this into English: Newhaven Fisherman
4Here's the English translation:\n", + "\n", + "\"Attributed t...translate this into English: 전(傳) 오원 장승업 (1843...
5I apologize, but I'm not sure which language \"...translate this into English: Bridge of Augustu...
6This title is already in English. It describes...translate this into English: Antoine Dominique...
7<NA><NA><NA>
8<NA><NA><NA>
9<NA><NA><NA>
\n", + "

10 rows × 3 columns

\n", + "
[10 rows x 3 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result ml_generate_text_status \\\n", + "0 This text is already in English. It appears to... \n", + "1 The phrase \"Portrait of a Man\" is already in E... \n", + "2 The phrase \"[Snow Crystal]\" is already in Engl... \n", + "3 The phrase \"Newhaven Fisherman\" is already in ... \n", + "4 Here's the English translation:\n", + "\n", + "\"Attributed t... \n", + "5 I apologize, but I'm not sure which language \"... \n", + "6 This title is already in English. It describes... \n", + "7 \n", + "8 \n", + "9 \n", + "\n", + " prompt \n", + "0 translate this into English: Addie Card, 12 ye... \n", + "1 translate this into English: Portrait of a Man \n", + "2 translate this into English: [Snow Crystal] \n", + "3 translate this into English: Newhaven Fisherman \n", + "4 translate this into English: 전(傳) 오원 장승업 (1843... \n", + "5 translate this into English: Bridge of Augustu... \n", + "6 translate this into English: Antoine Dominique... \n", + "7 \n", + "8 \n", + "9 \n", + "\n", + "[10 rows x 3 columns]" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from bigframes.ml import llm\n", + "model = llm.Claude3TextGenerator(model_name=\"claude-3-5-sonnet\",\n", + " connection_name=\"bigframes-dev.us-east5.bigframes-rf-conn\" ) # replace with your connection\n", + "df[\"input_prompt\"] = \"translate this into English: \" + df[\"title\"]\n", + "result = model.predict(df[\"input_prompt\"])\n", + "result" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 461 + }, + "executionInfo": { + "elapsed": 5249, + "status": "ok", + "timestamp": 1724274172557, + "user": { + "displayName": "Annie Xu", + "userId": "11935526703047498014" + }, + "user_tz": 420 + }, + "id": "Ux1VI5qujHOB", + "outputId": "7b859943-5e7c-4cc0-d9c2-bb3d44682010" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 6b6eceaa-e713-493e-beac-481a3d777a5c is DONE. 4.9 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 5c660da9-318c-424e-9412-43f09e44a8b3 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 82b61007-8370-4514-addb-258d7c48d66c is DONE. 4.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", + "
object_idtitleml_generate_text_llm_resultprompt
0285844Addie Card, 12 years. Spinner in North Pownal ...This text is already in English. It appears to...translate this into English: Addie Card, 12 ye...
1437141Portrait of a ManThe phrase \"Portrait of a Man\" is already in E...translate this into English: Portrait of a Man
2670650[Snow Crystal]The phrase \"[Snow Crystal]\" is already in Engl...translate this into English: [Snow Crystal]
3268450Newhaven FishermanThe phrase \"Newhaven Fisherman\" is already in ...translate this into English: Newhaven Fisherman
4646996전(傳) 오원 장승업 (1843–1897) 청동기와 화초가 있는 정물화 조선|傳 吾...Here's the English translation:\n", + "\n", + "\"Attributed t...translate this into English: 전(傳) 오원 장승업 (1843...
5287958Bridge of Augustus at NaniI apologize, but I'm not sure which language \"...translate this into English: Bridge of Augustu...
6435869Antoine Dominique Sauveur Aubert (born 1817), ...This title is already in English. It describes...translate this into English: Antoine Dominique...
755834<NA><NA><NA>
845087<NA><NA><NA>
956883<NA><NA><NA>
\n", + "

10 rows × 4 columns

\n", + "
[10 rows x 4 columns in total]" + ], + "text/plain": [ + " object_id title \\\n", + "0 285844 Addie Card, 12 years. Spinner in North Pownal ... \n", + "1 437141 Portrait of a Man \n", + "2 670650 [Snow Crystal] \n", + "3 268450 Newhaven Fisherman \n", + "4 646996 전(傳) 오원 장승업 (1843–1897) 청동기와 화초가 있는 정물화 조선|傳 吾... \n", + "5 287958 Bridge of Augustus at Nani \n", + "6 435869 Antoine Dominique Sauveur Aubert (born 1817), ... \n", + "7 55834 \n", + "8 45087 \n", + "9 56883 \n", + "\n", + " ml_generate_text_llm_result \\\n", + "0 This text is already in English. It appears to... \n", + "1 The phrase \"Portrait of a Man\" is already in E... \n", + "2 The phrase \"[Snow Crystal]\" is already in Engl... \n", + "3 The phrase \"Newhaven Fisherman\" is already in ... \n", + "4 Here's the English translation:\n", + "\n", + "\"Attributed t... \n", + "5 I apologize, but I'm not sure which language \"... \n", + "6 This title is already in English. It describes... \n", + "7 \n", + "8 \n", + "9 \n", + "\n", + " prompt \n", + "0 translate this into English: Addie Card, 12 ye... \n", + "1 translate this into English: Portrait of a Man \n", + "2 translate this into English: [Snow Crystal] \n", + "3 translate this into English: Newhaven Fisherman \n", + "4 translate this into English: 전(傳) 오원 장승업 (1843... \n", + "5 translate this into English: Bridge of Augustu... \n", + "6 translate this into English: Antoine Dominique... \n", + "7 \n", + "8 \n", + "9 \n", + "\n", + "[10 rows x 4 columns]" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "output_df=df.drop(columns=[\"input_prompt\"]).join(result.drop(columns=\"ml_generate_text_status\"))\n", + "output_df" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "ej70vFMvelsg" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 8c3f1d21-9033-4224-b6f3-4f2414f4ed18 is DONE. 4.5 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "'bigframes-dev.garrettwu_us_east5.museum_art_translate'" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# prompt: load the dataframe output to another Bigquery table\n", + "\n", + "# @title Save results to BigQuery\n", + "\n", + "output_df.to_gbq(\"bigframes-dev.garrettwu_us_east5.museum_art_translate\", if_exists=\"replace\") # replace with your table" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "colab": { + "provenance": [] + }, + "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.9" + } + }, + "nbformat": 4, + "nbformat_minor": 0 +} diff --git a/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb b/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb index 527d3c4aaac..4f1329129e2 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_code_generation.ipynb @@ -1,1305 +1,1304 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 2, - "metadata": { - "id": "ur8xi4C7S06n" - }, - "outputs": [], - "source": [ - "# Copyright 2022 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": [ - "# Use BigQuery DataFrames with Generative AI for code generation\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 walk through an example use case of generating sample code by using BigQuery DataFrames and its integration with Generative AI support on Vertex AI.\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 create a CSV file containing sample code for calling a given set of APIs.\n", - "\n", - "The steps include:\n", - "\n", - "- Defining an LLM model in BigQuery DataFrames, specifically the [Gemini Model](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models), using `bigframes.ml.llm`.\n", - "- Creating a DataFrame by reading in data from Cloud Storage.\n", - "- Manipulating data in the DataFrame to build LLM prompts.\n", - "- Sending DataFrame prompts to the LLM model using the `predict` method.\n", - "- Creating and using a custom function to transform the output provided by the LLM model response.\n", - "- Exporting the resulting transformed DataFrame as a CSV file." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "08d289fa873f" - }, - "source": [ - "### Dataset\n", - "\n", - "This tutorial uses a dataset listing the names of various pandas DataFrame and Series APIs." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "aed92deeb4a0" - }, - "source": [ - "### Costs\n", - "\n", - "This tutorial uses billable components of Google Cloud:\n", - "\n", - "* BigQuery\n", - "* Generative AI support on Vertex AI\n", - "* Cloud Functions\n", - "\n", - "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models),\n", - "[Generative AI support on Vertex AI pricing](https://cloud.google.com/vertex-ai/pricing#generative_ai_models), and [Cloud Functions pricing](https://cloud.google.com/functions/pricing), 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", - "Install the following packages, which are required to run this notebook:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": { - "id": "2b4ef9b72d43" - }, - "outputs": [], - "source": [ - "!pip install bigframes --upgrade --quiet" - ] - }, - { - "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": "Wbr2aVtFQBcg" - }, - "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,cloudfunctions.googleapis.com,run.googleapis.com,artifactregistry.googleapis.com,cloudbuild.googleapis.com,cloudresourcemanager.googleapis.com) to enable the following APIs:\n", - "\n", - " * BigQuery API\n", - " * BigQuery Connection API\n", - " * Cloud Functions API\n", - " * Cloud Run API\n", - " * Artifact Registry API\n", - " * Cloud Build API\n", - " * Cloud Resource Manager 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": "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": 27, - "metadata": { - "id": "oM1iC_MfAts1" - }, - "outputs": [ + "cells": [ { - "name": "stdout", - "output_type": "stream", - "text": [ - "\u001b[1;31mERROR:\u001b[0m (gcloud.config.set) argument VALUE: Must be specified.\n", - "Usage: gcloud config set SECTION/PROPERTY VALUE [optional flags]\n", - " optional flags may be --help | --installation\n", - "\n", - "For detailed information on this command and its flags, run:\n", - " gcloud config set --help\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 bf\n", - "from google.cloud import bigquery\n", - "from google.cloud import bigquery_connection_v1 as bq_connection" - ] - }, - { - "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", - "bf.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", - "bf.options.bigquery.location = REGION" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "DTVtFlqeFbrU" - }, - "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": "6eytf4xQHzcF" - }, - "source": [ - "# Define the LLM model\n", - "\n", - "BigQuery DataFrames provides integration with [Gemini Models](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models) via Vertex AI.\n", - "\n", - "This section walks through a few steps required in order to use the model in your notebook." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "qUjT8nw-jIXp" - }, - "source": [ - "## Define the model\n", - "\n", - "Use `bigframes.ml.llm` to define the model:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "sdjeXFwcHfl7" - }, - "outputs": [ + "cell_type": "code", + "execution_count": 2, + "metadata": { + "id": "ur8xi4C7S06n" + }, + "outputs": [], + "source": [ + "# Copyright 2022 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." + ] + }, { - "data": { - "text/html": [ - "Query job 0ee1a08e-788e-4fc7-b061-52c23ab25d5a is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "JAPoU8Sm5E6e" + }, + "source": [ + "## Use BigQuery DataFrames with Generative AI for code generation\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", + "
" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "from bigframes.ml.llm import GeminiTextGenerator\n", - "\n", - "model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GbW0oCnU1s1N" - }, - "source": [ - "# Read data from Cloud Storage into BigQuery DataFrames\n", - "\n", - "You can create a BigQuery DataFrames DataFrame by reading data from any of the following locations:\n", - "\n", - "* A local data file\n", - "* Data stored in a BigQuery table\n", - "* A data file stored in Cloud Storage\n", - "* An in-memory pandas DataFrame\n", - "\n", - "In this tutorial, you create BigQuery DataFrames DataFrames by reading two CSV files stored in Cloud Storage, one containing a list of DataFrame API names and one containing a list of Series API names." - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "metadata": { - "id": "SchiTkQGIJog" - }, - "outputs": [], - "source": [ - "df_api = bf.read_csv(\"gs://cloud-samples-data/vertex-ai/bigframe/df.csv\")\n", - "series_api = bf.read_csv(\"gs://cloud-samples-data/vertex-ai/bigframe/series.csv\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "7OBjw2nmQY3-" - }, - "source": [ - "Take a peek at a few rows of data for each file:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "metadata": { - "id": "QCqgVCIsGGuv" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 48be241c-ee93-4dfa-a9e3-66b64c4b5150 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "24743cf4a1e1" + }, + "source": [ + "**_NOTE_**: This notebook has been tested in the following environment:\n", + "\n", + "* Python version = 3.10" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 6af9caa5-4f7a-48f0-a7df-d692ee063b7e is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "tvgnzT1CKxrO" + }, + "source": [ + "## Overview\n", + "\n", + "Use this notebook to walk through an example use case of generating sample code by using BigQuery DataFrames and its integration with Generative AI support on Vertex AI.\n", + "\n", + "Learn more about [BigQuery DataFrames](https://cloud.google.com/python/docs/reference/bigframes/latest)." ] - }, - "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", - "
API
0values
1dtypes
\n", - "

2 rows × 1 columns

\n", - "
[2 rows x 1 columns in total]" - ], - "text/plain": [ - " API\n", - "0 values\n", - "1 dtypes\n", - "\n", - "[2 rows x 1 columns]" + "cell_type": "markdown", + "metadata": { + "id": "d975e698c9a4" + }, + "source": [ + "### Objective\n", + "\n", + "In this tutorial, you create a CSV file containing sample code for calling a given set of APIs.\n", + "\n", + "The steps include:\n", + "\n", + "- Defining an LLM model in BigQuery DataFrames, specifically the [Gemini Model](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models), using `bigframes.ml.llm`.\n", + "- Creating a DataFrame by reading in data from Cloud Storage.\n", + "- Manipulating data in the DataFrame to build LLM prompts.\n", + "- Sending DataFrame prompts to the LLM model using the `predict` method.\n", + "- Creating and using a custom function to transform the output provided by the LLM model response.\n", + "- Exporting the resulting transformed DataFrame as a CSV file." ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df_api.head(2)" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "metadata": { - "id": "BGJnZbgEGS5-" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 41e4f2e7-689a-45d9-bf92-4416f5560b81 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "08d289fa873f" + }, + "source": [ + "### Dataset\n", + "\n", + "This tutorial uses a dataset listing the names of various pandas DataFrame and Series APIs." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job aae0b164-f786-4734-8c79-2af9805af0cf is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "aed92deeb4a0" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery\n", + "* Generative AI support on Vertex AI\n", + "* Cloud Functions\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models),\n", + "[Generative AI support on Vertex AI pricing](https://cloud.google.com/vertex-ai/pricing#generative_ai_models), and [Cloud Functions pricing](https://cloud.google.com/functions/pricing), and use the [Pricing Calculator](https://cloud.google.com/products/calculator/)\n", + "to generate a cost estimate based on your projected usage." ] - }, - "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", - "
API
0shape
1size
\n", - "

2 rows × 1 columns

\n", - "
[2 rows x 1 columns in total]" - ], - "text/plain": [ - " API\n", - "0 shape\n", - "1 size\n", - "\n", - "[2 rows x 1 columns]" + "cell_type": "markdown", + "metadata": { + "id": "i7EUnXsZhAGF" + }, + "source": [ + "## Installation\n", + "\n", + "Install the following packages, which are required to run this notebook:" ] - }, - "execution_count": 13, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "series_api.head(2)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "m3ZJEsi7SUKV" - }, - "source": [ - "# Generate code using the LLM model\n", - "\n", - "Prepare the prompts and send them to the LLM model for prediction." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9EMAqR37AfLS" - }, - "source": [ - "## Prompt design in BigQuery DataFrames\n", - "\n", - "Designing prompts for LLMs is a fast growing area and you can read more in [this documentation](https://cloud.google.com/vertex-ai/docs/generative-ai/learn/introduction-prompt-design).\n", - "\n", - "For this tutorial, you use a simple prompt to ask the LLM model for sample code for each of the API methods (or rows) from the last step's DataFrames. The output is the new DataFrames `df_prompt` and `series_prompt`, which contain the full prompt text." - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "metadata": { - "id": "EDAaIwHpQCDZ" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 17f50c10-aa81-4023-b206-4ba59ddf2269 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "code", + "execution_count": 3, + "metadata": { + "id": "2b4ef9b72d43" + }, + "outputs": [], + "source": [ + "!pip install bigframes --upgrade --quiet" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job d6d217aa-a623-4ea4-83fb-8f1b8bfb8e68 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "BF1j6f9HApxa" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job a275a107-752e-46f8-be9f-9cb35eb6b0b9 is DONE. 132 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "Wbr2aVtFQBcg" + }, + "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,cloudfunctions.googleapis.com,run.googleapis.com,artifactregistry.googleapis.com,cloudbuild.googleapis.com,cloudresourcemanager.googleapis.com) to enable the following APIs:\n", + "\n", + " * BigQuery API\n", + " * BigQuery Connection API\n", + " * Cloud Functions API\n", + " * Cloud Run API\n", + " * Artifact Registry API\n", + " * Cloud Build API\n", + " * Cloud Resource Manager API\n", + " * Vertex AI API\n", + "\n", + "4. If you are running this notebook locally, install the [Cloud SDK](https://cloud.google.com/sdk)." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/plain": [ - "0 Generate Pandas sample code for DataFrame.values\n", - "1 Generate Pandas sample code for DataFrame.dtypes\n", - "Name: API, dtype: string" + "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)." ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df_prompt_prefix = \"Generate Pandas sample code for DataFrame.\"\n", - "series_prompt_prefix = \"Generate Pandas sample code for Series.\"\n", - "\n", - "df_prompt = (df_prompt_prefix + df_api['API'])\n", - "series_prompt = (series_prompt_prefix + series_api['API'])\n", - "\n", - "df_prompt.head(2)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "rwPLjqW2Ajzh" - }, - "source": [ - "## Make predictions using the LLM model\n", - "\n", - "Use the BigQuery DataFrames DataFrame containing the full prompt text as the input to the `predict` method. The `predict` method calls the LLM model and returns its generated text output back to two new BigQuery DataFrames DataFrames, `df_pred` and `series_pred`.\n", - "\n", - "Note: The predictions might take a few minutes to run." - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "metadata": { - "id": "6i6HkFJZa8na" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 01f95d2d-901d-4edf-bd3a-245d17c31ef6 is DONE. 0 Bytes processed. Open Job" + "cell_type": "code", + "execution_count": 27, + "metadata": { + "id": "oM1iC_MfAts1" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\u001b[1;31mERROR:\u001b[0m (gcloud.config.set) argument VALUE: Must be specified.\n", + "Usage: gcloud config set SECTION/PROPERTY VALUE [optional flags]\n", + " optional flags may be --help | --installation\n", + "\n", + "For detailed information on this command and its flags, run:\n", + " gcloud config set --help\n" + ] + } ], - "text/plain": [ - "" + "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" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 55927a6f-b023-479a-b9bf-826abde77111 is DONE. 584 Bytes processed. Open Job" + "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 bf\n", + "from google.cloud import bigquery\n", + "from google.cloud import bigquery_connection_v1 as bq_connection" + ] + }, + { + "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", + "bf.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", + "bf.options.bigquery.location = REGION" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "DTVtFlqeFbrU" + }, + "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": "6eytf4xQHzcF" + }, + "source": [ + "# Define the LLM model\n", + "\n", + "BigQuery DataFrames provides integration with [Gemini Models](https://cloud.google.com/vertex-ai/generative-ai/docs/learn/models#gemini-models) via Vertex AI.\n", + "\n", + "This section walks through a few steps required in order to use the model in your notebook." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "qUjT8nw-jIXp" + }, + "source": [ + "## Define the model\n", + "\n", + "Use `bigframes.ml.llm` to define the model:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": { + "id": "sdjeXFwcHfl7" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 0ee1a08e-788e-4fc7-b061-52c23ab25d5a is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "source": [ + "from bigframes.ml.llm import GeminiTextGenerator\n", + "\n", + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 445eb0af-f643-40c5-9c1e-25aa3db8374a is DONE. 146 Bytes processed. Open Job" + "cell_type": "markdown", + "metadata": { + "id": "GbW0oCnU1s1N" + }, + "source": [ + "# Read data from Cloud Storage into BigQuery DataFrames\n", + "\n", + "You can create a BigQuery DataFrames DataFrame by reading data from any of the following locations:\n", + "\n", + "* A local data file\n", + "* Data stored in a BigQuery table\n", + "* A data file stored in Cloud Storage\n", + "* An in-memory pandas DataFrame\n", + "\n", + "In this tutorial, you create BigQuery DataFrames DataFrames by reading two CSV files stored in Cloud Storage, one containing a list of DataFrame API names and one containing a list of Series API names." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": { + "id": "SchiTkQGIJog" + }, + "outputs": [], + "source": [ + "df_api = bf.read_csv(\"gs://cloud-samples-data/vertex-ai/bigframe/df.csv\")\n", + "series_api = bf.read_csv(\"gs://cloud-samples-data/vertex-ai/bigframe/series.csv\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "7OBjw2nmQY3-" + }, + "source": [ + "Take a peek at a few rows of data for each file:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": { + "id": "QCqgVCIsGGuv" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 48be241c-ee93-4dfa-a9e3-66b64c4b5150 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 6af9caa5-4f7a-48f0-a7df-d692ee063b7e 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", + " \n", + " \n", + " \n", + " \n", + "
API
0values
1dtypes
\n", + "

2 rows × 1 columns

\n", + "
[2 rows x 1 columns in total]" + ], + "text/plain": [ + " API\n", + "0 values\n", + "1 dtypes\n", + "\n", + "[2 rows x 1 columns]" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "" + "source": [ + "df_api.head(2)" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job ddee268c-773a-4dcc-b14c-ebdd90c2c347 is DONE. 0 Bytes processed. Open Job" + "cell_type": "code", + "execution_count": 13, + "metadata": { + "id": "BGJnZbgEGS5-" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 41e4f2e7-689a-45d9-bf92-4416f5560b81 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job aae0b164-f786-4734-8c79-2af9805af0cf 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", + " \n", + " \n", + " \n", + " \n", + "
API
0shape
1size
\n", + "

2 rows × 1 columns

\n", + "
[2 rows x 1 columns in total]" + ], + "text/plain": [ + " API\n", + "0 shape\n", + "1 size\n", + "\n", + "[2 rows x 1 columns]" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "" + "source": [ + "series_api.head(2)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "m3ZJEsi7SUKV" + }, + "source": [ + "# Generate code using the LLM model\n", + "\n", + "Prepare the prompts and send them to the LLM model for prediction." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9EMAqR37AfLS" + }, + "source": [ + "## Prompt design in BigQuery DataFrames\n", + "\n", + "Designing prompts for LLMs is a fast growing area and you can read more in [this documentation](https://cloud.google.com/vertex-ai/docs/generative-ai/learn/introduction-prompt-design).\n", + "\n", + "For this tutorial, you use a simple prompt to ask the LLM model for sample code for each of the API methods (or rows) from the last step's DataFrames. The output is the new DataFrames `df_prompt` and `series_prompt`, which contain the full prompt text." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job d7f1eb26-28b2-44ba-8858-5cd4df8621bd is DONE. 904 Bytes processed. Open Job" + "cell_type": "code", + "execution_count": 14, + "metadata": { + "id": "EDAaIwHpQCDZ" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 17f50c10-aa81-4023-b206-4ba59ddf2269 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job d6d217aa-a623-4ea4-83fb-8f1b8bfb8e68 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job a275a107-752e-46f8-be9f-9cb35eb6b0b9 is DONE. 132 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "0 Generate Pandas sample code for DataFrame.values\n", + "1 Generate Pandas sample code for DataFrame.dtypes\n", + "Name: API, dtype: string" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "" + "source": [ + "df_prompt_prefix = \"Generate Pandas sample code for DataFrame.\"\n", + "series_prompt_prefix = \"Generate Pandas sample code for Series.\"\n", + "\n", + "df_prompt = (df_prompt_prefix + df_api['API'])\n", + "series_prompt = (series_prompt_prefix + series_api['API'])\n", + "\n", + "df_prompt.head(2)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "rwPLjqW2Ajzh" + }, + "source": [ + "## Make predictions using the LLM model\n", + "\n", + "Use the BigQuery DataFrames DataFrame containing the full prompt text as the input to the `predict` method. The `predict` method calls the LLM model and returns its generated text output back to two new BigQuery DataFrames DataFrames, `df_pred` and `series_pred`.\n", + "\n", + "Note: The predictions might take a few minutes to run." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job f24d27a5-0e36-4fb5-953b-d09298f83af6 is DONE. 226 Bytes processed. Open Job" + "cell_type": "code", + "execution_count": 15, + "metadata": { + "id": "6i6HkFJZa8na" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 01f95d2d-901d-4edf-bd3a-245d17c31ef6 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 55927a6f-b023-479a-b9bf-826abde77111 is DONE. 584 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 445eb0af-f643-40c5-9c1e-25aa3db8374a is DONE. 146 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job ddee268c-773a-4dcc-b14c-ebdd90c2c347 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job d7f1eb26-28b2-44ba-8858-5cd4df8621bd is DONE. 904 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job f24d27a5-0e36-4fb5-953b-d09298f83af6 is DONE. 226 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "source": [ + "df_pred = model.predict(df_prompt.to_frame(), max_output_tokens=1024)\n", + "series_pred = model.predict(series_prompt.to_frame(), max_output_tokens=1024)" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df_pred = model.predict(df_prompt.to_frame(), max_output_tokens=1024)\n", - "series_pred = model.predict(series_prompt.to_frame(), max_output_tokens=1024)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "89cB8MW4UIdV" - }, - "source": [ - "Once the predictions are processed, take a look at the sample output from the LLM, which provides code samples for the API names listed in the DataFrames dataset." - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "metadata": { - "id": "9A2gw6hP_2nX" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 65599c98-72ad-4088-8b09-f29bf05c164b is DONE. 21.8 kB processed. Open Job" + "cell_type": "markdown", + "metadata": { + "id": "89cB8MW4UIdV" + }, + "source": [ + "Once the predictions are processed, take a look at the sample output from the LLM, which provides code samples for the API names listed in the DataFrames dataset." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "id": "9A2gw6hP_2nX" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 65599c98-72ad-4088-8b09-f29bf05c164b is DONE. 21.8 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "```python\n", + "import pandas as pd\n", + "\n", + "# Create a DataFrame\n", + "df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])\n", + "\n", + "# Get the values as a NumPy array\n", + "values = df.values\n", + "\n", + "# Print the values\n", + "print(values)\n", + "```\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "print(df_pred['ml_generate_text_llm_result'].iloc[0])" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "```python\n", - "import pandas as pd\n", - "\n", - "# Create a DataFrame\n", - "df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])\n", - "\n", - "# Get the values as a NumPy array\n", - "values = df.values\n", - "\n", - "# Print the values\n", - "print(values)\n", - "```\n" - ] - } - ], - "source": [ - "print(df_pred['ml_generate_text_llm_result'].iloc[0])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Fx4lsNqMorJ-" - }, - "source": [ - "# Manipulate LLM output using a remote function\n", - "\n", - "The output that the LLM provides often contains additional text beyond the code sample itself. Using BigQuery DataFrames, you can deploy custom Python functions that process and transform this output.\n", - "\n" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "d8L7SN03VByG" - }, - "source": [ - "Running the cell below creates a custom function that you can use to process the LLM output data in two ways:\n", - "1. Strip the LLM text output to include only the code block.\n", - "2. Substitute `import pandas as pd` with `import bigframes.pandas as bf` so that the resulting code block works with BigQuery DataFrames." - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "metadata": { - "id": "GskyyUQPowBT" - }, - "outputs": [], - "source": [ - "@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", - " if \"import bigframes.pandas as bf\" not in res:\n", - " res = \"import bigframes.pandas as bf\\n\" + res\n", - " return res\n", - " except:\n", - " return \"\"" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "hVQAoqBUOJQf" - }, - "source": [ - "The custom function is deployed as a Cloud Function, and then integrated with BigQuery as a [remote function](https://cloud.google.com/bigquery/docs/remote-functions). Save both of the function names so that you can clean them up at the end of this notebook." - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "metadata": { - "id": "PBlp-C-DOHRO" - }, - "outputs": [ + "cell_type": "markdown", + "metadata": { + "id": "Fx4lsNqMorJ-" + }, + "source": [ + "# Manipulate LLM output using a remote function\n", + "\n", + "The output that the LLM provides often contains additional text beyond the code sample itself. Using BigQuery DataFrames, you can deploy custom Python functions that process and transform this output.\n", + "\n" + ] + }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "Cloud Function Name projects/swast-scratch/locations/us-central1/functions/bigframes-6e7606963c3f06b8181b3cb9449a4363\n", - "Remote Function Name swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_6e7606963c3f06b8181b3cb9449a4363\n" - ] - } - ], - "source": [ - "CLOUD_FUNCTION_NAME = format(extract_code.bigframes_cloud_function)\n", - "print(\"Cloud Function Name \" + CLOUD_FUNCTION_NAME)\n", - "REMOTE_FUNCTION_NAME = format(extract_code.bigframes_remote_function)\n", - "print(\"Remote Function Name \" + REMOTE_FUNCTION_NAME)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "4FEucaiqVs3H" - }, - "source": [ - "Apply the custom function to each LLM output DataFrame to get the processed results:" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "metadata": { - "id": "bsQ9cmoWo0Ps" - }, - "outputs": [ + "cell_type": "markdown", + "metadata": { + "id": "d8L7SN03VByG" + }, + "source": [ + "Running the cell below creates a custom function that you can use to process the LLM output data in two ways:\n", + "1. Strip the LLM text output to include only the code block.\n", + "2. Substitute `import pandas as pd` with `import bigframes.pandas as bf` so that the resulting code block works with BigQuery DataFrames." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "metadata": { + "id": "GskyyUQPowBT" + }, + "outputs": [], + "source": [ + "@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", + " if \"import bigframes.pandas as bf\" not in res:\n", + " res = \"import bigframes.pandas as bf\\n\" + res\n", + " return res\n", + " except:\n", + " return \"\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "hVQAoqBUOJQf" + }, + "source": [ + "The custom function is deployed as a Cloud Function, and then integrated with BigQuery as a [remote function](https://cloud.google.com/bigquery/docs/remote-functions). Save both of the function names so that you can clean them up at the end of this notebook." + ] + }, { - "data": { - "text/html": [ - "Query job 047903f8-ea67-430a-8281-8fb5a119b779 is DONE. 21.8 kB processed. Open Job" + "cell_type": "code", + "execution_count": 18, + "metadata": { + "id": "PBlp-C-DOHRO" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Cloud Function Name projects/swast-scratch/locations/us-central1/functions/bigframes-6e7606963c3f06b8181b3cb9449a4363\n", + "Remote Function Name swast-scratch._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_6e7606963c3f06b8181b3cb9449a4363\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "CLOUD_FUNCTION_NAME = format(extract_code.bigframes_cloud_function)\n", + "print(\"Cloud Function Name \" + CLOUD_FUNCTION_NAME)\n", + "REMOTE_FUNCTION_NAME = format(extract_code.bigframes_remote_function)\n", + "print(\"Remote Function Name \" + REMOTE_FUNCTION_NAME)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "4FEucaiqVs3H" + }, + "source": [ + "Apply the custom function to each LLM output DataFrame to get the processed results:" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 793df956-0b1a-46ba-bb5e-e428171f3bd0 is DONE. 26.3 kB processed. Open Job" + "cell_type": "code", + "execution_count": 19, + "metadata": { + "id": "bsQ9cmoWo0Ps" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 047903f8-ea67-430a-8281-8fb5a119b779 is DONE. 21.8 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 793df956-0b1a-46ba-bb5e-e428171f3bd0 is DONE. 26.3 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "source": [ + "df_code = df_pred.assign(code=df_pred['ml_generate_text_llm_result'].apply(extract_code))\n", + "series_code = series_pred.assign(code=series_pred['ml_generate_text_llm_result'].apply(extract_code))" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df_code = df_pred.assign(code=df_pred['ml_generate_text_llm_result'].apply(extract_code))\n", - "series_code = series_pred.assign(code=series_pred['ml_generate_text_llm_result'].apply(extract_code))" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "ujQVVuhfWA3y" - }, - "source": [ - "You can see the differences by inspecting the first row of data:" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "metadata": { - "id": "7yWzjhGy_zcy" - }, - "outputs": [ + }, { - "data": { - "text/html": [ - "Query job 6974c2b7-2ed9-4564-a80b-57aef6959e19 is DONE. 22.8 kB processed. Open Job" + "cell_type": "markdown", + "metadata": { + "id": "ujQVVuhfWA3y" + }, + "source": [ + "You can see the differences by inspecting the first row of data:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "id": "7yWzjhGy_zcy" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 6974c2b7-2ed9-4564-a80b-57aef6959e19 is DONE. 22.8 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "import bigframes.pandas as bf\n", + "\n", + "# Create a DataFrame\n", + "df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])\n", + "\n", + "# Get the values as a NumPy array\n", + "values = df.values\n", + "\n", + "# Print the values\n", + "print(values)\n", + "\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "print(df_code['code'].iloc[0])" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "import bigframes.pandas as bf\n", - "\n", - "# Create a DataFrame\n", - "df = pd.DataFrame([[1, 2, 3], [4, 5, 6], [7, 8, 9]])\n", - "\n", - "# Get the values as a NumPy array\n", - "values = df.values\n", - "\n", - "# Print the values\n", - "print(values)\n", - "\n" - ] - } - ], - "source": [ - "print(df_code['code'].iloc[0])" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "GTRdUw-Ro5R1" - }, - "source": [ - "# Save the results to Cloud Storage\n", - "\n", - "BigQuery DataFrames lets you save a BigQuery DataFrames DataFrame as a CSV file in Cloud Storage for further use. Try that now with your processed LLM output data." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9DQ7eiQxPTi3" - }, - "source": [ - "Create a new Cloud Storage bucket with a unique name:" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "metadata": { - "id": "-J5LHgS6LLZ0" - }, - "outputs": [ + "cell_type": "markdown", + "metadata": { + "id": "GTRdUw-Ro5R1" + }, + "source": [ + "# Save the results to Cloud Storage\n", + "\n", + "BigQuery DataFrames lets you save a BigQuery DataFrames DataFrame as a CSV file in Cloud Storage for further use. Try that now with your processed LLM output data." + ] + }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "Creating gs://code-samples-773ee0f2-e302-11ee-8298-4201c0a8181f/...\n" - ] - } - ], - "source": [ - "import uuid\n", - "BUCKET_ID = \"code-samples-\" + str(uuid.uuid1())\n", - "\n", - "!gcloud storage buckets create gs://{BUCKET_ID}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "tyxZXj0UPYUv" - }, - "source": [ - "Use `to_csv` to write each BigQuery DataFrames DataFrame as a CSV file in the Cloud Storage bucket:" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "metadata": { - "id": "Zs_b5L-4IvER" - }, - "outputs": [ + "cell_type": "markdown", + "metadata": { + "id": "9DQ7eiQxPTi3" + }, + "source": [ + "Create a new Cloud Storage bucket with a unique name:" + ] + }, { - "data": { - "text/html": [ - "Query job 81277037-032f-4557-a46e-1d39702f33d5 is DONE. 22.8 kB processed. Open Job" + "cell_type": "code", + "execution_count": 21, + "metadata": { + "id": "-J5LHgS6LLZ0" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Creating gs://code-samples-773ee0f2-e302-11ee-8298-4201c0a8181f/...\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "import uuid\n", + "BUCKET_ID = \"code-samples-\" + str(uuid.uuid1())\n", + "\n", + "!gcloud storage buckets create gs://{BUCKET_ID}" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 8dc5a38c-ac16-44e7-83dd-4187380f780f is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "tyxZXj0UPYUv" + }, + "source": [ + "Use `to_csv` to write each BigQuery DataFrames DataFrame as a CSV file in the Cloud Storage bucket:" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 9087a758-b1f9-4be7-889b-7761ef0ad966 is DONE. 27.7 kB processed. Open Job" + "cell_type": "code", + "execution_count": 22, + "metadata": { + "id": "Zs_b5L-4IvER" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 81277037-032f-4557-a46e-1d39702f33d5 is DONE. 22.8 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 8dc5a38c-ac16-44e7-83dd-4187380f780f is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 9087a758-b1f9-4be7-889b-7761ef0ad966 is DONE. 27.7 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 6126ea72-c6f7-43f0-8888-e1c2a464a8a4 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "source": [ + "df_code[[\"code\"]].to_csv(f\"gs://{BUCKET_ID}/df_code*.csv\")\n", + "series_code[[\"code\"]].to_csv(f\"gs://{BUCKET_ID}/series_code*.csv\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UDBtDlrTuuh8" + }, + "source": [ + "You can navigate to the Cloud Storage bucket browser to download the two files and view them.\n", + "\n", + "Run the following cell, and then follow the link to your Cloud Storage bucket browser:" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job 6126ea72-c6f7-43f0-8888-e1c2a464a8a4 is DONE. 0 Bytes processed. Open Job" + "cell_type": "code", + "execution_count": 23, + "metadata": { + "id": "PspCXu-qu_ND" + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "https://console.developers.google.com/storage/browser/code-samples-773ee0f2-e302-11ee-8298-4201c0a8181f/\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "print(f'https://console.developers.google.com/storage/browser/{BUCKET_ID}/')" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "df_code[[\"code\"]].to_csv(f\"gs://{BUCKET_ID}/df_code*.csv\")\n", - "series_code[[\"code\"]].to_csv(f\"gs://{BUCKET_ID}/series_code*.csv\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "UDBtDlrTuuh8" - }, - "source": [ - "You can navigate to the Cloud Storage bucket browser to download the two files and view them.\n", - "\n", - "Run the following cell, and then follow the link to your Cloud Storage bucket browser:" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "metadata": { - "id": "PspCXu-qu_ND" - }, - "outputs": [ + }, + { + "cell_type": "markdown", + "metadata": { + "id": "RGSvUk48RK20" + }, + "source": [ + "# Summary and next steps\n", + "\n", + "You've used BigQuery DataFrames' integration with LLM models (`bigframes.ml.llm`) to generate code samples, and have tranformed LLM output by creating and using a custom function in BigQuery DataFrames.\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)." + ] + }, { - "name": "stdout", - "output_type": "stream", - "text": [ - "https://console.developers.google.com/storage/browser/code-samples-773ee0f2-e302-11ee-8298-4201c0a8181f/\n" - ] + "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": {}, + "outputs": [], + "source": [ + "bf.close_session()" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "id": "yw7A461XLjvW" + }, + "outputs": [], + "source": [ + "# # Delete the BigQuery Connection\n", + "# from google.cloud import bigquery_connection_v1 as bq_connection\n", + "# client = bq_connection.ConnectionServiceClient()\n", + "# CONNECTION_ID = f\"projects/{PROJECT_ID}/locations/{REGION}/connections/{CONN_NAME}\"\n", + "# client.delete_connection(name=CONNECTION_ID)\n", + "# print(f\"Deleted connection '{CONNECTION_ID}'.\")" + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "id": "sx_vKniMq9ZX" + }, + "outputs": [], + "source": [ + "# # Delete the Cloud Function\n", + "# ! gcloud functions delete {CLOUD_FUNCTION_NAME} --quiet\n", + "# # Delete the Remote Function\n", + "# REMOTE_FUNCTION_NAME = REMOTE_FUNCTION_NAME.replace(PROJECT_ID + \".\", \"\")\n", + "# ! bq rm --routine --force=true {REMOTE_FUNCTION_NAME}" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "metadata": { + "id": "iQFo6OUBLmi3" + }, + "outputs": [], + "source": [ + "# # Delete the Google Cloud Storage bucket and files\n", + "# ! gcloud storage rm gs://{BUCKET_ID} --recursive\n", + "# print(f\"Deleted bucket '{BUCKET_ID}'.\")" + ] + } + ], + "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.11.1" } - ], - "source": [ - "print(f'https://console.developers.google.com/storage/browser/{BUCKET_ID}/')" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "RGSvUk48RK20" - }, - "source": [ - "# Summary and next steps\n", - "\n", - "You've used BigQuery DataFrames' integration with LLM models (`bigframes.ml.llm`) to generate code samples, and have tranformed LLM output by creating and using a custom function in BigQuery DataFrames.\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": {}, - "outputs": [], - "source": [ - "bf.close_session()" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "metadata": { - "id": "yw7A461XLjvW" - }, - "outputs": [], - "source": [ - "# # Delete the BigQuery Connection\n", - "# from google.cloud import bigquery_connection_v1 as bq_connection\n", - "# client = bq_connection.ConnectionServiceClient()\n", - "# CONNECTION_ID = f\"projects/{PROJECT_ID}/locations/{REGION}/connections/{CONN_NAME}\"\n", - "# client.delete_connection(name=CONNECTION_ID)\n", - "# print(f\"Deleted connection '{CONNECTION_ID}'.\")" - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "metadata": { - "id": "sx_vKniMq9ZX" - }, - "outputs": [], - "source": [ - "# # Delete the Cloud Function\n", - "# ! gcloud functions delete {CLOUD_FUNCTION_NAME} --quiet\n", - "# # Delete the Remote Function\n", - "# REMOTE_FUNCTION_NAME = REMOTE_FUNCTION_NAME.replace(PROJECT_ID + \".\", \"\")\n", - "# ! bq rm --routine --force=true {REMOTE_FUNCTION_NAME}" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "metadata": { - "id": "iQFo6OUBLmi3" - }, - "outputs": [], - "source": [ - "# # Delete the Google Cloud Storage bucket and files\n", - "# ! gcloud storage rm gs://{BUCKET_ID} --recursive\n", - "# print(f\"Deleted bucket '{BUCKET_ID}'.\")" - ] - } - ], - "metadata": { - "colab": { - "provenance": [], - "toc_visible": true - }, - "kernelspec": { - "display_name": "venv (3.10.14)", - "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": 0 + "nbformat": 4, + "nbformat_minor": 0 } diff --git a/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb b/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb new file mode 100644 index 00000000000..1a9b5688975 --- /dev/null +++ b/notebooks/generative_ai/bq_dataframes_llm_gemini_2.ipynb @@ -0,0 +1,377 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "# Copyright 2024 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 Gemini 2.0 Text Generation Simple Example" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Note: This feature is only available in bigframes >= 1.29.0" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Import packages" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "from bigframes.ml import llm" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create Gemini 2.0 experimental Model with model_name as \"gemini-2.0-flash-exp\"" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/ml/llm.py:803: PreviewWarning: Model gemini-2.0-flash-exp is subject to the \"Pre-GA Offerings Terms\" in the General Service Terms section of the\n", + " Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available \"as is\"\n", + " and might have limited support. For more information, see the launch stage descriptions\n", + " (https://cloud.google.com/products#product-launch-stages).\n", + " warnings.warn(\n", + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/pandas/__init__.py:435: DefaultLocationWarning: No explicit location is set, so using location US for the session.\n", + " return global_session.get_global_session()\n" + ] + }, + { + "data": { + "text/html": [ + "Query job f673a2ea-023e-4771-84a2-fb81f808fa1b is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "model = llm.GeminiTextGenerator(model_name=\"gemini-2.0-flash-exp\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Create a simple DataFrame" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 2276ea5b-2e08-4ed6-af34-49a7d165d145 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
0Tell me something about Gemini 2.0.
\n", + "

1 rows × 1 columns

\n", + "
[1 rows x 1 columns in total]" + ], + "text/plain": [ + " prompt\n", + "0 Tell me something about Gemini 2.0.\n", + "\n", + "[1 rows x 1 columns]" + ] + }, + "execution_count": 5, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "df = bpd.DataFrame({\"prompt\": [\"Tell me something about Gemini 2.0.\"]})\n", + "df" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Make predictions" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 9ba21e96-6023-491e-8e83-f2e6fa7df0e7 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/core/__init__.py:109: PreviewWarning: Interpreting JSON column(s) as StringDtype. This behavior may change in future versions.\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/html": [ + "Query job 933d45cc-4bc0-4bdf-b4b8-573da2d58be3 is DONE. 2 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 3dda9bc6-84b1-4f4a-8891-85d25d8848ce is DONE. 4.3 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", + "
ml_generate_text_llm_resultml_generate_text_rai_resultml_generate_text_statusprompt
0Alright, let's talk about Gemini 2.0! It's a b...<NA>Tell me something about Gemini 2.0.
\n", + "

1 rows × 4 columns

\n", + "
[1 rows x 4 columns in total]" + ], + "text/plain": [ + " ml_generate_text_llm_result \\\n", + "0 Alright, let's talk about Gemini 2.0! It's a b... \n", + "\n", + " ml_generate_text_rai_result ml_generate_text_status \\\n", + "0 \n", + "\n", + " prompt \n", + "0 Tell me something about Gemini 2.0. \n", + "\n", + "[1 rows x 4 columns]" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "result = model.predict(df)\n", + "result" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### Save the model" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/html": [ + "Copy job 8e68af62-e7ab-475b-99c9-b79e8ba3c40b is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/garrettwu/src/bigframes/bigframes/ml/llm.py:803: PreviewWarning: Model gemini-2.0-flash-exp is subject to the \"Pre-GA Offerings Terms\" in the General Service Terms section of the\n", + " Service Specific Terms(https://cloud.google.com/terms/service-terms#1). Pre-GA products and features are available \"as is\"\n", + " and might have limited support. For more information, see the launch stage descriptions\n", + " (https://cloud.google.com/products#product-launch-stages).\n", + " warnings.warn(\n" + ] + }, + { + "data": { + "text/html": [ + "Query job cae7f929-d8cb-4819-a644-ac832cdc0912 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "GeminiTextGenerator(connection_name='bigframes-dev.us.bigframes-rf-connection',\n", + " model_name='gemini-2.0-flash-exp',\n", + " session=)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model.to_gbq(\"bigframes-dev.garrettwu.gemini_2_flash\", replace=True)" + ] + } + ], + "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.15" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb index 2d5bb46d95e..bc550969422 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_kmeans.ipynb @@ -26,7 +26,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Use BigQuery DataFrames to cluster and characterize complaints\n", + "## Use BigQuery DataFrames to cluster and characterize complaints\n", "\n", "\n", "\n", @@ -1593,7 +1593,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "metadata": { "id": "mL5P0_3X04dE" }, @@ -1614,7 +1614,7 @@ "source": [ "from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "q_a_model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")" + "q_a_model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { @@ -1736,7 +1736,7 @@ "provenance": [] }, "kernelspec": { - "display_name": "venv (3.10.14)", + "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" }, @@ -1750,7 +1750,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.14" + "version": "3.10.9" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb index b3e2e4ebc84..70714c823c9 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_output_schema.ipynb @@ -25,7 +25,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Format LLM output using an output schema\n", + "# BigFrames LLM Output Schema\n", "\n", "
\n", "\n", @@ -43,7 +43,7 @@ " \n", "
\n", " \n", " \"BQ\n", - " Open in BigQuery Studio\n", + " Open in BQ Studio\n", " \n", "
\n" @@ -53,125 +53,27 @@ "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." + "This Notebook introduces BigFrames LLM with output schema to generate structured output dataframes." ] }, { "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:" + "### Setup" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ - "# ! gcloud auth login" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "**Colab**\n", + "PROJECT = \"bigframes-dev\" # replace with your project\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_ID = \"\" # @param {type:\"string\"}\n", "import bigframes\n", - "bigframes.options.bigquery.project = PROJECT_ID\n", + "# Setup project\n", + "bigframes.options.bigquery.project = PROJECT\n", "bigframes.options.display.progress_bar = None\n", "\n", "import bigframes.pandas as bpd\n", @@ -182,8 +84,8 @@ "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:" + "### 1. Create a BigFrames DataFrame and a Gemini model\n", + "Starting from creating a simple dataframe of several cities and a Gemini model in BigFrames" ] }, { @@ -260,13 +162,6 @@ "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, @@ -291,8 +186,8 @@ "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:" + "### 2. Generate structured output data\n", + "Before, llm models can only generate text output. Saying if you want to know whether the city is a US city, for example:" ] }, { @@ -378,9 +273,9 @@ "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", + "The outputs are text results that human can read. But if want the output data to be more useful for analysis, it is better to transfer to structured data like boolean, int or float values. Usually the process 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:" + "Now you can get structured output out-of-the-box by specifying the output_schema parameter in Gemini model predict method. In below example, the outputs are only boolean values." ] }, { @@ -466,7 +361,7 @@ "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:" + "You can also get float or int values, for example, to get populations in millions:" ] }, { @@ -552,7 +447,7 @@ "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:" + "And yearly rainy days:" ] }, { @@ -638,10 +533,10 @@ "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", + "### 3. Generate all types of data in one prediction\n", + "You can get the different output columns and types in one prediction. \n", "\n", - "The input doesn't have to be dedicated prompts as long as the output column names are informative to the model." + "Note it doesn't require dedicated prompts, as long as the output column names are informative to the model." ] }, { @@ -735,14 +630,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Format output as a composite data type" + "### 4. Generate composite data types" ] }, { "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:" + "Composite datatypes like array and struct can also be generated. Here the example generates a places_to_visit column as array of strings and a gps_coordinates as struct of floats. Along with previous fields, all in one prediction." ] }, { @@ -849,36 +744,6 @@ "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": { diff --git a/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb index c9fa39926a9..b964117b674 100644 --- a/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb +++ b/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb @@ -1,1790 +1,2175 @@ { - "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 + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "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." + ] }, - "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/" + { + "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" + ] }, - "executionInfo": { - "elapsed": 2, - "status": "ok", - "timestamp": 1742191608487, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "markdown", + "metadata": { + "id": "vFMjpPBo9aVv" + }, + "source": [ + "**Author:** Sudipto Guha (Google)\n", + "\n", + "**Last updated:** March 16th 2025" + ] }, - "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 + { + "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!" + ] }, - "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 + { + "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)." + ] }, - "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/" + { + "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." + ] }, - "executionInfo": { - "elapsed": 468, - "status": "ok", - "timestamp": 1742192516923, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "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)" + ] }, - "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 + { + "cell_type": "markdown", + "metadata": { + "id": "Z-mvYJUCooEV" + }, + "source": [ + "## Before you begin\n", + "\n", + "Complete the tasks in this section to set up your environment." + ] }, - "executionInfo": { - "elapsed": 6697, - "status": "ok", - "timestamp": 1742192524632, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "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)." + ] }, - "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 + { + "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)" + ] }, - "executionInfo": { - "elapsed": 6, - "status": "ok", - "timestamp": 1742191801044, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "code", + "execution_count": 1, + "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 = \"\" # @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}" + ] }, - "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... " + "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." ] - }, - "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 + { + "cell_type": "markdown", + "metadata": { + "id": "U7ChP8jUooEV" + }, + "source": [ + "**Vertex AI Workbench**\n", + "\n", + "Do nothing, you are already authenticated." + ] }, - "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": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "VfHOYcZZooEW" + }, + "source": [ + "**Local JupyterLab instance**\n", + "\n", + "Uncomment and run the following cell:" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job fa4f1a54-85d4-4030-992e-fddda5edf3e3 is DONE. 0 Bytes processed. Open Job" - ], - "text/plain": [ - "" + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "3cGhUVM0ooEW" + }, + "outputs": [], + "source": [ + "# ! gcloud auth login" ] - }, - "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 + { + "cell_type": "markdown", + "metadata": { + "id": "AoHnXlg-ooEW" + }, + "source": [ + "**Colab**\n", + "\n", + "Uncomment and run the following cell:" + ] }, - "id": "b5HHZob_u61B", - "outputId": "c9ecc5fd-5d11-4fd8-f59b-9dce4e12e371" - }, - "outputs": [ { - "data": { - "text/html": [ - "Load job 70377d71-bb13-46af-80c1-71ef16bf2949 is DONE. Open Job" + "cell_type": "code", + "execution_count": 2, + "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": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "WARNING: google.colab.auth.authenticate_user() is not supported in Colab Enterprise.\n" + ] + } ], - "text/plain": [ - "" + "source": [ + "# from google.colab import auth\n", + "# auth.authenticate_user()" ] - }, - "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": [ - "" + "cell_type": "markdown", + "metadata": { + "id": "a9gsyttuooEW" + }, + "source": [ + "Now we are ready to use BigQuery DataFrames!" ] - }, - "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 + "cell_type": "markdown", + "metadata": { + "id": "xckgWno6ouHY" + }, + "source": [ + "## Step 1: Data Ingestion and Embedding Generation" + ] }, - "executionInfo": { - "elapsed": 6715, - "status": "ok", - "timestamp": 1742192727525, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "markdown", + "metadata": { + "id": "Hjg9jDN-ooEW" + }, + "source": [ + "Install libraries" + ] }, - "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": [ - "" + "cell_type": "code", + "execution_count": 33, + "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" ] - }, - "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]" + "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." ] - }, - "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 + { + "cell_type": "code", + "execution_count": 4, + "metadata": { + "executionInfo": { + "elapsed": 2, + "status": "ok", + "timestamp": 1742191620533, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "9Gil1Oaas7KA" + }, + "outputs": [], + "source": [ + "bf.options.bigquery.ordering_mode = \"partial\"" + ] }, - "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": [ - "" + "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." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/plain": [ - "'my_dataset.my_embeddings_table'" + "cell_type": "markdown", + "metadata": { + "id": "v6FGschEowht" + }, + "source": [ + "Data Input - read the data from a publicly available BigQuery dataset" ] - }, - "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 + { + "cell_type": "code", + "execution_count": 17, + "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": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/IPython/core/interactiveshell.py:3553: UserWarning: \u001b[93mReading cached table from 2025-03-17 06:07:09.526507+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().\u001b[0m\n", + " exec(code_obj, self.user_global_ns, self.user_ns)\n" + ] + } + ], + "source": [ + "publications = bf.read_gbq('patents-public-data.google_patents_research.publications')" + ] }, - "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 + { + "cell_type": "code", + "execution_count": 18, + "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": [ + { + "data": { + "text/html": [ + "Query job 6f15ad71-cc7b-49c1-90e9-274bea7afbb9 is DONE. 477.4 GB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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)" + ] }, - "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 + { + "cell_type": "code", + "execution_count": 11, + "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": { + "application/vnd.google.colaboratory.intrinsic+json": { + "repr_error": "Function 'unique' has no kernel matching input types (list not null>>)", + "type": "dataframe", + "variable_name": "publications" + }, + "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", + "
publication_numbertitletitle_translatedabstractabstract_translatedcpccpc_lowcpc_inventive_lowtop_termssimilarurlcountrypublication_descriptioncited_byembedding_v1
0AU-338190-SCompressor wheelFalseNewness and distinctiveness is claimed in the ...False[][][]['compressor wheel' 'newness' 'distinctiveness...[{'publication_number': 'AU-338190-S', 'applic...https://patents.google.com/patent/AU338190SAustraliaDesign[][ 5.2067090e-02 -1.5462303e-01 -1.3415462e-01 ...
1CN-100525651-CMethod for processing egg productsFalseThe invention discloses a processing method of...False[][][]['egg' 'processing method' 'egg body' 'pack' '...[{'publication_number': 'CN-101396133-B', 'app...https://patents.google.com/patent/CN100525651CChinaGranted Patent[][-0.05154578 -0.00437102 0.01365495 -0.168424...
2TW-I725505-BImproved carbon molecular sieve adsorbentFalseDisclosed herein are rapid cycle pressure swin...False[{'code': 'B01D2253/116', 'inventive': False, ...['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B...['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B...['swing adsorption' 'pressure swing' 'molecula...[{'publication_number': 'EP-1867379-B1', 'appl...https://patents.google.com/patent/TWI725505BTaiwanGranted Patent or patent of addition[][ 0.0163008 -0.20972364 0.02052403 -0.003073...
3EP-0248026-B1A system for supplying strip to a processing lineFalseA system (10) for supplying strip material (S)...False[{'code': 'B65H2701/37', 'inventive': False, '...['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6...['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6...['strip material' 'assembly' 'coil' 'take' 'pr...[{'publication_number': 'EP-0248026-B1', 'appl...https://patents.google.com/patent/EP0248026B1European Patent OfficeGranted patent[][-0.04377723 0.04111805 -0.0929429 0.043924...
4MY-135762-AMethod for producing acrylic acidFalseA PROCESS FOR THE FRACTIONAL CONDENSATION OF A...False[{'code': 'C07C51/50', 'inventive': True, 'fir...['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C...['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C...['acrylic acid' 'producing acrylic' 'stabilize...[{'publication_number': 'SG-157371-A1', 'appli...https://patents.google.com/patent/MY135762AMalaysiaGranted patent / Utility model[][ 0.10407669 0.01262973 -0.22623734 -0.171453...
\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": [ + " publication_number title \\\n", + "0 AU-338190-S Compressor wheel \n", + "1 CN-100525651-C Method for processing egg products \n", + "2 TW-I725505-B Improved carbon molecular sieve adsorbent \n", + "3 EP-0248026-B1 A system for supplying strip to a processing line \n", + "4 MY-135762-A Method for producing acrylic acid \n", + "\n", + " title_translated abstract \\\n", + "0 False Newness and distinctiveness is claimed in the ... \n", + "1 False The invention discloses a processing method of... \n", + "2 False Disclosed herein are rapid cycle pressure swin... \n", + "3 False A system (10) for supplying strip material (S)... \n", + "4 False A PROCESS FOR THE FRACTIONAL CONDENSATION OF A... \n", + "\n", + " abstract_translated cpc \\\n", + "0 False [] \n", + "1 False [] \n", + "2 False [{'code': 'B01D2253/116', 'inventive': False, ... \n", + "3 False [{'code': 'B65H2701/37', 'inventive': False, '... \n", + "4 False [{'code': 'C07C51/50', 'inventive': True, 'fir... \n", + "\n", + " cpc_low \\\n", + "0 [] \n", + "1 [] \n", + "2 ['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B... \n", + "3 ['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6... \n", + "4 ['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C... \n", + "\n", + " cpc_inventive_low \\\n", + "0 [] \n", + "1 [] \n", + "2 ['B01D2253/116' 'B01D2253/10' 'B01D2253/00' 'B... \n", + "3 ['B65H2701/37' 'B65H2701/30' 'B65H2701/00' 'B6... \n", + "4 ['C07C51/50' 'C07C51/42' 'C07C51/00' 'C07C' 'C... \n", + "\n", + " top_terms \\\n", + "0 ['compressor wheel' 'newness' 'distinctiveness... \n", + "1 ['egg' 'processing method' 'egg body' 'pack' '... \n", + "2 ['swing adsorption' 'pressure swing' 'molecula... \n", + "3 ['strip material' 'assembly' 'coil' 'take' 'pr... \n", + "4 ['acrylic acid' 'producing acrylic' 'stabilize... \n", + "\n", + " similar \\\n", + "0 [{'publication_number': 'AU-338190-S', 'applic... \n", + "1 [{'publication_number': 'CN-101396133-B', 'app... \n", + "2 [{'publication_number': 'EP-1867379-B1', 'appl... \n", + "3 [{'publication_number': 'EP-0248026-B1', 'appl... \n", + "4 [{'publication_number': 'SG-157371-A1', 'appli... \n", + "\n", + " url country \\\n", + "0 https://patents.google.com/patent/AU338190S Australia \n", + "1 https://patents.google.com/patent/CN100525651C China \n", + "2 https://patents.google.com/patent/TWI725505B Taiwan \n", + "3 https://patents.google.com/patent/EP0248026B1 European Patent Office \n", + "4 https://patents.google.com/patent/MY135762A Malaysia \n", + "\n", + " publication_description cited_by \\\n", + "0 Design [] \n", + "1 Granted Patent [] \n", + "2 Granted Patent or patent of addition [] \n", + "3 Granted patent [] \n", + "4 Granted patent / Utility model [] \n", + "\n", + " embedding_v1 \n", + "0 [ 5.2067090e-02 -1.5462303e-01 -1.3415462e-01 ... \n", + "1 [-0.05154578 -0.00437102 0.01365495 -0.168424... \n", + "2 [ 0.0163008 -0.20972364 0.02052403 -0.003073... \n", + "3 [-0.04377723 0.04111805 -0.0929429 0.043924... \n", + "4 [ 0.10407669 0.01262973 -0.22623734 -0.171453... " + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "## take a look at the sample dataset\n", + "\n", + "publications.head(5)" + ] }, - "executionInfo": { - "elapsed": 6927, - "status": "ok", - "timestamp": 1742194625774, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "markdown", + "metadata": { + "id": "Wl2o-NYMoygb" + }, + "source": [ + "Generate the text embeddings" + ] }, - "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" + "cell_type": "code", + "execution_count": 13, + "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 127fb090-1c9e-4d7a-acdd-86f077a87b07 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "source": [ + "from bigframes.ml.llm import TextEmbeddingGenerator\n", + "\n", + "text_model = TextEmbeddingGenerator() # No connection id needed" ] - }, - "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 + "cell_type": "code", + "execution_count": 19, + "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 b8079d70-7d99-4198-898f-2921915f305f is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 17338b11-420c-4d3d-bd55-0bba1247f705 is DONE. 8.9 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "Query job ebf3eb36-3199-4551-ad07-5fa5abb200be is DONE. 20.0 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 9e9c5aae-9045-4750-a34e-c98493369a90 is DONE. 20.0 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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" + ] }, - "executionInfo": { - "elapsed": 5110, - "status": "ok", - "timestamp": 1742194670801, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "code", + "execution_count": 20, + "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": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:238: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n", + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:262: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "Query job 1bc3517f-df67-456c-8d31-14a6432b8629 is DONE. 70.4 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job ae92602b-0eab-437f-a02d-102a4defa99a is DONE. 31.3 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", + "
publication_numbertitlecontentml_generate_embedding_resultml_generate_embedding_status
5753HN-1996000102-ANEW PESTICIDESTHE PRESENT INVENTION REFERS TO[-0.02709213 0.0366395 0.03931784 -0.003942...
8115AU-325874-SBaby slingAdjustable baby sling with velcro.[ 6.44167811e-02 -2.01051459e-02 -3.39564607e-...
5415AU-2016256863-A1Microbial compositions and methods for denitri...The present invention provides compositions an...[-5.90537786e-02 2.38401629e-03 7.22754598e-...
8886FR-2368509-A1NEW DEODORANTS OR FRESHENERS AND COMPOSITIONS ...Polyanionic polyamide salts comprising a conca...[-3.44522446e-02 5.64815439e-02 -1.35829514e-...
5661US-2006051255-A1Gas generatorA gas generator insulated by a vacuum-jacket v...[-1.50892800e-02 6.56989636e-03 2.34969519e-...
\n", + "

5 rows × 5 columns

\n", + "
[5 rows x 5 columns in total]" + ], + "text/plain": [ + " publication_number title \\\n", + "5753 HN-1996000102-A NEW PESTICIDES \n", + "8115 AU-325874-S Baby sling \n", + "5415 AU-2016256863-A1 Microbial compositions and methods for denitri... \n", + "8886 FR-2368509-A1 NEW DEODORANTS OR FRESHENERS AND COMPOSITIONS ... \n", + "5661 US-2006051255-A1 Gas generator \n", + "\n", + " content \\\n", + "5753 THE PRESENT INVENTION REFERS TO \n", + "8115 Adjustable baby sling with velcro. \n", + "5415 The present invention provides compositions an... \n", + "8886 Polyanionic polyamide salts comprising a conca... \n", + "5661 A gas generator insulated by a vacuum-jacket v... \n", + "\n", + " ml_generate_embedding_result \\\n", + "5753 [-0.02709213 0.0366395 0.03931784 -0.003942... \n", + "8115 [ 6.44167811e-02 -2.01051459e-02 -3.39564607e-... \n", + "5415 [-5.90537786e-02 2.38401629e-03 7.22754598e-... \n", + "8886 [-3.44522446e-02 5.64815439e-02 -1.35829514e-... \n", + "5661 [-1.50892800e-02 6.56989636e-03 2.34969519e-... \n", + "\n", + " ml_generate_embedding_status \n", + "5753 \n", + "8115 \n", + "5415 \n", + "8886 \n", + "5661 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 20, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "embedding.head(5)" + ] }, - "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 + { + "cell_type": "code", + "execution_count": 21, + "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 7370fb69-9589-4a9a-a5cf-7f7c8d50c53c is DONE. 70.3 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "application/vnd.google.colaboratory.intrinsic+json": { + "type": "string" + }, + "text/plain": [ + "'bqml_llm_trial.patent_embedding_BF-n'" + ] + }, + "execution_count": 21, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# store embeddings in a BQ table\n", + "DATASET_ID = \"\" # @param {type:\"string\"}\n", + "TEXT_EMBEDDING_TABLE_ID = \"\" # @param {type:\"string\"}\n", + "embedding.to_gbq(f\"{DATASET_ID}.{TEXT_EMBEDDING_TABLE_ID}\", if_exists='replace')" + ] }, - "executionInfo": { - "elapsed": 3511, - "status": "ok", - "timestamp": 1742195090670, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "markdown", + "metadata": { + "id": "OUZ3NNbzo1Tb" + }, + "source": [ + "## Step 2: Indexing and Similarity Search" + ] }, - "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": [ - "" + "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." ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job aa95f59c-7229-4e76-bd2c-3a63deea3285 is DONE. 4.7 kB processed. Open Job" + "cell_type": "code", + "execution_count": 22, + "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": [ + { + "data": { + "text/html": [ + "Query job 775f872d-ea2d-48f3-8b65-a85ed573dac0 is DONE. 61.4 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } ], - "text/plain": [ - "" + "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", + ")" ] - }, - "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]" + "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" ] - }, - "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": null, - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 34 + { + "cell_type": "code", + "execution_count": 23, + "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" + ] }, - "executionInfo": { - "elapsed": 4827, - "status": "ok", - "timestamp": 1742195565658, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "code", + "execution_count": 24, + "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 3a352b3b-b968-4347-80fe-6a9ef9045358 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "Query job 0e6d609b-9818-45fe-b26d-7247722bbea4 is DONE. 2 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 66602cee-78a8-4955-96fb-6a2d603d5d7d is DONE. 2 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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))" + ] }, - "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" + "cell_type": "code", + "execution_count": 25, + "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": [ + { + "data": { + "text/html": [ + "Query job 4768061f-d5a6-4638-8396-5c15a098ad7b is RUNNING. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job e6175f9a-6bbd-4cbe-967b-b04421b33b02 is DONE. 132.7 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + } ], - "text/plain": [ - "" + "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)" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "## gemini model\n", - "\n", - "llm_model = bf_llm.GeminiTextGenerator(model_name = \"gemini-2.5-flash\") ## 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 + { + "cell_type": "code", + "execution_count": 27, + "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": [ + "Query job 61c1138d-f4da-4971-a7dd-aa7150bafe50 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", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \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...KR-102569815-B1electronic device packageAn electronic device package technology is dis...0.357673
0Chip assemblies employing solder bonds to back...US-8962389-B2Microelectronic packages including patterned d...Embodiments of microelectronic packages and me...0.344263
0Chip assemblies employing solder bonds to back...TW-I256279-BSubstrate for electrical device and methods of...Substrate for electrical devices and methods o...0.3687
0Chip assemblies employing solder bonds to back...US-2005230147-A1Wiring board, and electronic device with an el...An electronic device is mounted on a wiring bo...0.304293
0Chip assemblies employing solder bonds to back...US-6686652-B1Locking lead tips and die attach pad for a lea...An assembly and method suitable for use in pac...0.364334
\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... KR-102569815-B1 \n", + "0 Chip assemblies employing solder bonds to back... US-8962389-B2 \n", + "0 Chip assemblies employing solder bonds to back... TW-I256279-B \n", + "0 Chip assemblies employing solder bonds to back... US-2005230147-A1 \n", + "0 Chip assemblies employing solder bonds to back... US-6686652-B1 \n", + "\n", + " title (relevant match) \\\n", + "0 electronic device package \n", + "0 Microelectronic packages including patterned d... \n", + "0 Substrate for electrical device and methods of... \n", + "0 Wiring board, and electronic device with an el... \n", + "0 Locking lead tips and die attach pad for a lea... \n", + "\n", + " abstract (relevant match) distance \n", + "0 An electronic device package technology is dis... 0.357673 \n", + "0 Embodiments of microelectronic packages and me... 0.344263 \n", + "0 Substrate for electrical devices and methods o... 0.3687 \n", + "0 An electronic device is mounted on a wiring bo... 0.304293 \n", + "0 An assembly and method suitable for use in pac... 0.364334 \n", + "\n", + "[5 rows x 5 columns]" + ] + }, + "execution_count": 27, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "## View the returned results based on simalirity with the user's query\n", + "\n", + "vector_search_results[['content', 'publication_number',\n", + " 'title', 'content_1', 'distance']].rename(columns={'content': 'query', 'content_1':'abstract (relevant match)' , 'title':'title (relevant match)'})" + ] }, - "executionInfo": { - "elapsed": 3371, - "status": "ok", - "timestamp": 1742195421813, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "code", + "execution_count": 28, + "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", + " table_id = 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" + ] }, - "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/" + "cell_type": "markdown", + "metadata": { + "id": "21rNsFMHo8hO" + }, + "source": [ + "## Step 3: AI-Powered Summarization with Retrieval Augmented Generation (RAG)" + ] }, - "collapsed": true, - "executionInfo": { - "elapsed": 1620, - "status": "ok", - "timestamp": 1742195587180, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "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." + ] }, - "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 + "cell_type": "code", + "execution_count": 37, + "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 093debfb-08f1-4bba-8b39-c3da575793a4 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" + ] }, - "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 + { + "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" + ] }, - "executionInfo": { - "elapsed": 14425, - "status": "ok", - "timestamp": 1742195608280, - "user": { - "displayName": "", - "userId": "" - }, - "user_tz": -480 + { + "cell_type": "code", + "execution_count": 35, + "metadata": { + "executionInfo": { + "elapsed": 1474, + "status": "ok", + "timestamp": 1742195536109, + "user": { + "displayName": "", + "userId": "" + }, + "user_tz": -480 + }, + "id": "EyP-ZFJK8h-2" + }, + "outputs": [], + "source": [ + "TEMPERATURE = 0.4" + ] }, - "id": "OYYkVYbs8Y0P", - "outputId": "def839e3-3dee-4320-9cb5-cac855ddea6b" - }, - "outputs": [ { - "data": { - "text/html": [ - "Load job 34f3b649-6e45-46db-a6e5-405ae0a8bf69 is DONE. Open Job" + "cell_type": "code", + "execution_count": 34, + "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": [ + { + "data": { + "text/html": [ + "Query job f77bbae5-ea1f-4ba9-92bc-bfc7bc474cd9 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "['{\"abstract\": \"Substrate for electrical devices and methods of fabricating such substrate are disclosed. An embodiment for an electrical device with substrate comprised of a chip having an active surface; a substrate being coupled with the chip; and a plurality of conductive wires (bumps) electrically connecting the chip to the substrate. In additionally, the present invention of the substrate for electrical devices may be comprised of an adhesive mean or a submember as required, and furthermore, by mean of using substrate, the present invention may be capable of affording a number of advantages, it is possible to include a thinner electrical device thickness, enhanced reliability, and a decreased cost in production.\"}', '{\"abstract\": \"An electronic device is mounted on a wiring board, which includes: a substrate having through holes, and lands extending on surfaces of the substrate and adjacent to openings of the through holes. Further, at least one coating layer is provided, which coats at least one part of an outer peripheral region of the at least one land, in order to cause that the at least one part is separated from a lead-less solder, thereby preventing any peel of the land from the surface of the substrate.\"}', '{\"abstract\": \"An assembly and method suitable for use in packaging integrated circuits including a support substrate for supporting an integrated circuit die embedded in a molded encapsulating cap. The substrate includes a conductive die attach pad adapted to be molded into the encapsulating cap. The pad includes an interior facing support surface and a spaced-apart exterior facing exposed surface defined by a peripheral edge. The support surface is adapted to support the embedded die, while the exposed surface is to be exposed from the encapsulating cap. The attach pad further includes a locking ledge portion extending outward peripherally beyond at least a portion of the exposed surface peripheral edge. This ledge is adapted to be subtended in the encapsulating cap in a manner substantially preventing a pull-out of the attach pad in a direction away from the encapsulating cap.\"}', '{\"abstract\": \"Embodiments of microelectronic packages and methods for fabricating microelectronic packages are provided. In one embodiment, the fabrication method includes printing a patterned die attach material onto the backside of a wafer including an array of non-singulated microelectronic die each having an interior keep-out area, such as a central keep-out area. The die attach material, such as a B-stage epoxy, is printed onto the wafer in a predetermined pattern such that the die attach material does not encroaching into the interior keep-out areas. The wafer is singulated to produce singulated microelectronic die each including a layer of die attach material. The singulated microelectronic die are then placed onto leadframes or other package substrates with the die attach material contacting the package substrates. The layer of die attach material is then fully cured to adhere an outer peripheral portion of the singulated microelectronic die to its package substrate.\"}', '{\"abstract\": \"An electronic device package technology is disclosed. An electronic device package may include a substrate. The electronic device package may also include a first electronic component and a second electronic component in a stacked configuration. Each of the first electronic component and the second electronic component can include electrical interconnections exposed toward the substrate. The electronic device package may further include a mold compound encapsulating the first electronic component and the second electronic component. Additionally, the electronic device package can include electrically conductive posts extending through the mold compound between the electrical interconnection of at least one of the first electronic component and the second electronic component and the substrate. Related systems and methods are also disclosed.\"}']\n" + ] + } ], - "text/plain": [ - "" + "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)" ] - }, - "metadata": {}, - "output_type": "display_data" }, { - "data": { - "text/html": [ - "Query job a574725f-64ae-4a19-aac0-959bec0bffeb is DONE. 5.0 kB processed. Open Job" + "cell_type": "code", + "execution_count": 41, + "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\": \"Substrate for electrical devices and methods of fabricating such substrate are disclosed. An embodiment for an electrical device with substrate comprised of a chip having an active surface; a substrate being coupled with the chip; and a plurality of conductive wires (bumps) electrically connecting the chip to the substrate. In additionally, the present invention of the substrate for electrical devices may be comprised of an adhesive mean or a submember as required, and furthermore, by mean of using substrate, the present invention may be capable of affording a number of advantages, it is possible to include a thinner electrical device thickness, enhanced reliability, and a decreased cost in production.\"}', '{\"abstract\": \"An electronic device is mounted on a wiring board, which includes: a substrate having through holes, and lands extending on surfaces of the substrate and adjacent to openings of the through holes. Further, at least one coating layer is provided, which coats at least one part of an outer peripheral region of the at least one land, in order to cause that the at least one part is separated from a lead-less solder, thereby preventing any peel of the land from the surface of the substrate.\"}', '{\"abstract\": \"An assembly and method suitable for use in packaging integrated circuits including a support substrate for supporting an integrated circuit die embedded in a molded encapsulating cap. The substrate includes a conductive die attach pad adapted to be molded into the encapsulating cap. The pad includes an interior facing support surface and a spaced-apart exterior facing exposed surface defined by a peripheral edge. The support surface is adapted to support the embedded die, while the exposed surface is to be exposed from the encapsulating cap. The attach pad further includes a locking ledge portion extending outward peripherally beyond at least a portion of the exposed surface peripheral edge. This ledge is adapted to be subtended in the encapsulating cap in a manner substantially preventing a pull-out of the attach pad in a direction away from the encapsulating cap.\"}', '{\"abstract\": \"Embodiments of microelectronic packages and methods for fabricating microelectronic packages are provided. In one embodiment, the fabrication method includes printing a patterned die attach material onto the backside of a wafer including an array of non-singulated microelectronic die each having an interior keep-out area, such as a central keep-out area. The die attach material, such as a B-stage epoxy, is printed onto the wafer in a predetermined pattern such that the die attach material does not encroaching into the interior keep-out areas. The wafer is singulated to produce singulated microelectronic die each including a layer of die attach material. The singulated microelectronic die are then placed onto leadframes or other package substrates with the die attach material contacting the package substrates. The layer of die attach material is then fully cured to adhere an outer peripheral portion of the singulated microelectronic die to its package substrate.\"}', '{\"abstract\": \"An electronic device package technology is disclosed. An electronic device package may include a substrate. The electronic device package may also include a first electronic component and a second electronic component in a stacked configuration. Each of the first electronic component and the second electronic component can include electrical interconnections exposed toward the substrate. The electronic device package may further include a mold compound encapsulating the first electronic component and the second electronic component. Additionally, the electronic device package can include electrically conductive posts extending through the mold compound between the electrical interconnection of at least one of the first electronic component and the second electronic component and the substrate. Related systems and methods are also disclosed.\"}']\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" + ] + } ], - "text/plain": [ - "" + "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)" ] - }, - "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" - ] + "cell_type": "code", + "execution_count": 38, + "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]" + ] }, { - "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" + "cell_type": "code", + "execution_count": 42, + "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 53681d07-ddc6-4f62-a170-ac5cafc1c7af is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 259907b0-1bae-402f-be4f-d45e478832f1 is DONE. 5.3 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:114: PreviewWarning: \u001b[93mJSON column interpretation as a custom PyArrow extention in\n", + "`db_dtypes` is a preview feature and subject to change.\u001b[0m\n", + " warnings.warn(msg, bfe.PreviewWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "Query job f3e6dca3-7674-41f6-a4ba-0daec387e25e is DONE. 2 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job e06bc512-d746-433b-b431-3e7426b6cd9c is DONE. 2 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/lib/python3.10/dist-packages/bigframes/core/array_value.py:238: AmbiguousWindowWarning: \u001b[93mWindow ordering may be ambiguous, this can cause unstable results.\u001b[0m\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" + ] + }, + { + "data": { + "text/html": [ + "Query job bcf9e83c-a420-4282-86b1-d005244c97f2 is DONE. 1.5 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "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 top five patent abstracts describe advancements in microelectronic packaging, focusing on improved chip-to-substrate interconnection and enhanced reliability. A common thread is the development of novel substrate designs and assembly methods to facilitate robust electrical connections. Several abstracts highlight techniques for creating reliable connections between chips and substrates, emphasizing the use of conductive materials and adhesives to ensure strong and durable bonds. These methods aim to improve the overall reliability and performance of electronic devices. The innovations include improved techniques for preventing delamination or peeling of conductive lands, leading to more robust assemblies. The use of encapsulating materials and specialized die-attach methods are also prominent, suggesting a focus on protecting the chip and its connections from environmental factors. These advancements collectively contribute to the creation of thinner, more reliable, and cost-effective electronic devices, with applications spanning various consumer electronics and other industries. While the abstracts don't explicitly mention electrolytic nickel layers, the focus on improved solder bond reliability and substrate design suggests that such a layer could be a complementary enhancement to the described technologies.\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 42, + "metadata": {}, + "output_type": "execute_result" + } ], - "text/plain": [ - "" + "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." ] - }, - "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" + ], + "metadata": { + "colab": { + "name": "bq_dataframes_llm_kmeans", + "provenance": [], + "toc_visible": true + }, + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "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" + } }, - "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 + "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 93ac3f31c14..3220bbf6cdb 100644 --- a/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb +++ b/notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb @@ -58,6 +58,17 @@ "" ] }, + { + "cell_type": "markdown", + "metadata": { + "id": "24743cf4a1e1" + }, + "source": [ + "**_NOTE_**: This notebook has been tested in the following environment:\n", + "\n", + "* Python version = 3.9" + ] + }, { "cell_type": "markdown", "metadata": { @@ -480,7 +491,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 22, "metadata": { "id": "UW2fQ2k5Hsic" }, @@ -570,7 +581,7 @@ ], "source": [ "# Define the model\n", - "model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")\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", @@ -1568,21 +1579,12 @@ "provenance": [] }, "kernelspec": { - "display_name": "venv", - "language": "python", + "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.1" + "version": "3.10.9" } }, "nbformat": 4, diff --git a/notebooks/generative_ai/large_language_models.ipynb b/notebooks/generative_ai/large_language_models.ipynb index 4ff9a9d3d23..1d7bc7f6ef1 100644 --- a/notebooks/generative_ai/large_language_models.ipynb +++ b/notebooks/generative_ai/large_language_models.ipynb @@ -16,12 +16,12 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Define the model" + "## Define the model" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [ { @@ -60,7 +60,7 @@ } ], "source": [ - "model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")" + "model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")" ] }, { diff --git a/notebooks/geo/geoseries.ipynb b/notebooks/geo/geoseries.ipynb index 1159b8d31de..953fc8f45fa 100644 --- a/notebooks/geo/geoseries.ipynb +++ b/notebooks/geo/geoseries.ipynb @@ -44,7 +44,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## 1. Load the Counties table from the Census Bureau US Boundaries dataset" + "### 1. Load the Counties table from the Census Bureau US Boundaries dataset" ] }, { @@ -699,7 +699,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Reuse `five_geom` and `geom_obj` to find the difference between the geometry objects" + "#### Reuse `five_geom` and `geom_obj` to find the difference between the geometry objects" ] }, { @@ -902,7 +902,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Reuse `wkts_from_geo` and `geom_obj`" + "#### Reuse `wkts_from_geo` and `geom_obj`" ] }, { diff --git a/notebooks/getting_started/bq_dataframes_template.ipynb b/notebooks/getting_started/bq_dataframes_template.ipynb index 664a3a68d33..e8002fd6115 100644 --- a/notebooks/getting_started/bq_dataframes_template.ipynb +++ b/notebooks/getting_started/bq_dataframes_template.ipynb @@ -144,7 +144,7 @@ }, "outputs": [], "source": [ - "PROJECT_ID = \"bigframes-dev\" # @param {type: \"string\"}\n", + "PROJECT_ID = \"\" # @param {type: \"string\"}\n", "LOCATION = \"US\" # @param {type: \"string\"}" ] }, @@ -180,7 +180,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": { "id": "NPPMuw2PXGeo" }, @@ -200,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", @@ -643,6 +643,16 @@ "execution_count": 13, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:239: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" + ] + }, { "data": { "text/plain": [ @@ -655,7 +665,7 @@ }, { "data": { - "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/itlsF2CojxcVoQcMZ85ceb6gAJ3ZjDE11bI+nQ6tgwM4OeNYvz6a4v10sbmvlPXfa7XjRqr5fyFCCCGqTaNGjXBxcWHTpk2MGzfO7jl/f38AEhIS8PHxASyFs1WtXbt2JCYmotfrCQsLq/L1l9S2bVtMJhPJycn07Nnzutbh5eVFYGAg+/bto1evXoDlJpMHDx6kTZs26nKOjo5X7SFQFAWlqAizwQBAYUIChTo9oGDOyUEp47XG9HQUk+lKgKLRgIMDGkdH9L6+oNOh8/FB4+SE1s0VnZvbde3jzSBBihBCiHI5OzszdepUXnzxRRwdHenevTspKSmcOHGCUaNGERISwowZM5g1axZnz57lgw8+qPI29O/fn65duzJs2DDeffddGjduzKVLl1i9ejXDhw+nQ4cOVbq9xo0b8/DDDzNq1Cg++OAD2rZtS0pKCps2baJVq1YMGTKkQut55plnePvttwkPD6dJeDjz5s0jPT0dzGbMBQUA1Ktdm93btnF2xw7c3dyo5e1NUVISAIVxcRSmpGA2GCiMiwPAnJWFKTPjykbKuBeURqNBo9PhGBqK1t0dNBp07u5ojUYciruNbhUSpAghhLiqV199Fb1ez2uvvcalS5cIDg7mySefxMHBge+//56nnnqKVq1a0bFjR2bOnMl9991XpdvXaDSsWbOGl19+mTFjxpCSkkJQUBC9evVS6yaq2uLFi5k5cyb//e9/iY+Px8/Pjy5dunDnnXeWubxiMmEuLARQA5AXnnuOhNhYRj36KDqNhsfvvZf+XbqgMxoxnD8PwDP33sv4Q4do078/+QUFnFq3DnNeHmCZs8Ss0YBGg6a4a03n64dDcZGtxskJrbt7qZtW6v390Tg4oLsJNSM3mkapyg60m8Bak5KZmXlTinaEEKIqFBQUEBUVRf369avkFvbi5rEWn155QMGUlY1iKCj+1dL1wjVOp4pGQ5s77+SeQYN4/bnnLA9qteg8PdGUqG9R6XToPDzUmpGa7mqf8+s5f0smRQghxL+WUlRkX+xqNGLKzEIxWgqAFaMRc25uhdal0ensul9i4uPZtHMXvW/rg9HZmU8WLiQ6Pp5Rzz6Lc9OmVbkb/1gSpAghhPhHi4mJoXnz5lcesA1KFIWDv/5KyDVqNUpmMjQuLuiK6z0AtC4uaFxc7LpeXN3c+O6115j2/nsoikKLFi3YuHEjERERf2t/mjdvTkxMTJnPff755zz88MN/a/01iQQpQggh/jEUkwlTZiZKcX2IYjLjm5XF7p9+Kvc1wQGBoLHUfGg0oHX3QOtaPPRao0Hr7o62eBK5yggJCanwDLGVsWbNmnKHet+oGp3qIkGKEEKIGkcxm1GKikBRLIWoRqPlsfwCzPl5Zc4PUh6dRkNDmwnQtG5uaN3c0Oh0V68HqaHq1atX3U24aW6ZIEVmnBVCiFuXYjJZajtsuloUsxmloADFOjGYdWiuyYxiMl6zELU8GicnS1cMWDIhxUEJJSYpEzXfLROkyIyzQghRcyiKgmIwlA4kFDAXGlDy8y0BR/HzisFQ5sRjV6PRakGjReNomYgMjRats5Ol9sPRkTJDDY0GdDoJRP4hbpkgRQghxN+jKIp6Izm7x41GzHl5KPn5V7IaJZnMmAvyrwQdJlOlMx0aBwc0Do42D4DWyRkc9NZf0Tg7o9HrLYGGg4MEG/9yEqQIIcQtTDGbMefno+QXlBE0KJgNBpTCIlDMdpmNqqDRaqGM+Ts0ev2V0S7W53U6tK6uEnSISpEgRQghajjFZMJcYADFkuVQ8vMtI1hMZst8Hn838NBo0bo4W4KI8iYN02jsgw6NxtLlIkGHuIEkSBFCiBtErdsAS0ZDUSzFoiW6VJSCAowZGVBGzYZiKCyeZr38QESj16N1dYXiqdPtnnNwQOPkZLmfi7UrpSSt9oYFG0uWLGHy5MlkZGTckPXfSH369KFNmzbMmTPnhm9Lo9GwYsUKhg0bdsO3dSuRIEUIIapI/rHj5B87ijk3l/wjR8g/cBBTejoA5uBgTK+8jMFoLDOYuBaN3gGNrvh11rvYOjqhcdBL7cYtZMaMGaxcufKG3C36n0iCFCHEv15RUjLmvBJTnysKhshI8vfvJ//ECUtdx1Uo+fkYzp27vgZoNOi8vNCWcU8fjYMDGhcXtI6OZbxQiH+2yofzQghxi1GMRrLWrCHl43mlfmInTOB8795cGHyH/c8dQ4h/5lnSvv6G/P0HKDh69Ko/hnPnQK/HrVdPPO+4g4AXnqfe98tovH8fTQ7sp/7/lqMPCsKpYUOcIyJwbtoU54ah6o9jgA96T5dSPzoXPVqKoDC36n4qWcNiNpt59913CQ8Px8nJidDQUGbNmsWWLVvQaDR2XTmHDx9Go9EQHR1d5rpmzJhBmzZtWLRoEaGhobi7u/P0009jMpl49913CQoKIiAggFmzZtm9LiMjg3HjxuHv74+npyd9+/blyJEjpda7dOlSwsLC8PLy4oEHHiA7O7tC+5ibm8uoUaNwd3cnODiYDz74oNQyBoOB559/njp16uDm5kbnzp3ZsmWL+vySJUvw9vZm5cqVNGrUCGdnZwYOHEhcXJz6/P/93/9x5MgRS/ebRsOSJUvU16empjJ8+HBcXV1p1KgRq1atqlDbre/D+vXradu2LS4uLvTt25fk5GTWrl1LREQEnp6ePPTQQ+QV32EZLN1ZzzzzDJMnT8bHx4fAwEAWLlxIbm4uY8aMwcPDg/DwcNauXVuhdtwIkkkRQtwylMJCDFHRYLKfbdSUnUP6D99jOHmqzNeZcnIwXb5c/oqLpz4vySEoEJf27XFt2xatx7Xv2urcLAKHoKAyn9PqdGi0WjQ6naX4tDAX3gm55jpviOmXwNGtwotPmzaNhQsX8tFHH9GjRw8SEhI4ffr0dW8+MjKStWvXsm7dOiIjI7n33nu5cOECjRs35q+//mLnzp08/vjj9O/fn86dOwNw33334eLiwtq1a/Hy8uLzzz+nX79+nD17llq1aqnrXblyJb///jvp6emMHDmS2bNnlwp4yvLCCy/w119/8euvvxIQEMD06dM5ePAgbdq0UZeZNGkSJ0+e5IcffqB27dqsWLGCQYMGcezYMRo1agRAXl4es2bN4ptvvsHR0ZGnn36aBx54gB07dnD//fdz/Phx1q1bx8aNGwHs5v36v//7P959913ee+895s2bx8MPP0xMTIy6f9cyY8YM5s+fj6urKyNHjmTkyJE4OTmxbNkycnJyGD58OPPmzWPq1Knqa77++mtefPFF9u7dy48//shTTz3FihUrGD58ONOnT+ejjz7i0UcfJTY2FldX1wq1oypJkCKEqHaKopC7Yyd5+/aVfZVvNlFw8hR5hw6h5Odf1zZ0Pj543H57qcJRrYcH3iOG4/gvmmq8MrKzs5k7dy7z58/nscceA6Bhw4b06NHDLotQGWazmUWLFuHh4UGzZs247bbbOHPmDGvWrEGr1dKkSRPeeecdNm/eTOfOndm+fTt79+4lOTkZp+J76Lz//vusXLmS5cuXM2HCBHW9S5YswcPDA4BHH32UTZs2XTNIycnJ4auvvuLbb7+lX79+gOXkXbduXXWZ2NhYFi9eTGxsLLVr1wbg+eefZ926dSxevJi33noLgKKiIubPn68GV19//TURERHs3buXTp064e7ujl6vJ6iMYHb06NE8+OCDALz11lt8/PHH7N27l0GDBlXouM6cOZPu3bsDMHbsWKZNm0ZkZCQNGjQA4N5772Xz5s12QUrr1q155ZVXAEswOnv2bPz8/Bg/fjwAr732Gp9++ilHjx6lS5cuFWpHVZIgRQhRaea8PIouXar4CxQFw/nz5O7aTf6RI+qIF3V9hQaMlxIqtCqth4dlJIstjQbXDh3wvmcEmrJuBKfR4NykSenXVScHV0tGo7q2XUGnTp3CYDCoJ++qEBYWpgYSYLkpnk6nQ2tTUBwYGEhycjIAR44cIScnB19fX7v15OfnExkZWe56g4OD1XVcTWRkJIWFhWpgAVCrVi2aNGmi/n7s2DFMJhONGze2e63BYLBrl16vp2PHjurvTZs2xdvbm1OnTtGpU6ertqNVq1bq/93c3PD09KxQ+8t6fWBgIK6urmqAYn1s79695b5Gp9Ph6+tLy5Yt7V4DVKodVemWCVLk3j1CVC1FUSiMiiZv7x5MmVkVfp0pM5OMn3/GXMG+/orSODnhOWQIOo/S3S4ADnVDcO3cCafwcMskYrc6jaZSXS7VxcXFpdznrEGFYpP9Ku/uvLYcStzQT6PRlPmYuXiodk5ODsHBwWVmbry9va+6XnN5M+hWUk5ODjqdjgMHDqArMZeMexldhdfj77bf9vXXOqZX22bJ9QBVdhwr65YJUuTePUJYKCYTZpvit8ow5+WRt28/uTt3krtrF8aEimUvyqJ1d6/U3WP1AQG4demMa6dO6GxOLFaODRqg9/G57vaIG6NRo0a4uLiwadMmxo0bZ/ecv78/AAkJCfgUv3c3Ymhtu3btSExMRK/XExYWVuXrb9iwIQ4ODuzZs4fQ4rslp6enc/bsWXr37g1A27ZtMZlMJCcn07Nnz3LXZTQa2b9/v5o1OXPmDBkZGURERADg6OgoF9uVcMsEKUL8EykmEwUnT1JUXP1vy5ieTv6Bg3YBiVJoIP+IZR6OqqBxcMClbVscQupee2H1RRrcunTF847B/4yMhrgqZ2dnpk6dyosvvoijoyPdu3cnJSWFEydOMGrUKEJCQpgxYwazZs3i7NmzZY6K+bv69+9P165dGTZsGO+++y6NGzfm0qVLrF69muHDh9OhQ4e/tX53d3fGjh3LCy+8gK+vLwEBAbz88st23U+NGzfm4YcfZtSoUXzwwQe0bduWlJQUNm3aRKtWrRgyZAhgyUw888wzfPzxx+j1eiZNmkSXLl3UoCUsLIyoqCgOHz5M3bp18fDwUOtsRGkSpAhRQYqiUBQTgzE1laKkJEsAUWi49gttGU3kHztGUWysZZ1mc5mzjN5IThERuHXtilu3bri2b4f2Kul8IQBeffVV9Ho9r732GpcuXSI4OJgnn3wSBwcHvv/+e5566ilatWpFx44dmTlzJvfdd1+Vbl+j0bBmzRpefvllxowZQ0pKCkFBQfTq1Uutmfi73nvvPXJychg6dCgeHh7897//JTMz026ZxYsXM3PmTP773/8SHx+Pn58fXbp04c4771SXcXV1ZerUqTz00EPEx8fTs2dPvvrqK/X5e+65h19++YXbbruNjIwMFi9ezOjRo6tkH/6JNIpShXebugms3T2ZmZl4el57SKAQiqJgTE6+ajBgvJxG7o7t5GzfjjExqcxlzAUFmFJTq7x9Wg8PnJo0RqO17+fWODnh0rYNDnZfwhqcmjTBqXGjsm9Tf82NacueFl3ccAUFBURFRVG/fn2cy5i0Tdz6buVbAFSVq33Or+f8Ld9W4pahKAqGU6cw2FTzX3X5IiP5hw6Rs3UrxqSyA4/K0jg44FCnDlo3N1w7dEB3HTUUjmFhuLRsod49Vu/nJ4GDEEKUQb4ZxU1lNhgovHDB0s2hQMGpkxhOneJqCT3jpQTy9u/HbDCA0Vjuclel0101ENA4OeHasSPuPXsWZzXKqLXQanFq2LBmDWMVQlxTbGwszZo1K/f5kydPqgWzNdGTTz7Jt99+W+ZzjzzyCJ999tlNbtHNI909ohRFUa45bbZiNJJ/4ACFMTGYMjLI3bX7mkNSFSxDXq93Mi4AjasrLs2bV3hUiWODBrj37o1rp45opThNVCPp7qk+RqOx3Gn6wVLMqq/B2czk5GSyssqeJsDT05OAgICb3KLySXePqBJFiYkYU0tME64oZK1ZQ/qyZaUm26pKOi8vNMUfXn1QIG4dO6JxKv9LW+vujlvnTuh8fND5+sqN1oQQlaLX6wkPD6/uZly3gICAGhWI3EwSpNzCDBcukH/kKJjLKAhVFPJPnKDgxEkoMQmPOS+PwgsX/vb2dX5+uLRqhdbZGZcO7XEMufZ9SPQBATg1biy3lRdCCHFNEqTUYIbISDJ/XYVSWGj3uFJYSO7u3X8v0NBq0QcEWGa9tKH39cVv4tO42NxUqzw6Ly+ZJ0MIIcQNI0FKNSlKSMBw/jymzCxyt23FWHLImslM3p49KFebYtrBAdc2bdC6lT21tj4oELcuXdG6lOhK0Wpxbt4cfQXvrCmEEEJUBwlSbjDDhQskvfMORdEx6mOKyUTRxYsVer1bjx44RzQt8ahlrgz33r3Q2dxMSwghhPgnkSClCihFRRjT0688YDaTf/AgWevWk7N5c9nZEI3GcqM0VxdcO3TAqUHDUl0vDrVr49q5k9RvCCGE+FeSIOU6mQ0GCiMjMVyIImn27KvOROrWqye+Y8ehcbhyuB1DQtAX35xLCCFqMkVReOKJJ1i+fDnp6el4eXkxevRo5syZA1iG8E6ePJnJkydXazsrQqPRsGLFCoYNG1bdTWHGjBmsXLnyhtyU8Z9CgpRKMuXkkjJnDpkrV2LOybnyhEZjlwlxCKmL58BBeA4aiFNEhGRDhBC3rHXr1rFkyRK2bNlCgwYNuPfee+2e37dvH27l1MYJi5oUHN1KbpkgZcGCBSxYsOCm3eLalJ1N7o6ddl01RQkJZPzwA0WXLgGg9fJC5+6O55Ah+E2aKPN3CCH+kSIjIwkODqZbt24ApSY+868hWeHCwkIc5Xv4H+WWGT86ceJETp48yb59+27YNhRFIfvPP0n7ZikX7rqb+MmTufTCC+pPyocfUnTpEg61axOycCGNd+0kfNNGAqb8RwIUIUSlKIpCXlFetfxUZqLx0aNH88wzzxAbG4tGoyEsLKzUMmFhYWrXD1iyBp9++imDBw/GxcWFBg0asHz5cvX56OhoNBoNP/zwA926dcPZ2ZkWLVrw119/2a33+PHjDB48GHd3dwIDA3n00UdJtela79OnD5MmTWLy5Mn4+fkxcODAir8BxeLi4hg5ciTe3t7UqlWLu+++22522tGjRzNs2DDef/99goOD8fX1ZeLEiRTZXMAmJCQwZMgQXFxcqF+/PsuWLbM7JtZjNnz48DKP4dKlSwkLC8PLy4sHHniA7GvM3m27/8888wyTJ0/Gx8eHwMBAFi5cSG5uLmPGjMHDw4Pw8HDWrl2rvmbLli1oNBrWr19P27ZtcXFxoW/fviQnJ7N27VoiIiLw9PTkoYceIi8vr9LHs6rdMpmUmyF740bin3lW/V1fOxgnmw+TxtkF9z698bxjCDp3SW0KIa5fvjGfzss6V8u29zy0B1eHit2Dau7cuTRs2JAvvviCffv2odPpuO+++675uldffZXZs2czd+5cli5dygMPPMCxY8eIiIhQl3nhhReYM2cOzZo148MPP2To0KFERUXh6+tLRkYGffv2Zdy4cXz00Ufk5+czdepURo4cyZ9//qmu4+uvv+app55ix44dlT4ORUVFDBw4kK5du7Jt2zb0ej0zZ85k0KBBHD16VM3KbN68meDgYDZv3sz58+e5//77adOmDePHjwdg1KhRpKamsmXLFhwcHJgyZQrJycnqdvbt20dAQACLFy9m0KBB6HRX7ngeGRnJypUr+f3330lPT2fkyJHMnj2bWbNmVWgfvv76a1588UX27t3Ljz/+yFNPPcWKFSsYPnw406dP56OPPuLRRx8lNjYWV5v7js2YMYP58+fj6urKyJEjGTlyJE5OTixbtoycnByGDx/OvHnzmDp1aqWPa1WSIMVG2leLAHBu0QK3bt3we2JCuXOQCCHEv4GXlxceHh7odDqCgoIq/Lr77ruPcePGAfDmm2+yYcMG5s2bxyeffKIuM2nSJO655x4APv30U9atW8dXX33Fiy++yPz582nbti1vvfWWuvyiRYsICQnh7NmzNG7cGIBGjRrx7rvvXte+/fjjj5jNZr788ku1bnDx4sV4e3uzZcsWBgwYAICPjw/z589Hp9PRtGlThgwZwqZNmxg/fjynT59m48aN7Nu3jw4dOgDw5Zdf0qhRI3U71u4wb2/vUsfQbDazZMkSPIqnk3j00UfZtGlThYOU1q1b88orrwAwbdo0Zs+ejZ+fnxpAvfbaa3z66accPXqULl26qK+bOXMm3bt3B2Ds2LFMmzaNyMhIGjRoAMC9997L5s2bJUipKfIOHSL/8GE0Dg6EfPqJjLwRQtxQLnoX9jy0p9q2faN17dq11O8lR7HYLqPX6+nQoQOnTp0C4MiRI2zevBl3d/dS646MjFSDlPbt2193G48cOcL58+fVAMGqoKCAyMhI9ffmzZvbZT+Cg4M5duwYAGfOnEGv19OuXTv1+fDwcHx8fCrUhrCwMLvtBwcH22VhrqVVq1bq/3U6Hb6+vrRs2VJ9LDAwEKDUOm1fFxgYiKurqxqgWB/bu3dvhdtxo0iQUixt8RIAPO8aKgGKEOKG02g0Fe5y+TfKyclh6NChvPPOO6WeCw4OVv//d0YV5eTk0L59e7777rtSz9kWAzuUuOu6RqPBXOKeaNfr7667rNfbPmbNEJVcZ8llbuQ+/h23TOHsjeZ9zwhcO3XCd/To6m6KEELc8nbv3l3qd9t6lJLLGI1GDhw4oC7Trl07Tpw4QVhYGOHh4XY/VTXcuV27dpw7d46AgIBS2/Dy8qrQOpo0aYLRaOTQoUPqY+fPnyfddoJPLEHBzRqd+k8iQUox9969qffN1zjZ9CMKIYS4Pj///DOLFi3i7NmzvP766+zdu5dJkybZLbNgwQJWrFjB6dOnmThxIunp6Tz++OOAZURnWloaDz74IPv27SMyMpL169czZsyYKjvZP/zww/j5+XH33Xezbds2oqKi2LJlC88++ywXK3jrkqZNm9K/f38mTJjA3r17OXToEBMmTMDFxcVufqywsDA2bdpEYmJiqQBGlE+CFCGEEFXu//7v//jhhx9o1aoV33zzDd9//z3NmjWzW2b27NnMnj2b1q1bs337dlatWoWfnx8AtWvXZseOHZhMJgYMGEDLli2ZPHky3t7eaKvo7uuurq5s3bqV0NBQRowYQUREBGPHjqWgoABPT88Kr+ebb74hMDCQXr16MXz4cMaPH4+HhwfOzldu7vrBBx+wYcMGQkJCaNu2bZW0/99Ao1RmwHwNkJWVhZeXF5mZmZX6EAkhRHUqKCggKiqK+vXr2528/omuNbtqdHQ09evX59ChQ7Rp0+amtu1muHjxIiEhIWzcuJF+/fpVd3Nuqqt9zq/n/C2Fs0IIIcTf8Oeff5KTk0PLli1JSEjgxRdfJCwsjF69elV302550t0jhBDiH+G7777D3d29zJ/mzZvfsO0WFRUxffp0mjdvzvDhw/H391cndrtesbGx5e6Lu7s7sbGxVbgHNZdkUoQQQlSpa1URhIWFVWpq/oq666676Ny57Fl8/07AcC0DBw68rin5r6Z27dpXvTty7dq1q3R7NZUEKUIIIf4RPDw8Sk3MdqvS6/WEh4dXdzOqnXT3CCGEEKJGkiBFCCGEEDWSBClCCCGEqJEkSBFCCCFEjSRBihBCCCFqJAlShBBClKtPnz5Mnjy5Ste5ZMkSvL29q3Sd4p9JghQhhBBC1EgSpAghhBCiRrplgpQFCxbQrFkzOnbsWN1NEUKIfxWj0cikSZPw8vLCz8+PV199VZ0xNj09nVGjRuHj44OrqyuDBw/m3Llzdq9fsmQJoaGhuLq6Mnz4cC5fvqw+Fx0djVarZf/+/XavmTNnDvXq1cNsNl+1bVu2bEGj0bB+/Xratm2Li4sLffv2JTk5mbVr1xIREYGnpycPPfQQeXl56uvWrVtHjx498Pb2xtfXlzvvvJPIyEj1+cLCQiZNmkRwcDDOzs7Uq1ePt99+G7DMqDtjxgxCQ0NxcnKidu3aPPvssxU6lgkJCQwZMgQXFxfq16/PsmXLCAsLY86cORV6/b/NLTPj7MSJE5k4caJ6F0UhhLiVKYqCkp9fLdvWuLig0WgqvPzXX3/N2LFj2bt3L/v372fChAmEhoYyfvx4Ro8ezblz51i1ahWenp5MnTqVO+64g5MnT+Lg4MCePXsYO3Ysb7/9NsOGDWPdunW8/vrr6rrDwsLo378/ixcvpkOHDurjixcvZvTo0Wi1FbuWnjFjBvPnz8fV1ZWRI0cycuRInJycWLZsGTk5OQwfPpx58+YxdepUAHJzc5kyZQqtWrUiJyeH1157jeHDh3P48GG0Wi0ff/wxq1at4qeffiI0NJS4uDji4uIA+N///sdHH33EDz/8QPPmzUlMTOTIkSMVaueoUaNITU1V7+0zZcoUkpOTK/pW/OvcMkGKEEL8kyj5+Zxp175att3k4AE0rq4VXj4kJISPPvoIjUZDkyZNOHbsGB999BF9+vRh1apV7Nixg27dugGWm/yFhISwcuVK7rvvPubOncugQYN48cUXAWjcuDE7d+5k3bp16vrHjRvHk08+yYcffoiTkxMHDx7k2LFj/PrrrxVu48yZM+nevTsAY8eOZdq0aURGRtKgQQMA7r33XjZv3qwGKffcc4/d6xctWoS/vz8nT56kRYsWxMbG0qhRI3r06IFGo6FevXrqsrGxsQQFBdG/f38cHBwIDQ2lU6dO12zj6dOn2bhxI/v27VMDsi+//JJGjRpVeD//bW6Z7h4hhBDVo0uXLnaZl65du3Lu3DlOnjyJXq+3u6mfr68vTZo04dSpUwCcOnWq1E3/unbtavf7sGHD0Ol0rFixArB0D912222EhYVVuI2tWrVS/x8YGIirq6saoFgfs81YnDt3jgcffJAGDRrg6empbst6d+HRo0dz+PBhmjRpwrPPPssff/yhvva+++4jPz+fBg0aMH78eFasWIHRaLxmG8+cOYNer6ddu3bqY+Hh4fj4+FR4P/9tJJMihBDVQOPiQpODB6pt2zWJo6Mjo0aNYvHixYwYMYJly5Yxd+7cSq3D9i7HGo2m1F2PNRqNXX3L0KFDqVevHgsXLqR27dqYzWZatGhBYWEhAO3atSMqKoq1a9eyceNGRo4cSf/+/Vm+fDkhISGcOXOGjRs3smHDBp5++mnee+89/vrrrxt6t+V/IwlShBCiGmg0mkp1uVSnPXv22P2+e/duGjVqRLNmzTAajezZs0ft7rl8+TJnzpyhWbNmAERERJT5+pLGjRtHixYt+OSTTzAajYwYMeIG7c2VNi5cuJCePXsCsH379lLLeXp6cv/993P//fdz7733MmjQINLS0qhVqxYuLi4MHTqUoUOHMnHiRJo2bcqxY8fssiQlNWnSBKPRyKFDh2jf3tLVd/78edLT02/Mjv4DSJAihBDiqmJjY5kyZQpPPPEEBw8eZN68eXzwwQc0atSIu+++m/Hjx/P555/j4eHBSy+9RJ06dbj77rsBePbZZ+nevTvvv/8+d999N+vXr7erR7GKiIigS5cuTJ06lccffxyXG5jt8fHxwdfXly+++ILg4GBiY2N56aWX7Jb58MMPCQ4Opm3btmi1Wn7++WeCgoLw9vZmyZIlmEwmOnfujKurK99++y0uLi52dStladq0Kf3792fChAl8+umnODg48N///heXShYy/5tITYoQQoirGjVqFPn5+XTq1ImJEyfy3HPPMWHCBMAyCqd9+/bceeeddO3aFUVRWLNmjdrt0aVLFxYuXMjcuXNp3bo1f/zxB6+88kqZ2xk7diyFhYU8/vjjN3R/tFotP/zwAwcOHKBFixb85z//4b333rNbxsPDg3fffZcOHTrQsWNHoqOjWbNmDVqtFm9vbxYuXEj37t1p1aoVGzdu5LfffsPX1/ea2/7mm28IDAykV69eDB8+nPHjx+Ph4YGzs/ON2t1bmkaxDna/RViHIGdmZuLp6VndzRFCiAopKCggKiqK+vXrywmpHG+++SY///wzR48ere6m3DQXL14kJCSEjRs30q9fv+puzt92tc/59Zy/pbtHCCFEtcrJySE6Opr58+czc+bM6m7ODfXnn3+Sk5NDy5YtSUhI4MUXXyQsLIxevXpVd9NqJOnuEUIIUa0mTZpE+/bt6dOnT6munieffBJ3d/cyf5588slqanHZtm3bVm5b3d3dASgqKmL69Ok0b96c4cOH4+/vr07sJkqT7h4hhLgJpLvn+iQnJ5OVlVXmc56engQEBNzkFpUvPz+f+Pj4cp8PDw+/ia2pHtLdI4QQ4l8jICCgRgUiV+Pi4vKvCERuJunuEUKIm+gWS14LUSlV/fmWIEUIIW4CnU4HoM5oKsQ/kfVO01VVYyPdPUIIcRPo9XpcXV1JSUnBwcGhwnf3FeJWoCgKeXl5JCcn4+3trQblf5cEKUIIcRNoNBqCg4OJiooiJiamupsjxA3h7e1NUFBQla1PghQhhLhJHB0dadSokXT5iH8kBweHKsugWEmQIoQQN5FWq5UhyEJUkHSKCiGEEKJGkiBFCCGEEDWSBClCCCGEqJEkSBFCCCFEjSRBihBCCCFqJAlShBBCCFEjSZAihBBCiBpJghQhhBBC1EgSpAghhBCiRpIgRQghhBA1kgQpQgghhKiRJEgRQgghRI0kQYoQQgghaqSbHqRkZGTQoUMH2rRpQ4sWLVi4cOHNboIQQgghbgH6m71BDw8Ptm7diqurK7m5ubRo0YIRI0bg6+t7s5sihBBCiBrspmdSdDodrq6uABgMBhRFQVGUm90MIYQQQtRwlQ5Stm7dytChQ6lduzYajYaVK1eWWmbBggWEhYXh7OxM586d2bt3r93zGRkZtG7dmrp16/LCCy/g5+d33TsghBBCiH+mSgcpubm5tG7dmgULFpT5/I8//siUKVN4/fXXOXjwIK1bt2bgwIEkJyery3h7e3PkyBGioqJYtmwZSUlJ178HQgghhPhHqnSQMnjwYGbOnMnw4cPLfP7DDz9k/PjxjBkzhmbNmvHZZ5/h6urKokWLSi0bGBhI69at2bZtW7nbMxgMZGVl2f0IIYQQ4p+vSmtSCgsLOXDgAP3797+yAa2W/v37s2vXLgCSkpLIzs4GIDMzk61bt9KkSZNy1/n222/j5eWl/oSEhFRlk4UQQghRQ1VpkJKamorJZCIwMNDu8cDAQBITEwGIiYmhZ8+etG7dmp49e/LMM8/QsmXLctc5bdo0MjMz1Z+4uLiqbLIQQgghaqibPgS5U6dOHD58uMLLOzk54eTkdOMaJIQQQogaqUozKX5+fuh0ulKFsElJSQQFBVXlpoQQQgjxD1elQYqjoyPt27dn06ZN6mNms5lNmzbRtWvXqtyUEEIIIf7hKt3dk5OTw/nz59Xfo6KiOHz4MLVq1SI0NJQpU6bw2GOP0aFDBzp16sScOXPIzc1lzJgxf6uhCxYsYMGCBZhMpr+1HiGEEELcGjRKJad73bJlC7fddlupxx977DGWLFkCwPz583nvvfdITEykTZs2fPzxx3Tu3LlKGpyVlYWXlxeZmZl4enpWyTqFEEIIcWNdz/m70kFKdZMgRQghhLj1XM/5+6bfu0cIIYQQoiIkSBFCCCFEjSRBihBCCCFqJAlShBBCCFEj3TJByoIFC2jWrBkdO3as7qYIIYQQ4iaQ0T1CCCGEuOFkdI8QQggh/jEkSBFCCCFEjSRBihBCCCFqJAlShBBCCFEjSZAihBBCiBrplglSZAiyEEII8e8iQ5CFEEIIccPJEGQhhBBC/GNIkCKEEEKIGkmCFCGEEELUSBKkCCGEEKJGkiBFCCGEEDWSBClCCCGEqJFumSBF5kkRQggh/l1knhQhhBBC3HAyT4oQQggh/jEkSBFCCCFEjSRBihBCCCFqJAlShBBCCFEjSZAihBBCiBpJghQhhBBC1EgSpAghhBCiRpIgRQghhBA10i0TpMiMs0IIIcS/i8w4K4QQQogbTmacFUIIIcQ/hgQpQgghhKiRJEgRQgghRI0kQYoQQgghaiQJUoQQQghRI0mQIoQQQogaSYIUIYQQQtRIEqQIIYQQokaSIEUIIYQQNZIEKUIIIYSokW6ZIEXu3SOEEEL8u8i9e4QQQghxw8m9e4QQQgjxjyFBihBCCCFqJAlShBBCCFEjSZAihBBCiBpJghQhhBBC1EgSpAghhBCiRpIgRQghhBA1kgQpQgghhKiRJEgRQgghRI0kQYoQQgghaiQJUoQQQghRI0mQIoQQQogaSYIUIYQQQtRIEqQIIYQQoka6ZYKUBQsW0KxZMzp27FjdTRFCCCHETaBRFEWp7kZURlZWFl5eXmRmZuLp6VndzRFCCCFEBVzP+fuWyaQIIYQQ4t9FghQhhBBC1EgSpAghhBCiRpIgRQghhBA1kgQpQgghhKiRJEgRQgghRI0kQYoQQgghaiQJUoQQQghRI0mQIoQQQogaSYIUIYQQQtRIEqQIIYQQokaSIEUIIYS4yQ4nH2ZDzAbyivIA2J2wm0PJh8pdvsBYwDcnvuHtPW+TW5QLwIXMC2yJ2wLAxpiNnLp8yu41J1JP8NOZn9h6cSs/nfmJ6Mxo9bn4nHiKTEV2yx9KPsTehL1/f+eqkL66GyCEEEL8XUazkdisWOp71Uej0VzXOvKK8lh+djk+zj50Ce6Cv6t/pV7/W+RvzDkwhze6v0Er/1YUGAvKXEemIZNxf4zDYDLg4eDBqOajWHB4AXqtnlV3r6KuR111H/Ym7OW5zc+RU5Sjvj4lP4X3er3H0xufJj4nnocjHua7U9/hoHXgrZ5vMShsEHFZcYxZP4Z8Y776ulCPUH4b/hvHUo/x6JpHaVqrKV8N/AoPRw9ismJ4fN3jmBQTy4Yso4Vfi+s6hlVN7oIshBCiTGbFjAbNdZ/0r0VRFFaeX0k9z3q0C2xX6debFTNajaVDYOrWqayJWkPfkL482+5ZwjzD0Gl1pbZ3JOUI3k7ehHqGqq+1en/f+3x98msAnHROjGo2isdbPE5MdgxatDT0boijzhGzYua5zc9xJPkIA8IGMKX9FFz0Lty18i6is6LxcfLBaDaioPDz0J/JKswiwDUAPxc/ADbFbmLy5sll7lNDr4Yk5yfj5uDGXQ3vYm/CXg6nHAYg0DWQywWXMZqN3F7vdjbEbCj1eg0aPu77MYuOL+JQ8iGC3YJxc3AjPieefGM+n/b/lI0xG/nfuf8BEO4dTp+QPpy6fIodl3YA0My3GcvuWFbq+P1d13P+liBFCHFLsz1R1SQXMi/g6+yLl5PXTd3uycsnOZ56nPsa3/e3goszaWcYs24MOq2OTkGdmNh2Ig28GqAoSrnrLTIVUWQuwkXvUuYyhaZC3trzFl5OXjzd5mlWRa7ijV1v4Kp3ZfWI1VzIuECe0dL9EeQWRNNaTckpzGFDzAaMipF7Gt2DVqPFZDbx7r53WXl+JeNajqNTcCceWfOI3bbCPMP4pP8nhHiEqI8tPr6YDw98CEDTWk0Z22Isy88tp4VvC0Y0GsGjax8lrSDNbj16jR6jYgTARe/CgHoDqOtRlwWHF6jLPBLxCHc2uJMHVj9Qap/ruNchPicenUbHgHoDmN55Op8d/YzvTn3H8PDhxOfEszdxLwEuAaTkp6BQ+pSs1+pZPnQ5YZ5h/HT2J97a81apZXQaHb3q9mJz3Gb1MTcHN/531/+o416Hd/a+w7envqV7ne6cTD1JuiG91Dq0Gi0uehdyi3J5ufPLPNC09P78HRKkCCH+Vb489iULjy7kgz4f0KNOj+pujmp3wm6e2PAE7QLasXjQYvXx/Yn7STekc3u92wFIyUvBpJgIcguqku1mGjLp8YPlOHw14Cs6BXdSn8suzOZ02mlOp50mOS8ZsFx1DwwbSHO/5qXW9eTGJ9kRv0P9Xa/V88XtXzD/0HyyCrOY0W0G3k7efHbkM3QaHXU96vLVsa8oMBXQLqAdSwYtKRWofHnsS+YenAtYgojkvGQ1KPF09CSrMMtu+Vb+rTibdpYCUwEAt4XcRr4xn8TcRKKzotXldBodJsVE9zrdKTQVcjz1OPnGfPxd/Pl68NcsPbmUtII0NsduptBciIPWgSKzfT2Gla+zLxvu28C2i9v46MBHRGdF46p3Ra/Vl2pf1+Cu7ErYhY+TDwPCBvDjmR9p6deSpLwkwr3D2XlpZ6n1B7sFk5qfSpG5iPd7v0+POj1YFbmKXnV78fOZn/nl3C9MajuJDEMG8w7NA+Duhnczs8dMwBKUP7LmEY6lHgMsmZDzGecZHDaYN3u8yaNrHuVU2im8nbx5t9e7dK3dFYDozGiGrhyqtsPbyZsf7vyBnZd2sidhD1svbuWBJg8Q7B7MslPLeKXLK3QO7lzmMbpeEqQIIapNYm4im+M2c2/je3HQOpS5jMFk4H9n/0ff0L5/+8T8W+RvTN8+HYA76t/BO73esXv+QNIBcoty6VW3FwD7EvdRaCqke53ugOXLfnfCbhJyEhgWPqzc1Ha+MZ9fzv3CwLCBfHXsKzbEbOClTi/Rv17/Mpc3mo30+akPmYZMAHY8uIPUvFRqu9em94+9yTPmsXzoclZHrWbpiaW4OriyevhqvJ291XUUmYr45dwvhHqG0jm4M6n5qczeO5v7m9yvnjgURWFt1FrMmKnvVZ89CXs4mnKUTbGbAHiu3XOMazkOgHXR65i+bXqZJ+Zw73BW3L1C/T01P5X10euZvXc2eo2eD/p8wDcnv+FA0gH8XfxJyU+5+htT7Mc7f+RA0gG8nLw4dfkUUZlRHEw+SL4xHxe9i1orEewWTEJuAgDOOmca+TTCrJg5nXYak2ICoJ5nPeKy4zArZnX9eo2ekU1GsvL8SvKMebjoXfj17l8Jdg8mJS+FCRsmcD7jPLWca9llR3rU6cHLnV9m/B/juZhzkcFhg4nLjuP45eMAjGk+hikdpljeB3MRkRmRhHqE4qJ34VDyIRYdX8RfF/+ioVdDvr/ze+745Q5S81PV9c/vO59edXuh0WjULqhRzUZxR/07mLptKjFZMeqyf93/F7Wca9kdN2umSlEUXt/5OlvitvD14K+p71VfXeZE6gkeWfsIoR6hfDP4G1aeX8mw8GF4OXmRmp/Kuqh13F7vdgLdAu3WbduddW/je3m96+ul3jeT2YRJMeGoc6zQ+1wZEqQIIW6KoylHySnKoVvtboDli63PT33IMGTwSudXuL/p/fx4+ke+Pvk1n/X/jFDPUAA+OvARi44vorlvc0a3GM32i9uZ1nkabg5uldr++fTz3P/7/RSaCwHLFfd3d3ynPp9pyKTfz/0wmAz8evev1HKuRd+f+2JWzKy7Zx3+Lv6M3zCefYn7AJjVYxZ3NbyrzG3NPTiXL499qV6x2rqr4V3M6jELgMv5l3F1cOX3C7/zxq431GXaBbTjYPJBuxqChl4NicyMVJd5t9e7DK4/WP193qF5fHH0CwDa+Lehvld9VpxfQbh3OD/e+SPnM86zNmotS04sKfcYDWkwhNk9Z5NTmMOQFUNIK0gj0DWQZr7NCPEIwWg2suz0MnQaHfse3oeDzpJdGPLLEDVouK/xfbzW9TUiMyIZ9uuwMrfTNbgrJsXEsdRjPN/hef6I+YM9CXvsgg9brfxbMa/vPHbE7yA+J557Gt3Dhwc+5EDSAd7p9Q5tA9oClvd456WdtAloQ0u/lmyJ28J3p7+jW+1uhHuH08CrAXU96mIwGTifcR4vRy/qetRVtxObFcuIVSMwmAyAJThx0DrwSpdXCHANIK8oj7jsOJrUakK+MZ8Xt77IoeRDfD/ke7suorJcyLiAn6sfno6efLD/A/V9aBvQlq8GfqUG6da2NavVDI1GQ1pBGn1+7IOCQoBLAJtGbrrqdoByu9fisuJwc3QrFeRcy2+Rv7E6ajXTOk2jnme9Sr3275IgRVS5IlMRf138i5Z+LUtF5TXN/sT9BLkF2X1RiSvMipmdl3bSyr8Vno6eFJoK2XpxK93rdMdF71Lh9ZxJO8ODqx/EaDayatgqwrzC+PX8r7yy4xUAOgV14quBXzHyt5GcSjvFxDYTebL1k6QVpDHof4PsRhsATG43mbEtx7LmwhoOJB3gjgZ30C6gHSbFRG5RLkXmIr45+Q3NfJsxKGwQhaZCHlj9AOfSz6knQle9K7se2qXWpiw7tYy3974NwIRWE6jrXpfXdr4GwGtdX6Oue10mbJigtsE22LBlNBsZsHxAudkDDRp2PLiD5LxkHvj9ARp6N6TAWGAXgFTEsPBhvNn9TcCSkRq6YigFpoIyuyUa+zTmbPpZ9fcA1wDS8tPoWrsruUW55Bblcib9jJohmXNgDl8d/4owzzB+ufsX9QSqKApdv7e8ZsVdKwj3CWdz7Gae3fwsHg4ejGwykgmtJuDq4ArAo2seVQs4fx32K8FuwQDqZ8dkNqHT6uy6dKwG1BtAx6COpOSncE+je6jtXtvueetpqKoLdK01KNZut2vVLl1PfVNsViwjfx9JC78WzL1t7jUD7qjMKN7Z9w4jwkcwIGxApbZ1q7ue87cMQf6HKjIX8Wfsn8RkxTCq2Sic9c7XtZ539r3Dj2d+RKfRMbHNRMa3Gl/FLS19pWBWzBxNOUqTWk0qfPI8dfkUY9aPwdvJm9UjVuPpeGMC2LjsOIxmo13qtTLOpJ3Bz8UPXxffMq+QsgqzWH1hNVq0jGwykjPpZwj1CCUxL5E/Y//kvsb3XbUQ02Q28drO1/B09OTFji/arf/zI5/zyZFPqONeh3l95/HlsS9ZE7WGB5o8wMtdXq5Q+/OK8nhp20vqiXNt1FrGtRqn9p0DnM84j8ls4kLmBcCSmo7MiOT9/e+Tb8wvdeL9+ezPpBWk8c3JbwD46exPTG43mX2J+9hxaYdauKjT6Ah2CyYyI5Jz6eeo5VyLpYOXMuiXQeQZ80jITaCOex0KTYWsPL9SXf+aC2uo53XlinH7xe3qMbQWNR5IOlDm/u68tLNUgPLN4G9o5N2Igf8bSFZhFoeTD/Pbhd8oMBVw4vIJwHLintBqQqmTtS1HrSNvdn+TqdumsjN+J4qicDb9LK/ueFWt6xjSYAhv7n7T7nVn08+i1+ip61GXMS3GMDx8OGbFrHZXJeYmcvvy24nKjCLTkMkPZ34A4D/t/2PXDafRaGjo1ZCjqUe5kHmBcJ9w9biNaDSCye0n2213ZJORHE45TMegjjTwalBqf6zb7xrclblY9tvD0YPNIzfjpHMq9zhY23IjjG4+mma+zWju27xCwcf1FGCHeoay7f5t6LX6Cu1Hfa/6fNb/s0pv59/qlglSFixYwIIFCzCZTNXdlJsm05DJ+uj13NngTvVqpjwHkg6Qb8ynR50eFJmKeGTtI5y8fBKwVNRPajuJ2KxYDiQd4O7wu9U/xuzCbP6M/ZM+IX1KnfxismJYfnY5ACbFxCdHPuGBpg/g4ehht5yiKPx+4Xea1mpKI59G6jbH/TGO2KxYhjcazviW40vtQ74xnzd3vcnOSzuZc9scNsVuwqSYyDJk8WvkrwwPH84b3d9AURQu5lykrnvdUl8Cv0X+xsHkg7jpLVcvGYYMpmyZgruDO8+0fYaG3g3LPF6KorAqchWt/FvZBRz7E/dTaCqkW51upV5zOf8y9/12H7lFuXQJ7sL7vd+v1MiN7fHbeXrj0wS4BnBXw7tYfnY50ztPZ1D9QZjMJpadXsb8Q/PVQsK10Ws5kHSAUI9Q8ox5pOansiFmA18O+LLUe6C2P2k/qyJXATCo/iBa+7cGLGnnZaeXAZZJnEb+PhKj2TJi4bcLv/Gf9v+xe39+OP0DkRmRTOkwRQ0Uk3KTeHrT05zPOI9Wo8WsmFkbvZbGPo1JykvCw9GD7MJs0grS2B6/XU2zH0w+yKNrHiW7KBsNGt7r/R7fnvwWNwc3DiYfJD4nXg1QOgd3Zk/CHuYdmqfWIxgVI+4O7uQU5TB923R1vogxzccQ6BZIA68GnE0/y7n0c/wR/QfzDs2jyFyEXqvHQevAxZyLXMy5qO7bXxf/Uvvbp3WaxnObnyM+J56fzvyEt5M3t4XchoPOcjL/8cyPAAwPH05MVgwhHiFqd0Tf0L6sPL+SX879wp9xf9q9D3fUv4MedXqoQYoGDQoKGjQ08mnE2fSzDAwbSN/QvjjpnEjOT+ZIyhEmbppIVmEW7g7uvNTpJRr5NOK7U99xIfOCemwAnmj9BE+2flLdnk5zpZ4m0DVQLUL96thX5BblUse9Dn1C+pT6vNT3qs/R1KPsTdzL6bTTbL24FbBkdkq6s8GdeDl50dy3dJGtraa1mqqfhSH1h1wzQLmRNBpNlRd/lsX6eRFV75YJUiZOnMjEiRPVdFFV2xCzge9OfcfUjlOJ8I0oc5njqcd5fefr3Nv4Xlr7t2Z99HrGthxLYm4il/Mv08KvRbknj5JS81O5mH2RNgFt7B5PzE1k2rZptPRryfmM82yL38bh5MO81fPKkLM9CXtIyU+hgVcDmvk242L2Rcb9MQ6j2cjCAQtJyElQAxSA709/T5+QPjy18SkyDBkYFSP3Nb6P6MxoJm+eTGRmZKlq/NT8VGbsnIFJMdGzTk/ic+K5kHmB7fHbGVx/MCdSTxDgGoC/qz9/XfyL6dun46h15OvBX9PCrwWfHvlUnT3xy2NfciTlCE46Jxy1jrzf531MZhPj/xjPkZQjgGUkgXUWRasV51fQrXY3PjvyGZGZkdzd8G7e7P6m2sYicxFv7XmLnKIcuysg6xd5WkEaXw/6GpNiQq/VE5cVx/v738ekmBgYNpBXdryCs86Zb+/4lia1mpBRkMETG56g0FzI3Nvm0je0L9mF2cw9OJdjqcdo7d9abePuhN18e+pbBtcfzMnLJ3HRu9Dct7ldMaht6jjTkMnrO15HQSEpL4mFxxYCMGPXDFr7t2bl+ZV8cuQTAGq71eZS7iX16j42O1Zd58nLJxm7fizz+80ntyiXZ/58Ri3Q9HLyorbblTT6d6e+U4OUNRfWkGHIwEXvQtuAtuqoA61GS25RLrP2zOKBJg/Q0r8lv0X+xqw9lq6PPGMeM7vPRKPRMHvvbM6mn8XX2ZfZvWYzadMkojKjeHmHJQtzX+P7OJZ6jH2J+9STO6COiAj1CGV2z9m09G9Jv9B+AOqwSGedM7N6zOL2erfzyJpHOJp6FLAM7by/yf14O3lz72/3qsfCUevI8EbDAdST/sKjC9XXAYxsPJI8Y56aHQj3Die9IJ3LBZfJN+YT4BpAz7o9iagVwfHLx9WMhb+LP893eB6TYmLrxa3oNDpGNx9NA2/77EH7wPasPL+SjbEbAehWuxuxWbEk5ibyYNMHCfcOx8PBg+yibCa1ncT8Q/PpFNSJZ9o9wzcnvmFS20k4653pENiBHZd28OqOV8kqzCLEI4Qlg5YQ4BoAwCf9P2Fvwl4Ghg1k1NpROOmdeLzF45RHo9HQpFYT9iXuY/EJy8iiOxvcWWaWwLpPtu9XG/82hPuEl7leaxHy1ei0Oh5o8gArzq/goYiHrrm8EFcjNSnFXvzrRdZGr+WeRvcwo9sM9fGL2RfJMGQQlx3Hi1tfVB+PqBXBqbRTtAtox9HUoxjNRpx0TrzU6SWcdE74u/rTJbhLqe0k5SZZ/oh/f4CkvCQW9FvAH9F/0MinEQ81fYgx68eoJ25b397xLa39W7Pr0i61L12v0bNy2Eq+PPal+kVc2602jjpHorOiea7dc/x6/leis6LVKzmwfFk39mnM2qi1dmPyX+r0Ev1C++Gid2H4r8NJyU/BUevIsiHLWBu1lq+Of8WgsEH0qtuL6dun4+HowZw+c/jxzI/8EfMHAD5OPnzc92MeW/cYZsXM2BZj+f7092p2AOD1rq9zLPUYv5z7BU9HTxQUsguzAfBz8SOjIEOdl6Aka1EmWEZrPL7+ype1Bg0jGo3gQuYFTl4+icFkoLZbbYrMRTzZ+km1uwEsw+8yDBmA5aT02/Df+CP6D7Vuwd3BncdbPM4PZ35Qh2tatfJvxdGUo3g4eJBrzLUbcTC2xViea/ccG2M3Mn3bdPrV68fUjlNZdHwRS04soY57HdIL0skz5qmjDnrU6cHZtLMk5yfzXLvneLzF48zaPYvl55bzSMQjbIzZiMFkYHrn6czcPZN0Qzp13evSKbgTv5z7pczjZP18vNnjTdz0bry+83XSDelMaT+F0c1Hsyl2E/E58RhMBrWrRoOGObfNYerWqeqQT4AZXWfQvU53Bv5vIGbFzPKhy2lSqwkv/PUC66LXqcutHr6a3y78xmdHyk5lP9/heR5r/pjdY5mGTL4+8TUDwwbSpFYTu/fVw9GDtSPWqtmqqMwoJm6aSFx2HA9HPMxLnV4CKFUD8WDTB5nSfgrOemfyivLYGLuR8xnnGRg2kPXR61l8fDEejh483+F5RjQawbv73mXpyaUAalBh68nWTzKxzcRS+xOXFccdK+5Qf19x1wq8nb3JKMhQT/I7L+3kUs4l7ml0D+cyzhHoGlgq+/Zn7J88t/k59ff/tv8vo1uMLvMYQvmFlLaswZ/V78N/L7NIckvcFp758xn19xc6vMCdDe+sdDGmEBUhhbN/w4GkA4xeNxoXvQvf3vEtDloHUvNTGbt+bJmT65Tk5uBmlwnQoOGhiIfYfWm3ZZKgZo+yLnodL2590a5P3nYoXpfgLuxO2G03eZD1ZNravzVLBy/l5e0v89uF39TtdKvdjd0JuzErZruhdh4OHvxx7x9sjtusDtNs7tucyIxIuxNQjzo9qOteV+231ml0jGo2isUnFhPkFsT8vvNpUqsJR1OO8vCah9XUf8niR1thnmFEZ0XTq24vFvRbwJ6EPUzbNg0PRw8uZF7AWedMgakADRo+v/1zYrNimblnJt1qd+OTfp+QU5TD7xd+Z/be2YClj7ttYFs+OfwJeo2emT1m4u3kzfb47XZfxK38WvHdEMsID9uhdhXxYscX2XVpF9vit5V6LsQjhMTcRIrMReg0Otbds46HVj+k1io08mmETqPjdNppACa2mciKcyu4lHsJsKTU0wrSyDRkMq/vPMI8w7iUc4lg92BG/DpCfa89HD3YMnKL2hWRXZiNh6MHReYijGYjLnoX4rLjGL12NMn5VwKnN7q9Qbh3OE9seILsomy8nbxp4tOEPYl77PajmW8zFg1cZFfYl16Qzqi1o9Q5J6xp+tb+relVtxfzDs3DUetIjzo9+DPuTzoGdWTRwEWApfvrlR2vsD1+O73r9mZ+v/kcSznGQ2uuXD1b56/QarRsuHeDmh24ll2XduHn4qd2H1plGjLZm7iXPnX7qCn2HfE7eHKjpetjcNhgZvWYVW763ayYSStIo5ZzLTWzcDrtNBP+mMCwRsOY1GYSXx3/isXHF5NvzKdDYAe+uP2LMtenKAq3/XQblwsuM77leJ5t92yF9q2s9Ty4+kFOXD6BXqNn430b8XXxva51WcVkxTBr9yyOXz5Or7q9mN1zdpnLxWbFMmTFEMAyCunrwRX/mxGisiRI+RsURWHEqhHqEEO9Rk8djzrEZMXg7eRNsFsw7QPbE5MVo57IHLWOFJoLCfUI5fs7v2fRsUV8dfwrtf/c1uMtHudM2hl12mEnnRNF5iK7q3CwpN/n3jaXPQl7iMqM4qVOL3Hfb/dRYCrg49s+Ztr2aeQW5fJs22f5+NDH6uv6hvTlufbP8cXRLziXfo6HIx5mRKMRgGW4qI+TD3U96vJ/u/6P/537Hw5aBz7p/wldgrtQYCxg8pbJHEk+onadmBUzY1qMYUp7y3wBZsXMwP8NJDE3EbAEVC56F3V2w2C3YO5scKfajQEwu+dshjQYoh5fg8nAkF+GkJyfjFaj5YUOL/BIM8sskcdTjxPuHa4W+GYaMrlr5V246l1ZNmQZ3k7eTN8+nd8v/F7qvbP21b/a5VVGNhkJQEZBBtO2TyPYLZhDyYc4n3Ge9oHtea3La9z9692ApWhybMuxvLHrDcsIiYI0jGYjPwz5gT2Je1hzYQ3d6nTjqdZP8cXRL/jy2Jdq4PXZkc9YcHgBjX0a890d3+Gsd+brE1/z/v731Xb5Ovui0WjUORRqu9VmzYg1dvNxvL7zdTUbUjKLV55vTnzDe/vfAyxB7J8j/8RB68DvF35n+rbpjGkxhnEtx/HtyW9ZG72WAmMBnYI6Mb3z9HJrm6yjOqze6vEWQxoM4bk/n2PLxS3q4x/0/sBuRIKiKJzPOE8d9zrquh9Z84iaDbRmL7rX6X7DigVNZhOLTyymkXcjeof0vq51lFW8rSjKNacFP5B0gFOXT/FA0wfQa6+/93xvwl6e2PAEd4Xfxf91+7/rXk9lmcwm2ixtA1gyZvc0vuembVv8+0iQ8jf9cPoHtS/eylHryNp71qpXgLb3XHij2xtoNVo6B3dWaxFS81PxcvTi1Z2vsiVuC31C+rD6wmrAMmOj0WzkydZP0qduH746bpkYqplvM9IK0kjMTbQ70VpZU7fWVHSwWzDr7lnHg6sf5OTlk4R7h7Nk0JIKFXEm5ibyzt53GNFoBD3r9rR7rmTa+csBX9oVnVmDLHcHd+5scCc6rY4nNzzJ/qT9TG43ma61u3L/7/er+7r1/q2lanT2Juzl57M/80izR9R6ifLkFuWq0zSDZTj0lC1T2Ba/DQVFDfC23m8p9vN28i4zDZ5RkMH2S9vpG9IXVwdXHl//OPsS9zG6+WgmtpnIgOUD1CmiG3g14Ndhv5Zah9FsZG3UWrrW7oqfix9FpiJ+v/A7ver2srvqte16mNpxKkFuQfxny38AeLbts6VGR8Vlx3HXirswKsZSx7s8OYU53L78dnKKcri/yf280uUV9bn0gnS8nLwqPUqh0FRIn5/6kF2Yjavelc0jN+Pq4EpWYRYf7v+QqMwoGno3ZHrn6dc8GW+I2cCULZbgdvXw1Zy4fIJOQZ3+dnbgny7TkImbg9vfCnaux/Kzy4nKjOI/7f9z07ct/l0kSPmbikxFfHn8S+q41+HzI58Tmx1banhmgbGAIb8MwagYWT18Ne6O7uWuz1o4+fTGp9XsSx33OqwdsRaNRkNSbhKLji/ikYhH8HD0ICkvSe2Xt5Wcl8zg/w1WJ66yZjjOpJ1h5fmVjG4+ukrmMDGYDPT+sTe5Rbm46F3Y/sD2a846aDAZOJB0gE5BndBpdAz63yAu5V66YVfOiqJgUkzEZsUyddtUWvm14tWur1ZqHVGZUfx89meeaPUEXk5e/HLuFxYcXkBDr4aMbzWejkEd/1YbN8du5lzGOcY0H4ODzoFZu2dxNPUon/X/DB9nn1LLr4teR0JOAqObj67wUMwfT//It6e+ZW7fuWUOB70e1qyOdVTV9TKZTUzZMgWtRssHfT6okffVEULcfBKkVKG47DjWRq3l4YiHS03Ok1aQhqIoFb4ytO0zr8ycFLa2XdzGzks7cXVwZVSzUTfspmXTtk3j9wu/07NOTz7p/0mlX2/NJMzpM4d+9frdgBaKGyWjIIPl55Zfcy4WIYS4HhKk1FBmxcyIX0cQmRnJwgELyxz1U1PEZMXw3r73eLL1k7Twa1Hp1yuKQoYho8yMgRBCiH8vCVJqsMTcRM5nnK9Rd2oVQgghbhaZFr8GC3ILqrLbsQshhBD/BlLRJoQQQogaSYIUIYQQQtRIEqQIIYQQokaSIEUIIYQQNZIEKUIIIYSokSRIEUIIIUSNJEGKEEIIIWokCVKEEEIIUSNJkCKEEEKIGkmCFCGEEELUSBKkCCGEEKJGkiBFCCGEEDWSBClCCCGEqJEkSBFCCCFEjSRBihBCCCFqJAlShBBCCFEjSZAihBBCiBpJghQhhBBC1EgSpAghhBCiRpIgRQghhBA1kgQpQgghhKiRbnqQEhcXR58+fWjWrBmtWrXi559/vtlNEEIIIcQtQH/TN6jXM2fOHNq0aUNiYiLt27fnjjvuwM3N7WY3RQghhBA12E0PUoKDgwkODgYgKCgIPz8/0tLSJEgRQgghhJ1Kd/ds3bqVoUOHUrt2bTQaDStXriy1zIIFCwgLC8PZ2ZnOnTuzd+/eMtd14MABTCYTISEhlW64EEIIIf7ZKh2k5Obm0rp1axYsWFDm8z/++CNTpkzh9ddf5+DBg7Ru3ZqBAweSnJxst1xaWhqjRo3iiy++uL6WCyGEEOIfTaMoinLdL9ZoWLFiBcOGDVMf69y5Mx07dmT+/PkAmM1mQkJCeOaZZ3jppZcAMBgM3H777YwfP55HH330qtswGAwYDAb196ysLEJCQsjMzMTT0/N6my6EEEKImygrKwsvL69Knb+rdHRPYWEhBw4coH///lc2oNXSv39/du3aBYCiKIwePZq+ffteM0ABePvtt/Hy8lJ/pGtICCGE+Heo0iAlNTUVk8lEYGCg3eOBgYEkJiYCsGPHDn788UdWrlxJmzZtaNOmDceOHSt3ndOmTSMzM1P9iYuLq8omCyGEEKKGuumje3r06IHZbK7w8k5OTjg5Od3AFgkhhBCiJqrSTIqfnx86nY6kpCS7x5OSkggKCqrKTQkhhBDiH65KgxRHR0fat2/Ppk2b1MfMZjObNm2ia9euVbkpIYQQQvzDVbq7Jycnh/Pnz6u/R0VFcfjwYWrVqkVoaChTpkzhscceo0OHDnTq1Ik5c+aQm5vLmDFjqrThQgghhPhnq3SQsn//fm677Tb19ylTpgDw2GOPsWTJEu6//35SUlJ47bXXSExMpE2bNqxbt65UMW1lLViwgAULFmAymf7WeoQQQghxa/hb86RUh+sZZy2EEEKI6lXt86QIIYQQQlQVCVKEEEIIUSNJkCKEEEKIGkmCFCGEEELUSLdMkLJgwQKaNWtGx44dq7spQgghhLgJZHSPEEIIIW44Gd0jhBBCiH8MCVKEEEIIUSNJkCKEEEKIGkmCFCGEEELUSBKkCCGEEKJGumWCFBmCLIQQQvy7yBBkIYQQQtxwMgRZCCGEEP8YEqQIIYQQokaSIEUIIYQQNZIEKUIIIYSokSRIEUIIIUSNJEGKEEIIIWokCVKEEEKIfzlFUXhs0V5Gfr4Lk7nmzExyywQpMpmbuFWcSsjip/1x3GJTEAkh/sWSsgz8dTaFvVFpxKfnV3dzVPrqbkBFTZw4kYkTJ6qTwQhRUw2euw0AP3dH+jYNrObWCCHEtUWm5Kj/v5ieR6ivazW25opbJpMixK3ANntyNinnKkuKxMwC/jydJBknIa7CYDTx/d5Y0nMLb+h2zidf+b6KS8+7oduqDAlShKhCWflG9f9uTrdMorJavPTLUR5fsp990enV3RQhaqylu2KY9ssxZq05dd3rKDSaeeO3k2w+nVzuMvaZlJrT3SNBihBV6FLmlT9uo8lcjS2p+aJTc+3+rcnMZoWXVxzjs78iq7spKIpCXqHx2guKGud6soaH4jIA2Ho2hQWbz3PPpzvJyKtcVmX9iUQW7Yji1V+Pl7uMbSZFghQh/qEuZVz5484uKP9EYjSZOXoxo0ZV0d9sl4vT16m5hmpuybUdvpjBd3timb32NNkFRdXalhmrTtDi9fW89L+jZOZXb1tExRy7mMkdc7dx94Idlb54OXUpC4DkbAMf/HGGAzHprDmWWKl1HIixZCsvpueTnF1Q5jK2mZS4NOnuEZVgvkVPZJ9sOc+MVSf+VTUHlzKvfAHkGMoPUr7YdoG75u9g2d7YSq3/QkoOBqPputtXVU5cymTPhcvX/fpCo1kN4i7nlH1VeCAmnUXboyr1+YlLy2P+n+eq/OSdaPO+Wr/wq8tfZ1MwK/DDvjjeW3+6WttyNVkFRSzYfJ7YyzXnhHej7YtOY/w3++1O8mcSsxnx6Q5OJmRx9GIm55JL16qZzQoLNp/n653Rdo/nFRqJunwl02g9Few4n1qpdh2MvfKZPRybUer5rIIikrKuXCxIJkVUWEq2gU5vbeT1q6TpaiKzWeHDP86yZGe03Yn7Zsg1GFl/IpH8wqo5ma85lsCjX+0hJdvyR3y1gk/7TIr9ibKgyMSEb/bz1fYoLqRYvnjOJGapz+cYjIxZvJef9seV2Y4DMWn0/eAvXllh/1m42UFgQZGJIR9v5/4vdqtXZS8uP8LjS/ZVOIBKt0lXX86xHFejyUzs5TwKjZYrzem/HOON30/afcFarTh0ka93RnPa5vgBzP/zPO//cZZvd8dc176VJ8bmRLsvOq1K111SfqGJUwlZ5T5/2aaAcm/UjW3L3/HTvjjeW3+G9/44U91NqTCTWWHDySTeXnuKTaeSSj1/JC6Dh7/cXeb7YzCa+M+Ph9lwMsmuW3D1sQSKTFf+Rk8lZFFkMnP3gh0MnbedgiIT8/48z3vrz/D6qhPsLA5AkrMLOBCTTll/3jsjUyt88ZpXaOTEpSvttXYf2bJ+H7k46ABIyi6oERdDcAsNQf63OhibTmpOIRtPJfN/d1f8dZ9sOU9BoYn/3N4YjUZT6e3+vD+O0FqudG7gS47ByD2f7KRrQ19m3NW8Qq/PNhgxFv8RpecWUsfbpdJtuF5fbL3A3E3nmH5HUyb0aljucgVFJpz02msenyU7otkbncbm08k0CnRn+Cc7AVg2vjPdGvrZLZtgE6RklejuWbIzmj9OJvHHySQGNrcMTbYGPgC7Ii+z+UwK8Rn5jOwQQn6hiY82nqWhvxvD29blVEI2AGeTstXXFBrNDFuwAxdHHT8/0RWttvx9Sc4q4LO/LvBwl1Aa+rtfdZ/PJWVzKbOA3o39Sz1nm0mIS8vDw8mBn/ZfBCxFfuN6NrjqusE+e2I96U7+8TC/H03AUa9lzv1tiC2+Go25nEf7erXU5Q/HZfCfH48AoNNq2PbibdQu/nxFF191Hr2Ycc02VEZs2pWr2RsdGMxac5Jvd8cyY2gzRnevb/dcXqHRrhsxMiWXgiITzsUnl5rkQnGt0aEygsyqkpCZb5mArEMIA5sH8efpZB7qHIqD7vquv7/bE8Nrv54A4JudMRx89XZcHK8c27sX7ADg9VUnmDmsBQk2fyPf7Y5VMxDrTySRlGUgI69Q/R501GkpNJk5lZCFj6sjR4qDhWe/P8QfJ68ERC8sP4qbk85udKCPqwPpeUVoNOCk15KeV8TJhCzq+7mx+lgC+6PTCPJ0pkNYLfw9nFh/IpEtZ1K4rUkAnerXsutWtr4fCZn51HJzxEmv448Tlu6j9vV8OBCTTn6RiUsZBdT3c7uu41iVJEip4ZKzLFeqKTkGFEWpUMCRYzDy7jrL1UvTYE/uaBlcqW1Gp+bywvKjBHo6sWd6f5bvj+NMUjZnkrIrHKRk2aTbs25yv7n1JB57lX7VCyk5DJ67jQc7hV5zn1KKr/Qvpufx9torFfaxl/PoViIGupRxJWtUsiZln83JzToKyDZISSuuzbCewLeeS+GLrRcAWLQ9msEtgwDsujJ2XbjMyeKrusu5hfi6OfLM94eo5ebIm8Na2G3/p/1xLNoRRX6RkbdHtLrqPk9YeoCo1Fy2vnBbqfkStp5LUf+fmGkg0PPKPny5LYpHutS75kkzzSYbkFq8v/uLR/kUGs38tD+O/CLLldyljHzeX3+GFnU8GdQimNM2V7Ems8LhuAw1SLEWLtteOVYF28/SkbjMCgcGey5cJiGzgGFt61R4W9/utnQBzvjtJMPb1sXL1UF9ztrt5Oaow9lBx+XcQk4nZtMmxPuq60zJNrDlTDIDWwTh6exw1WX/juyCIh79ai+Bnk7kFWcyL6bnk5ZbSC03x+ta3ysrj9O1gS8PdAot9fyqw5c4m5TDD/viWHXkEkcvZhKVmnvNv+mdkal4uTjQvLb9nFu7Iq90YeYXmdh2LoUBzS1/d4dtMhBxaXk8tmgvCZkFvDioCZ3r+/LhhrPq86k5BjaWyMSM6R7G51svcCohm2Sbv3trgHJ/hxD+OJlIfEbprpb7OoRQZDJTx9uFHedT2XwmhT9PJ7PxVBJHL2aWu5+H4zLo0sAS4DcOdOdsUg5H4jL5bk8Mr648zh0tg3ltaDMW74gG4NGu9UjKKuBccg5xaXk1Iki5Zbp7/q0zzlo/zIVGM9lXqXGwZTue/v0/zlS6UMvaZZGUZaCgyESCTXeNyaxQaDTznx8Pl9stAfYn0qybXGhobX9GXvnbnb32NAajmSUl+oDLYg0kDl/MJN1mnRllBF/xV+nuOZ14JQNi7e6wBkAAabmW5dPyCjGZFTVABTiTlK2eeG23u+XMlSGFiZkFxKXnsfpYAkt3x6jdJlbW9zHxGt1vBUUmooqvgmPSSo+82Xr2Sn94Qma+XVYkMauA9SeuXdR32aZY9nKOAaPJbFfQd8im33z1sUTmbz7PyyuOoyiKXR89oKbeTWaFhOIg8WJ6PplXef8ry7a7p9BkVq+CrybHYOT+L3Yz+cfDV+2+KcnDZuj6J3+dt3susfgzEejlTLPanoClPqigyMSKQxfLfG//OJFI17c38cLyo3y25caOTnp33RkOx2Ww/oT9ydOa2TIYTUxdfpQFm8+XswZ7n2yJ5NfDl3h55fFSXXsA+4uzejGXc9XtXe1vutBo5vmfj/DQwj3c8+lOkrLsj5d1HRHBlmNrm+FYvCNK/X9CZoH69/TuujPc8+lOcgxGujbw5a7WtUtt19/DSb1Y3B+Txh8nLOu1vtc9wv2YNbwFnz7Snse61mPeg21ZNq6z+vqIYA9eH9qccT0b0L+ZJQv70cazHL2YiYeznid7N+Te9nXxc3fEUaelf0QAA4qX233BcnH0ZO+G+Lk7kV9k4uUVxzEr8PvRBD7ZHEl+kYk2Id4MaBZISC3LRUlNqUu5ZYKUiRMncvLkSfbt23fDtqEoSo0bNppsU8xke9V9Nbb9/RdScktF9NeSahPkJGQW2J1IM/OLOBibzopD8czdeK7cddhmT272CIT44hPV1bZru09XU1BkUgtgT16yv2JJLzEM0GRW7L70cmwyKVkFRXYBjPX/yVkGtabEuj5Fsfw/tURBaWRxwV1WfpHaH20778GlzHy7gDIj3/711s/P5dxCVh6KZ8pPh8vsd7atq0ktcZySswvsTriJmQV2WRGA6NRrF0raviYtt5CkbAO2Xey2790pm0xRak4hUcX95w2Kr/JOFgdvKdkGNbUOcCKh/CvMyig0mtVj0i7UGyi7X7+k349cUv9/7CpXu7ZyDEa7i5Etp1Psnrd+voI8ndUswIlLWXyzK5r//HiE7u/8yS8HL9q95s3VJ9XjUhVFv0fiMvh0SySf/RVJQdGVz8+JS5YrdCvb99C6/wu3XuDH/ZZaFWsXg8Fo4tvdMXyy5bxdnUVSVoEaGJjMCtN/OYaiKJxJzCa7oAhFUdT9sa35AOwCfFtLd8ew/IDl+BQUmfnUJmhLyTYQn5GPRgP/vb0xAJtOJWE0mYlOzeX3owllrlNX3MXap4k/X43uwPB2lqxZaC1XHHSW57o08KVJkAdajWW7+UUm6vm68s3YTjzZuyELHmqHXqelSwNf/u/uFgxtXZtu4X588nA77m1fl8EtrmTD7+8QQttQb7VWZeqgprw0uCnv39eavdP7c+KNgXz5WEfmPNCGxoHuOOq0vHl3c4a3rcOnj7TD38PJrv3W+q1Jt4Wj0WgY0jKYZ/qG06KOZ5n7e7NJd0+xV1Ye46f9F5k2uCljSvQDV6ckm6vL1GwDDfzcyuzy+Wp7FNkFRUzu39juah/gZEI2g1pUvMvnss2JKSEz324ei7TcQjVTk5xdgNmsqHUQMZdzycgronWIt90X1N8JUiJTcjgQk86wNnVw1F87pjYYTeqJtWQQYavkydfWsj2xrDh0kQc6htIhzMfmNfbry8i136+SJ0nb7p6SJwfrc4biDJmns4PdiftyTqFdtgEgprjLwaxYan4u5xiItrnCT8wssDtppOcWEeDhfKV9xfucmm3gww1niU3LY3CLYG5vZj91v20wlZptv88HS+xHQlaBXSEnXLnavxrbfTWaFbWA2NpvX55zSdlq3ckdLYOZv/m8GsSUTJOfvJRVqmboelzKyMesgLODlgHNgzgYm1HmCImSfrTJNJ4sJ5OyNyqNvEIjfZoEAJS6Z8q55GzyCo24Olq+qhMzLe+hJUixZlKy1AyKyaww7Zdj3NEyGGcHHUUms906T1zKwmA0odVoKlW3kZJt4EJKDq1DvHlw4W61Kyctt5Dpd0QA8NW2KMqr5TxyMZOYy7nM+/NKBmX6imM0CfJgzOJ9av1KfV83zAo0DHBjxcF4CorMNAv2JCo1l4OxlmHgr6w8TpsQb96/r1WpANnqj5NJPNAxhEsZBYT6uqoXAr8ejgdgcIsg1h5P5Ls9MZxLzqaBnzvuzpZjHO7vTp8m/modyK4Ll/n18CVMZoXbmviTW2hS65Ke7deICb0aYDSZ8Xa1dGfd1iSAxWM60izYk/fXn+HnAxfpHxGAs4MOB50WQ3GGc8rtjWkb6kPbUJ+SzVfd0TK4VHe9Xqflo5FtuOfTnTQMcOdBm24wrVaDFsv3saujnlWTelBkMuNR3MXXMawW6yf3Yl90Gj/vv8jGU0kYzQruTnp6Nrb8rdzTvm657akOEqQU02u1FBrNFc5W3Cy2mZQvtl5gwtIDTLotnHE966vBSkGRiVmrT2JWYGSHkFIT/diOf68I2/R9QkaB3ZC59LxCNegoMimk5xXi626JzB9auIekrAJ2vtT3uoKU2Mt56HUatb4A4KX/HWVfdDpfbYti2h1N6VS/lvqFXRbbdHdGXhGLd0RxOiGbt0e0RKvVsDcqjbRcg93J1zbQWrQ9ijd+PwnAvuh0ejYq/yRXMggqOf+AbXdPyZO7rZRsA57ODnbddJdzDaWG5toWv2XlF7GnRAFnQmaBWsdRVvusgZltsHU2KbtUkGKb5i0ZzJ0rLuZzcdCRX2QqzqSUyLZUIEgpGdgcj7ecxJvX8eREfFa5gcqpxGw1MBvUIoj5m89zKbOAjLxCuwwQXKlLuZxj4KlvDxLs7cxTfRri5qjn6e8O0iHMh8aBHsRczmNy/0bl1phYg8PQWq60La79OBR39YzE6cQsuy4ra7Zn5/lULucWMrR1bXZGpvLQwj3otBp2vtSXQE9nLhZPR96ijicp2QaSsgycvJSFl4sDD325R+3CCvRypkUdSyblVEIWzjYBvMFoZlfkZQqKTIT5WU76eq0GnVZDjsFI3/f/IjXHwLP9GvFk74ZqJqA8i3dE8faa0xSazDzVp6EaoIDl72Voq9qE1HLh92OWTEOvxv5sPWvJAGk0lszgnguXGb14Hwajmc71a3E5t5DzyTk8tHCPXXD52qoTpGQbCPJ0Vi9Knukbzg/74vjrbArvF48UOhyXwRu/lz8D66IdUfx6OJ590ek83r0+uy9cJqk4oNZq4M1hLcgqKGLH+cvqj1Wrut7odVrubFWbpbtjmLPxnFqP8lz/xvx6OF4NUjqF1cK9jJmlbysOOmcOb8GDnUPVz82DnUJZsjOa5wc05u42Fa9TKinMz41d0/qh12quWizv7KAr9bmu5ebIwOZBJGUVqFn2Pk38cdLXvOJrkCBFZU2BJde0IMXmxLepOLU/a80psguKmDKgCWA5OVnPX1GpuerJztlBS0GRWR1eVlG2V/BHL2bYZQTScgvtaiKSsgz4ujuRmXelO+P4pUy7OhTbqeLLk19oYsjH23B21LF7Wj90Wg2KoqhTpp9Jymb04n00DfJg3eRe5a7H9gsvM6+ID/44S47ByCNd6tG8ticjP99V6jU5hZZMRn6hidlrLfNOdAzzYV90OtvOlZ6PoI63C/EZ+aVqXqxBRbCXMwmZBeQWmjCZFXRajV1NQ0kp2QYa+ruTllcik1K8PjdHHbklhlNn5BWVWmdiZn6JTMqV9SmKogbgtgGAbZ2M1UWb+3aU7BY7Xxzw9mjkx4aTSSRk5KsBRwM/Ny6k5pbKpJjNCs/8cAgnnZYPRrZGo9GQllMySLF0B9T2diEtt7Dc47X1bAqFRjMOOg1NgzzU9+JUQrYapNRycyQtt1AdurzuRCJ7i4cN/3Eiif8OaMyx+EyOxV/pgmng58bIjiFlbtOaSQyt5UbLul7otBqSsgwkZOYT7FX2qDVrxqChvxuRKbnFc2Rk8NCXe4ofd+eZZYeAK8W/A5sHqQFiXW9XgjxdSMqy1HYcjE23u4AK8nQmzNeVMF9XoouHbTs7aBnUPIiVhy8xZomlW9zaPRVSyxVvVwcOxWaofyPvrT+Dm6Ou1AgiW2eTsvm/306qvy/bYynq7VS/Fp7OejaeSmbo/O3UcnOk0GimaZAHD3QMUYOUzvVrkZVv5GRCFtkGI7W9nPno/jYcjsvg6e8Oqm15rl8j5m46d2WYf/FnyFGvpVdjf84m5fDX2RS7vznrNmyzb70b+3M6MYsLKbnq994im1oSgG4N/fBzd2LhqA4ciEknKcvAb0cu8Vfx+lqHWIK/kR1CWLo7Rs2C9o8IpE2Itzozq16roV0973KPHYCTXkc7m0zJ9DsiGNM9jHq+f78gtSKZ5avp0sBX/f/A4uLgmuiWqUm50WpikGI0mUtdcVr9cihe/b/tFeSF1Fy1u6d9Pcsfx4WUnEpNCGd7pV3yJJ2eW2iXGbEGUbF2kxflVDqTEpuWR7bBSEq2Qb16tz1B3tEyCK3GclJNyCy/oMtudI3BqNaTXEzPI6GcK3xr/UxUai6FJjPerg68e2/rcrfRNMgDKD9TEWbzBWStSyl5lW/L+sVsl0nJMagzsTYu3p6tzPwidcIo68iOS5kFdpkk226/HIORgqLS2YmzZQQp8XaZFPt9tH5BWzNMSdkGtf0Rxd0PJYsR49LzWH00gV8OxXM+OYePNpzlQIlhqdasR7Cn81WHq1tPJKG1XNHrtGqB48mELPUYD20VjIPOEhhGp+baBen5RSY1ILI7Dkmlj4OVtTi5ZR0vXB31NAm0vB+HyunyOZOYzZrirMLcB9riqNeSYzBy32dXAuTlBy7a/W1bC0utAWJdHxda1bWcLI/FZ5bqmgn0dEaj0XBnqytFmq3qenNb0wC75Q4Wt7GOtwst65S+e/zmMymlHrNVstDW+rfcONCdmcNa0q2hL1rNle67h7vUo4nN57WBvzvLn+rKqK71aF/Ph6XjOlPb24VBzYNo6G/5Owmp5cIzfcNpXcYIpe4NfXFz0pcbDLSs48UTva8Mee9UvxafPNweB50GvVajFrFGBHuqc4Dc3cbymKujnp6N/Lm3fV0+f7Q9XRv44qTXqkOKW9TxVP/WnR20vD60GWApcvVw1jOwRdBVs7plcdRrqyRAqQqNAtxpWceLuj4upT43NYkEKcUCrEGKzRes2azwvwMXq+3eIqk5hWVO5AOWjIF1sjLbE2B0aq7a3dOyjjeOxX2gZQ1rK49tTcqFEvuelldodzVj7Y6yDVLOJWVXOEjJzCtiZ2Qq8RlXXm/dH+vJJbSWK5883J6mQZYT0tXqAcoLBuIz8tWCy5KsmSLriJb6fm7U9XFBX04atbEapJTIpOReyaQ4FV/lWDNK1nb5uZcehmlb0Gq7LmsmxXpStJWRX6ge8871LUMMEzML7AIx2yCqZLBhFZmSU2oUkF13j03QbjYr6nvStYEvOq0Gk9lSyAjQrDhgSM0ppMgmWxNl8xma8tMRuytm7+LhtdbPZ5CXs113X3msQyOtJ69t51LUgulGgR50KJ5XZeu5FC6U6O60zjfzTN9wZg1voR6HsqTnFqqB+pBWltqAtsXZif3l3Bjx290xKAoMah5EizpeaoGvwWibwbKvUbGOKrEe+zo+LrQsDlKOXswo1YUb5GWpNbqz9ZV6hfb1fOgeXnb3ZB1vF7vg4f+Kh+gejE23u4ApKDKp9wWKS8vj1+Li3/kPtbVbX5NAD4K8nFk2vguHXhvAZ4+05+0RLXmoUyhhvm7q5z+0liuujnreuLsF/3uqmzo/j1ar4dU7m+Hv4cT0wRHodVqe7RtOgIcT/+nfWN3O7c0sV/htQryxluJ5uThw4JX+/PVCH357pofdXD4N/d1oX8+HdZN7sW5yLz5+sC1//rc3qyZ1Z/WzPXh7REtGtCtdc+HsoOO7cZ05+OrtahCh0Wh4qo9ljoGpg5qqo16CvJw58MrtfPxA21LruZVoNBpWPN2NP//bp8wuq5pCgpRi1gJD25TqjshU/vvzEV5ZWT2zvZa8IgXwdNbj7eqAolz5YrUd0RFtk0nxc3ekXvEcFyWDjZK+3R3D9BXHmLfp3FWzSem5hXYjd6xttMukJGWTadPFc7Ug5bVVx3lo4R41jWy7P9YTYoPiKy7rycHaP5yWW0huiWHZ5WVZLqbncyG17BORdX+sJ7MGfu446LSl5gexsl5dZeYX2s32ag3ufN0d8SguwssxGCkymdX0tfXK31ZKjoEik9muWy0hs8DmqrWMICWvSD3mnWyClESb/bfNzJRXa2U0K3ZBBJRfk3IpM5/8IhMOOg31/dzUwN6aBQkPcFdHMyRnG1h9NIGHFu62m6H1WIksRuMA+30L9nKxC1Ksc2tYPwNW1pPd7RGWepqd5y9zLtkSfNTxdqFX8YnrrzMppfbP+nfj5+6krudsUg6jFu3liaX77d7T1ccSMJoVIoI9CQ+wLGvNIq0/kVhmhtKa7bm3uADR+v54OOkJ9LQcM2uxr7U75lh8JoqiXOnu8XFVMx8XUnNLTaUe5Gn5vmoS6KF+Hrs28MXP3YkO9UoXYtbxcaFPkwCcHbR0DPPh4c6huDjoyC4wql140am5tH1jA81eW8+Qj7fx8srjmMwKPRv5MaRlsPqZBksgaOXl4sCgFkE82CkUXXHtS9Piz/nV5tno0ySAfS/3Z3BxYWi/iED2vtyf5/o3ok8Tf/w9nNRJDz2cHdTPSscwH3zdndRgooHNxITW97Ohv7v6fjXwt/w9N/B3V9tYFq1WU+rO5Xe3qcPpNweVGkzhqNdes5bnVqDXaf92t9GNVrNbdxMFFH95pOVduQq09ouX/JK7WcoKFhoGuBNe/Ido/bK1zR5EXc5Vr6C9XR3VP9rIMu4XYRVzOZdXVh5n2Z5YPthwtszx8YNbWK5o0nKL7Ia2JqndPVeO0fnkHLsrv6vNk2KdVdH6xW67P1HFQYX1i87arXEoNoO4tDx6vbuZBxfutjupxGeU3aUTn5Ffbm1OVolMivWE2KCcL1hr0FBkUricW6gO47VmK/zcndRq+uwCI0lZlpohR522zHWmZBtKdR1ZT0o6rabUCRosV7nWIKZDvVpoNJZaE9v7b9hmeq42msn2qr7QaLYbUXY5t1A9EVu7esJ83dDrtOrVvJWfu6Ma7Een5jJx2UF2Rl5mweby5+ZoFGg/822QlzN1vK+s1/qeRwR50qM4S3BbE39Gdw8DLIFRmK8rhSaz+vdax8dFvbr+62yKWmhrPXlbR2DZBinxGflsPZvC+hNJHIvPpNBoZsQnO9QLlDtbXclY9GkSgJujjviMfA7FWTIRB2LSyDUYiU7NJTYtDwedhq4NLX3+k/s35p17WrJt6m1qQaX1venRyB9HnZaMvCLi0vLtunv83J3wc3dCUUrPWWHNyGk0Gr54tAOfPtxODZ4+e7Q9v03qYRcg1PF2oY63C9un9uXrxzuh12nVY2vNCK0+lqAWXp+4lMXWsyloNTBtcAQajYYWNhOflRU423rjrub89/bG9L3OboRFj3Vk7/R+alE+QO8mlve0ZNeEj6sDtzcLpFP9Wjdk8rGaOJvvv0nNzfHcZLVcHdX09eWcQoK8nNUv9qQs+6G2N8LP++NYezyRZsGejO1RHx83RzVLEeDhpAYsDf3d0Ws17I9JVwMP23vjxF7Ow7m4StvH1UE9wV1thM+1Cmt9XB3o1diftccT7Ub3AGw+ncITS/erozPAkta27fcva8ZZ6+y51n20nefAWldyJZNiOZFYh+odjc9g6e4YcgxGjl7MZH9MOh3DahW/tvxMijXd/mzfcAB2RF7mQEy62r5Ia5BS/EVX3hdePV9XnPSWbrQOMzfi4aRnbM/66r74ujupV53ZBUVcyrC8LtjbGS/Xsrt70ksMZ7bWitRyc8TP5ovaypqR8HN3wsvVAT93p1LZEtvAp6xMilZjGc58KDZDHWmQkJmPoqDun8mssHR3DF0b+qpBivUKtbaXC4fIUNdXy82JQE8n4jPymfdn+XPo2BrYPIjvbLJowV7OaneDVgNDWwez+UwyvRr7MbR1bXIKjAR4XgliNBoN/SIC+Wq7pTiyU1gtGhW3z1rADJbi4+a1PdXJvyzHzhE/d0c8nfV2tzBYezwRg9Gs1nM0r+3JyA5XimqdHXQMaB7EikPx/HYkgRWH4vl2dyzuTnr1qrR9PR/1qryWmyP3d7QME/Ut0d1Xx9uZiGAPjlzM5Otd0aTnFaHTatSuhSZB7qSev/LeLR7dES9XB/Q2NSqhvq52WT9rcNM40F0NvOv4uKjPWbWv58OuC5a/gYc6h7K9uFtrbI/6rDmWQEJmAfd3DFUnjWtRx5NdFy7j5+50zdljW4d4l1ljUlFlfddOub0xtzUJULs3rTQaDQtHdbjubYmaTYKUYlqtBj93R5KyDCRnFxDk5ax+sRvNlivmkpPgVKV31p0mNaeQP08ns+18KsGezqwrnuyoeW1PkosL3Br6X0mpny8jk2I0K+qcDN6ujmradWfk5XKn1S9r+njbivkWdbzUL6W0XPualPiMfLt6F71Wg9Gs2F3FZ+YX2W375KUshn+yg3E965c5z4Fak1L8BdvQ70pmw3pCsU4XD5YbmXUMq6XeoA4sAYZtBiw+PU8dEtyjkT+d6tcictlBwJLpURTlSnePv7vdvwC1vZy5lFmAh5MeV0c9Pq6OahdOtsHIHJuJ7Wy7e7ILjGomqbaXC14uV6Ykd9Rbhr1HpuSUCiKsV7S+1whSQmtZTj7BNp9Xq2sFKXe2qs2qI5dYtjeWx7vXJ9TXVR3tE+brRlJ2ARl5Rby+6gSNAtzVK29rkNIhzIfVx65McFXLzVHNrlhnuSzL8LZ18Pdwwt/diV6N/bmtib9awOnv4YReq8FBp6GBnzvD29ZlQLMg9YRfVqHi0Na1+Wp7FCG1XPj0kXbq52xo69rq56SBv7tdcAPg5+GERqOhYYC7XRHsuuOJ6tTxg5oH8dmj7cvYZnBxcBKjZmZyDEYoPsy9yrjnEYCvm/176efuRO/G/hy5mKkGWne0DFZrBBoHeqjDY31cHSpV4Ngk0IP1xTObllWM3LF+LdhsuYHmfR3qqqNYHu4cypjuYaw7nshDna/MwdGpvi8Lt0Wp3a43m7ODTs1OiX8P6e6xYU1VW4tBbVPk15pK/O/IKihSuwocdBqOxGWoAQpgNyFVeIA7DYtPEueTc1AURb2pnaujfVrS29WBvk0DcHHQqZMhlcWaJrfe4wEsJ1pr8dvIDiFqkFIyk1KS7bA2qyKTYjd/x29HL2Ewmu2uoG0lZOZTZDKrwVP94myQVqtheBn3QFl9LIELKTnEpOVRaDLj4qBTJ7qyyiowqilza3bJ06ZL5nJuIdkFRjQa1Doe20yKtZbEv7hb0Nvmfiol+bk5qScZSybF8tmp4+OCp02/fs9wP7xdHbiYns/cTZb7fviWuEIt76rVWr8SWnzFbe1GsJVRRnePbcDzUOdQuof7Umg0M/LzXUz56TDbiu/L0yHMx27Zc8k56pwK1iGVJVP5ns56Am0CgZLFeNbgpk8Tf6bfEcH4XpZRGR/d34a2od6M7FAXB52WAE9n1jzbk2+LpwUvWSdQUpsQb1Y/24Pfn+lp1z1gOz15oKclKLJl3T/bmy066DREpeaqM6faTuZnq3fjAIa0DFYDlPE96/PR/a3Vv5l+TQPLfJ1fiQsdfw8nxvdqYFdQPb7nlfoH26LpihQU27IWeGs1lOqaA8solZ6N/MgvMvHAF7spLL43jKVw3JVxPRvYBYX9IwL4clQHZpW4J5QQN9ItE6TcjHv3BJQYhmx79XkuOZtt51JKTZtvNJl56tsD/OfHw3a1EZVhvfr3c3diyZhOOOq1hNRy4avHOrBodAdGdatHkKczGo2laNNakxKdmmcpHi0e5VMyDerj6oi7k16tJ/lfiemy1e0XBwMDml0ZK59rMPLrpO7Mf6gtQ1vXxqe4myI121Dqxnm2+keUfaVnO1fK/uJCyvLurXMps4A/TiRhMit4uzqoRYIArw1tzoOdLKn3/97emKZBHuQVmhg0dxtfF9+zIzzAvdx0tKezXg0EPF30xW0rUrsy6ni7qH3QtrUgTYMtX/iBxYGsj023TcmTsSWTUhwAGYxqpqm2twueNpmUOj4uvDSoKYA6H4z1RG67Lke9Vg1urFk0K2uQ8my/RrQuHg1iTbOnlVE4GxF85aQX7OXMa3c2x0mvJTGrgF8OxvP9XsssqZ3q18LZwf7rIT2vCL1WoxaClhxKqdFo7O478/KQCLX408VBx1ePdeC9e1uVureJt6sjK57ubjfsu1GgR6Uyl81re9llqSyPXQlU3Z30dutz1F05ptYgpWmQhxp4WQPa9mUUoYKlVmj+Q22Zc38bJvdvxPMDmzC8bV02/Kc3Pz3R1W4kjS2/MoJQD2cHXhpsmbW1R7gfrep6q8/bDj+v7J3EW9f1RqfV0DjQo8zZZXVaDZ8+0l793Fi3X95NTDUaDf2bBZbKSAlxI90y3T0TJ05k4sSJZGVl4eVVerx/VbAWz1q/0G2HbU75yXJr+Pb1fJhzfxu1z3jt8UTWHrdkPSb0amA3esO2jiUl20BUaq76BW997PO/ItXthvm60j3cj93T+uHhrLf7Ylk4qgMpOQWE1HLFbFbUbo83i2dHreXmSJ8mAWraXKNB/dK+p31dfjkUz29HLvHKkIhSKXNr0WtDmxNkVoGRpkGe6rBf60m/5KRittwcdbQr8aVurW3IzC8iyMuZgiITR+Kufh+TlGwDHxTPLDm6W5jdl6ZOq+HtEa14pm8jgjyd/7+98w6Pqkr/+HcmyUwSUob0BNIDgVACBAihSYnSVEBRVFRERRGwofwUXcXdVXF1LeuKfRU7igIqTSnSNHRCJxB6C4FAek/O7493ztx7p6RAOu/neeaZcsuce+bOPd/7toOxPdrg6QW7sOnoJXyZQne/7QI9YFINWDLWCKDBTO5PWlKyCkrxrxVUxE0dHBjg6Yonk9tDrwN6Rvjg/bVHLHfWakvKLT3aWL5b9pXa3SPrjrQxuWpmoPV0dcbtPUPxw7ZTFitXhG8r7DyVbUkLlv3u52FEbnE5Qn3cNTFEEWZrj5Neh+8fTsKyPefQIcgLI9/dgNziMpRXVMLZSW8RSh2DvSwptYFernB1ccK6mYPx4bojmPfXcUs/JUb64vH5qTa/Tfcwk8ayIWdWlcgB1uCsx+09Q3HgXC72nMlBhF8rhPu2atAaETqdDp/e2xNz16ZjxvWxmgBuPw+D5TwY0z0Eaw6exwP9IxHk7WZxkQCwmSXXev/Wsxtbx4dY42tlzZExKuMS2iLav5XGxQjAEl8D1N6SEurjjl+m97PrLpR4GJ3xw5QkfLzuKNYduoAHVVYchmkKNBtLSkMgzcGyQJm9jIjtJy7jujf+wL2fbcHcP9Lx9IJdlmVrVJO9zVl+AN3/udIyQ+ntH6Xg9o9SsGKv4sZ5fcVBfLrxGF5dRgOkvID7tDLY3Pl0aeuNIWYTsl6vwyzzfBmLU6mOQbC3q8YPLstgA5SaGObjjrzicizeqUx6dvRCPhZsO2Vx94T7uFsuaNbWAW83F6hvsDyMzpa75p8eScI/RnfCt5P72KTYBpvNzNJFtPdMjsOS5+r6IkcvFsDT6IxJfe1fNENMbtDrdWjb2h1/GxWnWdY+0FMToNpK5QZ7flRHy2tp1Vi08wx2nsyGl6uzZR4SyePJ7fDo0HZIivbFzheuxwzzxGPqwEV13QV3A5Whlv2490yOJWg5xKSNSfF0dYFer8MrY7tYfisfDwOmmmszAIr7Rw5m6uwgFyedxs3j6uKEW3q0RXtzxowQ1O+XC0qRZi5WNrJLMHQ6yh6RFqMgb1dMuS4aMlYx3NcdQd6ueG4kWXl6qVwe1nU43hnfHa4uetzXNwIAuYDen9ADfz07BE56ncUyYW3layiS4wKxaGo/hPm6aywpardLsLcbFkzpi+Gdg9Et1GRZz8vVuc7TM9VuHW83F00p8u5hrW2sQZ6uLhYLSm0tKQCJrMBqLB9GZyc8OrQdfnykrya1mGGaAixSVPib/8y/7cvAuZwizRwVku5hJlQKKsn8xm9pmgJNapHy0bqjyCkqw12fbMbbqw5Zgjjf+O0gyisqUVxWYbHASCKquAOz5o5eoRa3h5erM+7pE67ZXp0to9frcG9SOABg3l/HIIRAcVkF7vnfFsz8cTdKyiuh15H74Yv7e6FHmAkf36sNFnTS6zTWCW83Fyye3g8/PJyEhHAf3JsUgfhQE1yc9Jp6CvKim1NUhoU7TmOcquqmNW1MbpoJ+qYOjoF3FbEfkrhgL83Fv12A1pLy2NB20OmogFVnVdVNdXwIAPxtVFyVd8Emd+XuWx2s3FW1T3nO3NiVxMCGwxdx+nIRPI3OiA81WVxM9P3Uxo7BXpg+mDKOEsJa44nk9nhlbGf0DG9tqSERZC6/rh5EhnQIQGs7bi1n1W/w0q/78e2WkxCC4hu6hZrw1f2J+Ow+rds0yNvVInikoJjULxIrnhiAeZN6W9xM1iIlLsQLqS/eYKnGqdfrMLJLsEWkDYoNwKoZA/HsiA4O+7Wh8GllsAjtqqwL303ug96RPnjnjm513gaTu8EiBmvqzpLWV+s4K4a5Fmg27p6GQMY+XMwvRdKcNTbL2wd6YNHUfkjPzMOGwxex8fBFbDqahbE92uDrTSex8+RlXC4ohbtRuTvKKSrDR+sow8DVRY8jFwrw2vKDaB/oaSnZLgmvRY6/TqfDq2O74Ink9vD3MFrcSup0ZTW39wrF2ysP4dD5fPx1JAt7z+RosnICPF3h4qRHpxBvLJzaz+53Rvl7WDIAvN1cEO3vgWg7SQzR/h6WgmvSWrHpaJYlewFQMlsAGmzXHMxEzwgfBHq5Yumec7g3KRxTVOWuq0Kv16F/jJ/FqtQ+0NNS2MunlQEPDojC7b1CNa4WADbva5M50DnEC9tPXIbBWW83XTLctxWGdgjAqgMkXCf1i7D5PrWYe/L69nhwQKQllmVCYjgmJIZblj+R3A7tAzxwS0Jby/TyN1nFdqiR1Yh/3XUWv5qNfbKORn8HkyY+P6ojvNxcMM0smFyc9BZ338tjOuPUpSK7hcKqqyMRE9A07s5dnPTwcTcgq6DUbuVfSUyAB354OKle2uCk18GnlQEX86tug5qXx3TGA/0jWaQw1yQsUlT0i/HFyC5BWJt2wb4VJZQu0DEBnogJ8NRUIdx2/DIOZuRh+nc78PjQ9prt9DpgynXRCPA04qVf9+NT1WCtpjaWFICEirUp953x3XDXp5sxyVzsSuLl6oJxCW3xRcoJ/GfVYRywKsttPSmcPQa199eIFEfc2qMNUk9lw83FydK+eeag1vi23ugT7Qujkx7vmidhG90tBG+M6wqfVgbklZTjoYFR6NrW22EAnz0GtvfH4tSzcHNxspjFnfQ6y/wn1gIBgMaq0drdBW1b19yc/uT17eHh6oyx3R1Paz6pXyRWHciEh9EZ9/enc8XD4GypT+JpZcnxtNNGSbS/Bx4d2g5CCCRG+qCwtMJm9mI13UJNmpoggGNxIony98Db47vZXSbrfDR3/D2NZpFSf+UEqsPPw4iL+aXw96xZAGoro7PGAsgw1xIsUlS4G5zx/oQEvLB4L77aRIGQvq0MljlVulVRH+D5UR3x8Ffb8Wd6Fg6fp/obsYGeuLtPGHpH+iI2yBNCCPh4GPHm72k4kVUINxcn9Ivxtdxth/tcfVBh3xg/bH0+2W6K7L19I/BFygnLjLCd23hhdHwbvLLsgKXAWVUMig3AmyspVdY63VnNhMRw6PU69IrwgV4H/LY3A3kl5dDrgDdv74aYAA+s2KvU1wj0crUEFHq5ulxREajkuEB0DzOhd4QP9OZiWCnPDqnSXaQWLl3bmmolikzuBswcprgwXrgxDv9csh8P9leEa78YP/z3zu5o09oNJnOMjF6vg6erC3KKyqoUJY7Q6ShA1lHNG8nTw2Kxcv95tG3tZpnFNjGSa0z4expxMCOvUUWKjC+qqSWFYa5lWKTYoW+0r0WkdG7jbSnZbm/eFcmAdv6YO6EHJn2+1eJu6RjsiXuSIizr6HQ0K+fN8SHIK6bKkmvTLlhESk3iL2qCI193tL8HBsX6Y23aBTjrdXj91njEhXhhUKx/jbIu1OZm67lE1Oj1Oo2r4oO7E/DIN9txW0KoJcU2TCXIguogpdHL1QWLrNxU1aVKempEytXdqU7qG4GkKF9L0KrEnktmSIcAbD6a5TBNtSZUJ6j6RPmiT5QvhBBwNzghwNMVblUIy2uF23uGIqeoDEMdpMo3BDKNvS7Oe4Zp6bBIsUOiqiCZq4seDw+MwuXCUk2ApD36RfvBzcXJUrjMOp1QjRwgR3QOwsxhsXZnuq0Pnkhuj31nczHlumhLueuaRvTr9Tq0MbnhTHZRrQb1/u38sOvFGzSxGxF+VFpe56DQVEOgdvdY1yepLXq9ztKf1fH2+G71Ps2CRKfTtRhXTV1wU3xIlbE8DcFD10XBw9XZ7my8DMNoYZFiB3UhsMuFZfjono5VrK1gcNajd6SPxfJSk8mudDqdJVCxIegWasLW55OvePufp/fDvD+PY6I55bSmWA/I7gZnfHF/bwCNN4GXm+p7e0Y0bIpsQwgUpmnSIcgL/xjNVVsZpiawSHHAP8d0xj9/3Y9nhtcudbJfjK9FpNibvba54+dhxNPDYutkX/ZK6DckOp0Ov07vj6KyiiuqQcEwDMPULyxSHHBPn3DcnRhWq2BKQKkjodPVzJLCNC5drjIWhWEYhqk/WKRUQW0FCkCFxR4f2g4mdxe7M7YyDMMwDFMzeBStY3Q6HZ68vn31KzIMwzAMUyVcFp9hGIZhmCZJsxEpc+fORVxcHHr16lX9ygzDMAzDNHt0QghR/WpNh9zcXHh7eyMnJwdeXjyXBcMwDMM0B65k/G42lhSGYRiGYa4tWKQwDMMwDNMkYZHCMAzDMEyThEUKwzAMwzBNEhYpDMMwDMM0SVikMAzDMAzTJGGRwjAMwzBMk4RFCsMwDMMwTRIWKQzDMAzDNElYpDAMwzAM0yRhkcIwDMMwTJOERQrDMAzDME0SFikMwzAMwzRJWKQwDMMwDNMkYZHCMAzDMEyThEUKwzDXFhVlwOaPgawjjd0ShmGqgUUKw1wLHFwK/PoEUF7S2C2pP7Z9Bqx6CRBC+Sz3LLD1f0BZsfLZ2teA5TOBr29t8CYCAPLOA9vnAaUF9D73LLVd3UaGYQCwSGGYqiktAEryGrsVV8/yZ4HtnwMHfrW//OJhIH11w7apLiktAJbNBDa+DZzZrny+YBKwdAbw23PKZ5s/oufLx2r3HeteB3Z+ffVtXf868OvjwK7v6P03twNLngTW/LP6bU/8BZzZcfVtaAgqK4B9i4DCS43dEqYZwyKFaVlcPgGc2lo3+yovBd5PAj7s3/h3uXsXAnP7ACtmAfmZtds2PxPIOUmvz+60v878CcDXtwAZe6+unY3Fme1AZTm9Pq36/U9toudt/1M+K62h6CwrpnMAINfQH68AP08Dck5fXVtzz2mfz++h5+1fVL1dcS4wbxTwyWDgwqGq1y0vBQ6vUtrfGCx/BlhwH7D0qcZrA9PsYZHSUtn7E7B7QWO3ouH55jbgf8nAiZSr39flY0D2CeDyceDw71e/P4AsFgVZtd9u0wfAhQPApvdJTFSU13xb9Z332VTb5QUXgYtp9Fo9wF86Cqx5hZbXlOJcYN0bQMYe22V7fyIXi/r41a6ZqsjYW7U4O7lJeX1qi+P1pDAAAKO3/XWKLgOLpwH/Cgc+GkCWtKLLyvLqxER1lOTSc3G29vPqxFPhRUBU0utFD9Nvc8mBNWjrJ8A3twILJl5VUzWUlwDr/11zIbv1E3ret7Du2sBcczQbkTJ37lzExcWhV69ejd2Upk9xDvDTZLqQFec0dmtqz6ktwL8igG2f12479WC76f2rb8fFw8rrvT/Vwf7SyTLz3R3kdnktHDi6tvrtKsqBjN3K+4w9WstAdajdH+d2AZWVVstVIub8Pnq+dBT4fCS5JlbOtt1naYF24AaA0kLg2/HAHy8DPz5gK0DWvQ6krwL2/EDvf/8b8O92inDa+Q1tv/qfiiCprATWvAx82A/4coxjUXNSJUrVQqt1pPI654x2PUds/wJI/RooLwYuHAR+e177P9rxJQXf1oayImDhw+T+kPuq7X9T7XY8u4N+mw3/tr/u1k/pOW2Z7e9tj/xM+r/JOBl7pC0nl9Sql6rfX3Gu8tojsPr1GcYBzUakTJs2Dfv378fWrXVkym8JXEynAcQ6ZiLzICAq6JF9snHaZo9d84Etn2g/K7wEHF2nHXz++i8NgNL/fzYVeK8XsOfHqvevthIcXnn1Ai0rXXl96Lerj005thaoLANOb6E70uJsrfiprLC/3YUDNGAavYCR5kFpzSvkjqisJFfNd3dpXVKXjgFH/qB+VYuU0jzgklVWy1m1SDHfJf94P5BntjrsWQD8cC/w+SgaxIQAPhsGvNebfr8TKSTovr8bOPkXbXMxTSvASguAi2YXxemt9Nts+QQouAAsmkLC9NfHgEMraOCVwmjrp8D6N+h15j77GTmVFVoXX84pxWIiVH16eqtWpJTk2hc9UuTEjgKgA3Z8AZz4U1men1G1tcYex/8Eds8nK5PFkmI+P/Uuynrf3AZ8d6d9EWTv/Ms7b//7groor89WE8OSnwnM7Q0seUKJ17FH7hnz+g6+U82xdcprV1P16zOMA5qNSGkyCEF3wWkrGrslFAz45zvAlo+1n184oLyub5FycjPw2XDgkyHAn+9qL/rFOYppP/csDUbLngaOrVfWWTgZ+PJmYN2/6H1RNg1UAFkPyksoG+PiIeCnB8j1kpdBF9Pcs9q2qOMtyotsrR/5mdqBvLyEAkoXPmw/zkAtUsqL6A6/pu4Je5zeprw+l0rPFw/TgLRkBvBaGHBwme128riC44Ge9wNuPkBJDg3aZ3cCB5cAaUuBFc9Q+5Y8CbzbHfhqDLDwIUWkuHpr9yextqTkX1DW8WsPVJQA+38GTmwEdnxFwiJjD1CQCSyeCnw+HHivJ3BkNeDiDkQNom3VA17GHsVVcWor/YfKzb/FhQMkeirLgYA4+uzgUoqn2PWttq3S7ZaxF/jlMTq+jD0kvgyeyvYrngEyD2gH9tNbrUSOsLUcCKGIlH6PAeF9zdtu065XkEmiec0r9FydtUJanYouKVaG4hz6PqHa9vDvZP1I/dZ2HyX59BzSAxj/tbIPe5QWKq8dBUtLfnpAad+RNY7XK7hg/s7sqvcH0E2CpCbrM4wDWKTUhrIiutP5/m7g+wnVR61XVgDHNtB2R9eS2bo2sQQAmag/TaY7bzXlpcDxjfTaOto/86Dy+sJBYOWLwLnduCoqK+0P0Oteo7vTM9uBlS8AvzxK65UVAR8NBN5LoLu9A0sAmLdf8wqtc/Ewmf8BEiJH/qDBsMIc7FdRSoPlKVW8wbwb6a5v+f8BHw/Wxj7IgdXgSc/qfsk8CLzdiS7IALXvq1uAzR/QHe77SbaDtxzQOtxIz1s+0lqChKCBeN9i237JyyB3xuUTymfWAx0AXEgDFj9C7pvSfBKe8hy5cIjE3y+P0vs2PQC9E4kVgH5T2X8ApbVufJvSWSEAnZ5cK8XZgLMb0OkWbT+V5FFw5eHflH2U5CoxBH6xQP8Z2vamzFWsLQBwaLny2skA3PENMPJN87IVighQW7lyTpK1DABirgcMHjRQtwoA7v2Z3AMlOcDOL6mtOj3Q/0laf++PJCw/7E8Wjt3fK4OwfywQM5Re7/8ZWPq0VqSc20UiQU1JHrVx5Wz6jx1dS5YCvTP1s7sPrWct9s/soHNp/ev0/Oc79HnuOfvByXKgLrykWFKKsun7hR0L2vo3lHTxomyyVsntjJ6K4HQkUtSuuENV3FAJQTcaknO7HQsuGZtUVAMLpRThsi1XI+6ZaxoWKTWhvJQGtdRvgHTzHUJlefVm1N3fA1/cCHw8CPhyNF14ahPbIATFL5zeSr7gomy6a6soA85sA8rsDAAACRPJ+n8Df/6HAgCLc2mgnBNGxazscTGdRNiRP5Q27JoP/DtGGeAlRdmKVaT/k4DOCdj5FV2kd35NVo+iy2SyP/CLst2pTWQO3vYZvXcyAhDAhjeV/tGZT81N7ysXXKM3mfKLc2ib/Axg/l3KRVUODl3G0bM6qDBtGYmetGW0v13zyTJg8ASCutIA8PsL2uOTlpQBTwHJL9FrtdVq/2ISSwsm2t6R//IoDcSr/0HvCy8BWYdhQ9ElcqcANPBcPkbnTWkhuVjUrpqQ7vRsESm7lPNR9pe0SHW4Ebj7JyC8P7ktbv0UaGuO58rYQ/38VicKrgTot/OJptfydwntDXS9HbjhZdqXux8JDGvBDAATfgKmbwWihwB+MYCzKwBBA1vRZdv/ijxHR70JzEwHHlwDTNkAeAQAHW+mZTIrJLwf0G0CvT6znYQlVINeptly6BEADHkRuN6cynvpiJLxA5AlwDqOpiSP/lN/vkP/sa/G0OdBXQAXN8CtNb23trRlWIn+NS+T+H6rA4lnKWoy9ppdj9n0vqJEaVNxjn2RoXeh8zzNbFVb9jRZq6QYU4sUKVysUR/npWOORUJZIbVJUpKjxHVZI0VKSY5j16REHaBcYb5+MswVwCKlOiorSWS814vuztSccZDOKTm2gZ7VouFgNaZXgETI7h9ILKgvhvMnkFDY9AHFcUhyT5MZfuM7JELU31emMvuu/jsJhpIcIOU9unBcPq797tUv0cXwqzF08d7+OQXgFmbRwKYejA//Thdc/w40iLcfrny+8R1lvS0fKT79djfQ8+aPSPQBwPXmgfzkJqoDAQBdx9Oz7PMutwGPpwLjvwFu/xKYcYBiNLJPUoxH9kkg7ywAHdBpDG1z6ajSBrlfUUn1QPYtovcDZgB3fkdWgOMblH4tziGTPgD4xgA9H6B1sg5Tv6x7HVj1d2X/aivJ0bWKWyJ9FV3QpVVHWnms8YsFBjxNrze8SYPehQOASytlnZAe9BzcVfkeKWJufo+epQslegg9Ji0F7vwW6HijEqeQsRtY9n90HniG0Hd0HQ+07UnL5fkT2pssN30fBWKSgR73mPtSFZ8BAKGJQLtkoHWE8pmb2QJxcCnwehQJL0AbRNnjXqB1OImBtgmAZxB9Hjdau/+40fQbBJrb79+BRFH0EG173X0BZwPQfhi9zzun3U9hFlBoR6TknIINUhBKkVJpFSMirWxhfYGud5A1ZP3r5oVCOffm3wV8M04JSFZTnGPfFSKPX1rhpAiTNyNGTzr35T7soRYpFSWOxYy0ButdgIgB9PrUZvvrSncP4Hh/AF2/1Otat4dhagGLlOo4t5N8/zmnFKtBl9vpuTpLinqQlKSvobiIkjwy9Uv2/0IDX0EW8EE/itU4uES77Qmze+fw77ZZIb9MB1bNBj4dantxlmz9n9ntAkqt/WQIxS7IAfTyCa3/+ufptnfN0lpxcrPi+uh4Ez1HD6bnje+QcPIIBLzDzL73SrIAXPcsrZO2jD73iQZ6TwZMYTQQVJYBPlF0B6+mwygyvXe8kS7irXyB2JG0LPVbKtoFAG0SyDICkGgpLTQHVqouvKnfkiABgE5jAe+2QMJ99P6PV+muU1pRPAIBVy96RF5Hn/30ANXMUBcDO/EXiazsU+RmkBRnkyVs93x6HzsC8DAPxp4hynqRAyjexOhNFgCZnXTb58DoucCot2hAB4DgbvR8+Rj1q39H6i95dw0oA7ga/1hyYxTnUDqrk5GE33NngLEf2IqD0ETt++ih2vfyOBKn2H6Xuy89H/5dG3MxyPz7x1xPx2SPiP7AoFl0nG16Ap1vBXQ64J6FwOQ/gKmbSBS18lf6AVDeS2FhTWEWCTN120tylf9L74e0bQBsgz6lOJDCppUvMPo9YMTrSjwMQFaHijL6nwH2U7LLi5QsJqM3He+oNwEv83khB/q8DHqWgatqS0pZoW0tlMpKW/GTbyUaJNL95e6j/N6OgoILVanoRdn21wHMxyToXJPnAYsU5gpxbuwGNEkqK+miqNPZVuF0NQE9J5GvXw7uQtBDr9J8QigBrBN/pbvg93rRwJm2lO6ULx0FbnqXLigrX6R101eTudXNB+j1IN3B7l+sTak9uUm58IcmKgOwVxvlQmZNYGeKJVDXYsjcT897fqR4B1nXIHIg+cNPbab9uZroew7/RhewU5sVNwagmOejzCKl3Gza7fsY4BsNbHiLBsg+j9CF3DdGEQH9n6S79ajBFGMAkLWlbW/AO5RcVElTgbgxtsfUaSwN/nI7VxMw9kO64LqaqF8vHyNzs/rO74j5Nw3uBviYU1T7z6DU0lObaLk07/vGKNt1GKW4V7xD6c7f1USfrXuNHhKvNnTMR9ZQUChALpke9wJ9ppAgPLOdLFoA3cUaPYDudwOb5gIQdM60u4HOQzXqtFqAxI2TC1mydn9PIs/Hah0AcDaSFULGlLTtSZ+pj6/9cCWGwbeddvvQ3hQYK61z478mF4sUT2rczUJBZvQAQO+HgYRJFFhritD+X9TodCRmpKCReATQQyJFifwvtPKjZ2uR0sqfBny1WDKFkruwJE8RAcHxwLQtZE2T55v1vkzhSvE1gAZhJxcg8WF6fH8PuTYLL2nruqhjk9RIt5B/e+BBc3yRjNfJzyQBIsWBjF1RW1IAOred/bTv5bF6h5Kgyj9PbjhrpHhw81Esaed22W+rul5OVcGwUvR5BJGVrDCr+QbP5mWQm77vY/bPc3sIYfufvVqKc6gdfrGKRfMagS0p1pTkAf+7Hni3Gw2Q6sBEAIi6jgY3nRNd5JY+DbwZS3Uc1IG0eRl0Yun0NOAaPYAO5jv/hQ8rVpZfH1MECqAEiQ59ARjyPBCWqFw8JJVldMEK7093pJK7f1IsAlIwAHQBGvWm8t66iNXpLcD5/Uo2Rp9pSgwGACRMJOECkMtICpS40cBtXyjuB99ouijK7+w5iSwHD66ku83ATvTnla4crzbK62hVe2Oup/56dAfFKwx61v6fPnqwciwurYA75wN+5oHVJ4qeP+hL7jrZJ3JgA7TWGq9gcukAFFuw40vzd6gsErEjKdbCyQhMWECDyvUqsSYxeAB3fa8cm2TE62QxaZMAdL5FaSugmNp7PwjAfKwDnrJ/3Hq94rppHUliFqBnZzcSLY5Qp6aG9bFdfuunQPd7gDEf2IoIZ6OS7QIAAR0dX7ilu0eK5qGzgZGv0/H4RDkWKLWhlZ/Ve/Nv6+Sidau5+2oHdVdvxUKiFimeQSQsEx8i4QwAbibtd1gfr7QUWLepMEubqmvtLpJIS4vaYiOPoyBTcTmqMXoCTs7KMVq7fKR1xKUV/cfkvuxRqLKkSHedPfdXWREFdlu+I9v+/gBFpHgGKSKvuVpSNr5NLvKNb9ds/S2fAC8HAodqWfxRCGDtv4AUO/WdLh0F5iaSeF06Q5u5VZcByUKQ5XzdG3W3zzqALSmS9NXk+01fSUGpAMVSyHTEiAHkIuh4M2Bwpwv0+b2K9SH/PAU63rOILpLSiuITBbi40ut+j9Od9aWjAHRmK8gmumj2mEiZE6KCBsLOqsnP2qhEisFTsYb0fpDutrd+CnS7i9o06m1qY3A88G4PMm8Hx9OAFDmQXFbDX6V5TuQd8emt5DuvKKU76fbDaDDpfjetnzjFNnBw6Is0iKrR6cj1s+l9St80tIJdEqfQhazzrRRDAJArxehFcR8R/egzucwRzkbgFnO8S9KjgKcq3sE32tYd12EUCYWTKXShl24qSf8nKUNGbqd3od9F4hkIPPA7tTGgI33m30H1ne2AUf+mgcGvHQm2gE404A2YQZYfNaGJJGJDE8ltAND5cuPbdD5Jd5Y9Rv6b4pYGzVIG/NDewN8yquwyBHZWXocl2S43epKgdETUYBLu3qEkJB1hM3j721/varDep/o73Vsr/xODBw3C0prm5kPHCWhFinQBqbFnSdF8p5VQkm0ozKrZ9AXSwqJ21cnjyr+gtE2NbLurNx2jtZXCYh1prVieqnP3uLVWBE1xDqU7q39f66rDVdUgkm32CtZmKDUnykvo/y+t5TIuqCoqymiQryihLL2YoYrYrY7T24C1r9LrLrcBHqpze83LivCrKKWbSpnmv/gR+j8+kqLd5ko49Jsyf1SXcfatsY0AixTJ/sXK3bNk7WtkNvWLBe76gcyg8u4z4T7KpAiIA9pdT+se3wBs/pACDWW8iXoQM4UBD68H/nqPBrG4MRRnEtKDYh4uH6OYkI43aS9apjBqQ84p4LqZZHnxCKQMDicX4GlVbIter6RhmkKB8zmKpWP815TWGtqLrDvORqpRcvk4fbebD7mf5N376LnKftUDQJuetqmpkqEvkkiyd5cucfWigViNuw/w0FryY7u4Od7WmtgR9LBGWlIAymoZ8gLFGeidyLRuDw9/4OZ3lSymTmNs//gys0ai15NQ2PMjWU98o5VlbiZg6l+O2x7QEXh4gxIwKuk5yfE2krA+VfexIyyWFJ2S7VMbuoyjgGe1iLaHTN2VNIRIUb93a624UoyeAIQSJO6uEimFWYo7xTPY9jusRUp1lhSLSLlIltbqkJYUtcVGCouCTPvxZdIq5OpNsV/q6q6AA5HioABboWpdVy/ad0kuWcD8Y5X1rANhq3LfyPpFnsGKmGlOlpTcs8BH19Hxy1iiCwerd+McWqH85lmHKWsv/g7H68tg+7Y9lZg1gNzpHc1lD7JPKSUOAjpRfOTxjSRSyksomaGilIoo6p3p5kFe7yVC0O9gbRUUgizjOWfI6vvbLG3bWKQ0Mdr2oh9d50R33D89oGRKDJ5F1pNw1Z1n78n0kLh6U9rpujeA+LsU5S3vuCVGT9qfRCpigO7yTeFA0nTtNjodcN8SyqzxDqXA28iBJFCqIrgbWXsiBiptDDUPTHKgbttbuXiP+5/WGqHG2UjtOr6BxI6jP6uLm7afaoN6gL9a1APODS/XfEDvMo4Gjm3zlNoc1WEvfqKmBHWufp26JDSRrIL+HWwvWjXBMwiYWoPS8m5WIuVq7/LsYW3FULt/1N9v9CTrl2VZa0WkyNgovYutsAJsA2dtLCmORMrVWFIClH1YFywEyDIEkKgA7Lh7sunZzaRkUzly90jxII/dqw1wIZcspxqRYmVJqdLdo3KfSddlc4pJ2fiOrautOJuEmjomSlYFdnKhOEbpLpexgcufoeu/vLERggT+0bVkhZ43ij5/fLe2NMWpTYpI2fwhWdcjr6Nr0y+PUkmJjL3k7pY1pfYtVuob3fAK0Nc8hpzcTDe1pzaRG/36v5PbHaD9SDeWnKpCcmYb0PW22vddPcAiRdLjXnpI4u+k9Nu+j9ma6e3RbQLV0MjYQ3EmMjXUWqRUhVcIMOwV+8vUf45Bz9RsfyPfIJeQzAaxR58pFIjb/3H7GSFqHLWtKRIxgASnb4xtlkp1DHjK1pXVUnBxJcFb3zSIu8fa1aIWKSoLiNELMKiCZt18FGuEnJ/JM8i+8LaxpERYfaeVsLGIlEv2XTXWSCuOWgy5+wLQkRXXXuqy2t0D2BEpKuuI2nVkD4u7x3wc3m3IVW0dgF9o7e7Jtr8/QBWTEqJkHtXEklJWTK6Ldjcort7yUmDZU3SjVdNBs7KSLJz7f6HBf9RbijsVqNoikpehBOJbc+EgXYfLiqksw+HfKZB8ykaayuH4BrJm3P0TVUM+vYUm1Hwslfp51UuKGDmRotwEL/8/bf/I4noV5cCu7+h1n6nKjWVFKRVRVBdSVGeC/v483fycTSVLiQyiTl9JbbzxHbKorjaXUAjvR7V0AjtRhelNc7XzXzUyLFIcMfw1iqKWtSmqQ+8EjHiDirfJE8a3nVI7pDEwuCv1HhzRJgF40k56ZHPHLwaYtpkGsrqOtGeqp0HcPSpRYvTWxjC5W1lS1LEBandPlkqk2MPoSWJXZtZ4BZPVRQbC2ggltSWlBnPcSNSWFCdz6m7hRduicbJN6m2qEinVuntUMSmAEpeSYyVSbNw9VcWkqAJnpZipSUzKxrcpQ27gTGDI3+izg0vIDb/jS3OgvBdVto5JJje7NSdSqCq4b5SSpRTUBRhoLgtwejtdowc/R/Vtzu4g97UUMZs/IvHg7KqICMmFNLJgp6+i8ACA4vq+vpXcMNABo9+nG9O7f6Qq1rlnqJr02te0NatyVTF+cl8db6bMsDPbKTj2ZAqdR+6+dLx6J8cZnNKiIvnpQeU363I7pdev+xcJlZ+n0o23qATaDSM3tbxGXjpGIuXcbhJjMp6yEeHsHke4uNIAXpsBLjwJuOVjCoZ0caeiY46CR5n6x6+d45oZTP2idrcYPGsXZ1RTDK2UYnfWYkFjSfHUWnbU7h45EDkSKTqd1i1m8NTu25G7p+Bi7USKtetNigt79VVqKlLcfVTungt0Z772X9r5odR1UgCqGQTYDoRywJNusxpl9wTXLrtH1qE6uFT5TD3X0paPyV2y+UMqkFdRTjF2y5+hCRzLS6heVGmeNo06fRW5r3LP0bZlhTRlxbxRwLe3UzXtlPdpexmXOOxVpYqzrGck4wxlyQdZCiDTbO0a8jcg3pzR5+pNwgKgwNeyQhJLD6y0H6ANHWUKtgogQfxqMPD1LbQobgwJV52OYgb7PkoWD3sMnU3WHPl7Df4bjUmhvSiuMmIAiRNp0ek0RjvGtY4gi2Rlmf1zrxFgkVLXdL6VIq2npgCBcdWvzzAtEbUlw1pA1CVy3zYixcqSohEpKkuKxO7AIdc3D7QGT3IjyPfOrnQzokZ+T2WZdoJKazysYr/UlhRAsTypy/pL1IGzgG31V7V1xOLuySQ3xtpXKd5OrqOukwIolhQbkWJ298iAdHvunvP7gH+3V0STV7DixpLrl+TT/Fu/Pq7dtqJcme8nc78Si6OeLHXzR9p5kRZOBub2IuGx8gXKKsxKp4E+abpSAuBkCs3b9fEg7VxOF9PIKiYqyUXy2/NkvfIMpqy++LvovEh82NwWc2VjWexu4EwlWy6gE2VvqpHxhtLKMfh5ysCTQsYnSumfdtdToKpcpkYdpN4umWLs1FmHamJHkoUIANqPIAuSFCF6vW1ZBCmkJDpVQH0Tcfmwu6c+COhQ/ToM05JRixR1PFVd08qfAp2t3UnWlhS1iHG3I1IcWVIAZSCRgapy3+52XIkGd6XgXVXWA+9QraXFOkC3qj67EndPRYkyN1VZIVklBj2rrZMCKNVuj6wBPh9Jwfy+Mcqsxm170WCdeZCsDm0SKMNRp6P0fXlMAZ1ITMm+OrsT2L2A7vCPb6CHVxuycvh3oMw7tTskfTW529Wpv8XZyjxXgBIoCpB7Qm8ezgY9C/QyZ+id2aG49PIzaM4wNTe/S8X7ds9Xykkk3EeWizFzlbbL54Is5X1YH7JorH8DSJ5tm8gga0sB1Bcy5i/pUUpW6DaBvnvLx0C/J2jZDS/T9Bgn/gQWPUK/h71SAV3GkXssqDNZm3JOUf0m3xhgxL/INRY70vb87DAKWPIEid/gePvnWY97KUO0uhjFBoJFCsMwdY/RiwaNyvL6iUeRyH3buF2qsqSYtMXdAGVwtoccaI3WIsVONpBsS45qwNXp6W7d6K2U5TeFKvWYZJvUtLIaPDyDyY2id1GqBKtFSkU5cHw9metlTSO31uRmM3hQIbayAnLXVJSSVSJpmmLhkMck3T0ADZQL7qMsxsKLJKy63k6TiOadVWbnHvA0uToOmWfTvuk/ZIWwdpUtfFAREgBNLQGQ+8Q6WDV9JaXvysDm6CEknNRCBiBrQfoqslzJlF71oB6WqJ3YU24fHE8JBfF3UsmH/PP0XV7BSnFESVA8xRdmHQaWzyTB5+5HlhDfaGCCVWaMpJUfuXgy9lCZBPm7efhTKABAGTeDZ2lFs5uJxMSM/SR87BU+9AwCntxL58M340ikBMaRuHLyUCZZtcbdx1zraKUyj5o1suhoE4HdPQzD1D06neJCqE93j7wTtL4jtM7usXb3uKpEipPB1uyt2ZeJnm0sKb52V9cKJC9FcPhEKJ+rCzQGdQG8VOLAeh9BXZXpGYweyt2xZR6h0zRQfTWW5uKSMRJScMiUUwC49X8US1F0ieo1ycwPa3ePJOswZZ8A5PawTvsGKJX1yBqyaDkZgM7jlCBmnyiahNE7jN5XlpO4cjIP2GFJ2lpSslL2gSVUrLCyjERW97uVdZwMlJAQHA+MeV8pXSAqaNBWV3LuM822/o2TgeaAutlcE8roCdy7GJixj6pIW5+vej1N6QEosRyhiTWLV0x8hH5bub01er2tVU9i9NBOW2GNixuJEhmfoi7UWBUj36Dsxb6P1Wz9RoYtKQzD1A/uPlRrwtoqUJf0epCsBN3u0n5eVUyKu4+2bkrnW6t2rzi0pDgSKarPvdsC0JGrwRSmBHRGDQLu/43iWoK62t4tq4sRjv+aYi7ksUikJUXOxeRkMFuuAoAb/qlk9t32BcV5tO1J2xReBJY8qcw1ZfBQRIXBnQbgy8fJOrLkSdqnRxC5AdSzoHceRxaI4xuUAogR/bWVap1cgPvNqbJ/vks1O/o/STE5xzdSWYPz+5SaIT3vJ5GwZwFloQAkYqIGUz9CkOi663vlO/xjlXgR/w5at0tgHPDUQZpUdPEjSt/WtBKsJP5OCoCVgcYdRtVsu+4T6FGf9HqA/md9ptZsfZ9IKrrZTGCRwjBM/WCxpNSjuye4KzDuMzvfbRWT4mqigba8iAZI9WSDvSbbbG53X9KSIoucOYo9Uw/kfR8Ddn1Lr919yW2Qc4rqkVSVedbhRppvK3oIVbm1FkqANtjW4EH1b/za0/xNatHjFUwPSfxdwB9zlGJl1oX3Jq0gl4aLG7WjOIf6zOCuFXe9HiRLx/ENShyMIxcCQFNlJNxnTuvWKcIyoj9VsD6znWIhIgfQrOIyeDegIwnL4HgKrrWu+uzfAcDP9FptNVIjZ0YHtJOG1hSDO6UVn95GsSa1qX9V3/jHArfNa+xW1BssUhiGqR+iB9OgciXl+68WNxMsd95Gc1bOQ2tpUJXp0De+Q1aCtglV7yu8L1k85CSQ3e+hgdLRgBg5kOIs/NpTXMVhc6yGqzcwaTmlyFaXGu9s0MZGSCFhz5ICUPpqdTWRJC6uVBByqblgYdR12uV6PaA395G7j9b15OJGabDlxVRyQQi6Kz+9nWqNqN0y9lC72dQkz1a9aUVWphXPUql5WWuq5/3UZuspGdSVcR39Jv6x5GKqKNG6g2pDmwR6MA0KixSGYeqH6/6PTPvVTd9QH+idKA4g9wy5WQCtNQGo2RxJALlmZp1WjkOvB0K6OV4/aRrFfcSNJotBQBywbxGJFg9/AFdgWbJYUlQipXUkDdiuJiChhsci6fUgTWRXWeE4ANgRCar0V52ufqozm0KBO76hirPSFZUwUfvdEnVMiyOR4uRCy87uoHnQmGYDixSGYeqPxhAokuFz6m5ftTkOt9baOIT+T9LM4oFdHG9THWF9yNWizlzR6+27umqKdW2Wpkh1M6ED5L5xaUVZS2q3jjXDX6OKrp1vqbv2MfWOTgghGrsRtSE3Nxfe3t7IycmBl5cD0yHDMExLo4mUKW+SnEiheKMmUtuDsc+VjN9sSWEYhmkOsEBxzJXOvM40ebhOCsMwDMMwTRIWKQzDMAzDNElYpDAMwzAM0yRhkcIwDMMwTJOERQrDMAzDME0SFikMwzAMwzRJWKQwDMMwDNMkaRSRMnbsWLRu3Rrjxo1rjK9nGIZhGKYZ0Cgi5fHHH8eXX37ZGF/NMAzDMEwzoVFEyqBBg+Dp6Vn9igzDMAzDXLPUWqSsX78eN910E0JCQqDT6bB48WKbdebOnYuIiAi4uroiMTERW7ZsqYu2MgzDMAxzDVFrkVJQUID4+HjMnTvX7vLvv/8eM2bMwOzZs7Fjxw7Ex8dj2LBhyMzMvKIGlpSUIDc3V/NgGIZhGKblU2uRMmLECLz88ssYO3as3eVvvfUWJk+ejEmTJiEuLg4ffvgh3N3d8dlnVzal+Jw5c+Dt7W15hIaGXtF+GIZhGIZpXtTpLMilpaXYvn07Zs2aZflMr9cjOTkZKSkpV7TPWbNmYcaMGZb3OTk5CAsLY4sKwzAMwzQj5LgthKjxNnUqUi5evIiKigoEBgZqPg8MDMTBgwct75OTk7Fr1y4UFBSgbdu2WLBgAZKS7E+1bTQaYTQaLe/lQbJFhWEYhmGaH3l5efD29q7RunUqUmrKqlWrrnjbkJAQnDp1Cp6entDpdHXWptzcXISGhuLUqVPw8vKqs/02R7gvCO4HgvuB4H4guB8I7geiNv0ghEBeXh5CQkJqvP86FSl+fn5wcnLC+fPnNZ+fP38eQUFBdfIder0ebdu2rZN92cPLy+uaPuHUcF8Q3A8E9wPB/UBwPxDcD0RN+6GmFhRJndZJMRgMSEhIwOrVqy2fVVZWYvXq1Q7dOQzDMAzDMPaotSUlPz8f6enplvfHjh1DamoqfHx8EBYWhhkzZmDixIno2bMnevfujXfeeQcFBQWYNGlSnTacYRiGYZiWTa1FyrZt2zB48GDLe5l5M3HiRMybNw/jx4/HhQsX8OKLLyIjIwPdunXDihUrbIJpmxpGoxGzZ8/WBOleq3BfENwPBPcDwf1AcD8Q3A9EffeDTtQmF4hhGIZhGKaBaJS5exiGYRiGYaqDRQrDMAzDME0SFikMwzAMwzRJWKQwDMMwDNMkYZHCMAzDMEyThEWKmblz5yIiIgKurq5ITEzEli1bGrtJ9cpLL70EnU6neXTo0MGyvLi4GNOmTYOvry88PDxw66232lQSbo6sX78eN910E0JCQqDT6bB48WLNciEEXnzxRQQHB8PNzQ3Jyck4fPiwZp1Lly5hwoQJ8PLygslkwgMPPID8/PwGPIqrp7p+uO+++2zOj+HDh2vWaQn9MGfOHPTq1Quenp4ICAjAmDFjkJaWplmnJv+FkydPYtSoUXB3d0dAQABmzpyJ8vLyhjyUq6Im/TBo0CCbc2LKlCmadZp7P3zwwQfo2rWrpXpqUlISli9fbll+LZwLQPX90KDngmDE/PnzhcFgEJ999pnYt2+fmDx5sjCZTOL8+fON3bR6Y/bs2aJTp07i3LlzlseFCxcsy6dMmSJCQ0PF6tWrxbZt20SfPn1E3759G7HFdcOyZcvE888/LxYuXCgAiEWLFmmWv/baa8Lb21ssXrxY7Nq1S9x8880iMjJSFBUVWdYZPny4iI+PF5s2bRIbNmwQMTEx4s4772zgI7k6quuHiRMniuHDh2vOj0uXLmnWaQn9MGzYMPH555+LvXv3itTUVDFy5EgRFhYm8vPzLetU918oLy8XnTt3FsnJyWLnzp1i2bJlws/PT8yaNasxDumKqEk/XHfddWLy5MmacyInJ8eyvCX0wy+//CKWLl0qDh06JNLS0sRzzz0nXFxcxN69e4UQ18a5IET1/dCQ5wKLFCFE7969xbRp0yzvKyoqREhIiJgzZ04jtqp+mT17toiPj7e7LDs7W7i4uIgFCxZYPjtw4IAAIFJSUhqohfWP9eBcWVkpgoKCxBtvvGH5LDs7WxiNRvHdd98JIYTYv3+/ACC2bt1qWWf58uVCp9OJM2fONFjb6xJHImX06NEOt2mJ/SCEEJmZmQKAWLdunRCiZv+FZcuWCb1eLzIyMizrfPDBB8LLy0uUlJQ07AHUEdb9IAQNTI8//rjDbVpiPwghROvWrcWnn356zZ4LEtkPQjTsuXDNu3tKS0uxfft2JCcnWz7T6/VITk5GSkpKI7as/jl8+DBCQkIQFRWFCRMm4OTJkwCA7du3o6ysTNMnHTp0QFhYWIvuk2PHjiEjI0Nz3N7e3khMTLQcd0pKCkwmE3r27GlZJzk5GXq9Hps3b27wNtcna9euRUBAAGJjY/HII48gKyvLsqyl9kNOTg4AwMfHB0DN/gspKSno0qWLpqr2sGHDkJubi3379jVg6+sO636QfPPNN/Dz80Pnzp0xa9YsFBYWWpa1tH6oqKjA/PnzUVBQgKSkpGv2XLDuB0lDnQt1Ogtyc+TixYuoqKiwKdsfGBiIgwcPNlKr6p/ExETMmzcPsbGxOHfuHP7+979jwIAB2Lt3LzIyMmAwGGAymTTbBAYGIiMjo3Ea3ADIY7N3LshlGRkZCAgI0Cx3dnaGj49Pi+qb4cOH45ZbbkFkZCSOHDmC5557DiNGjEBKSgqcnJxaZD9UVlbiiSeeQL9+/dC5c2cAqNF/ISMjw+45I5c1N+z1AwDcddddCA8PR0hICHbv3o1nnnkGaWlpWLhwIYCW0w979uxBUlISiouL4eHhgUWLFiEuLg6pqanX1LngqB+Ahj0XrnmRcq0yYsQIy+uuXbsiMTER4eHh+OGHH+Dm5taILWOaAnfccYfldZcuXdC1a1dER0dj7dq1GDp0aCO2rP6YNm0a9u7di40bNzZ2UxoVR/3w0EMPWV536dIFwcHBGDp0KI4cOYLo6OiGbma9ERsbi9TUVOTk5ODHH3/ExIkTsW7dusZuVoPjqB/i4uIa9Fy45t09fn5+cHJysonQPn/+PIKCghqpVQ2PyWRC+/btkZ6ejqCgIJSWliI7O1uzTkvvE3lsVZ0LQUFByMzM1CwvLy/HpUuXWnTfREVFwc/PzzIDekvrh+nTp2PJkiX4448/0LZtW8vnNfkvBAUF2T1n5LLmhKN+sEdiYiIAaM6JltAPBoMBMTExSEhIwJw5cxAfH4///Oc/19y54Kgf7FGf58I1L1IMBgMSEhKwevVqy2eVlZVYvXq1xv/W0snPz8eRI0cQHByMhIQEuLi4aPokLS0NJ0+ebNF9EhkZiaCgIM1x5+bmYvPmzZbjTkpKQnZ2NrZv325ZZ82aNaisrLT8UVsip0+fRlZWFoKDgwG0nH4QQmD69OlYtGgR1qxZg8jISM3ymvwXkpKSsGfPHo1oW7lyJby8vCzm8aZOdf1gj9TUVADQnBPNvR/sUVlZiZKSkmvmXHCE7Ad71Ou5cAVBvi2O+fPnC6PRKObNmyf2798vHnroIWEymTSRyS2Np556Sqxdu1YcO3ZM/PnnnyI5OVn4+fmJzMxMIQSl2oWFhYk1a9aIbdu2iaSkJJGUlNTIrb568vLyxM6dO8XOnTsFAPHWW2+JnTt3ihMnTgghKAXZZDKJn3/+WezevVuMHj3abgpy9+7dxebNm8XGjRtFu3btml3qbVX9kJeXJ55++mmRkpIijh07JlatWiV69Ogh2rVrJ4qLiy37aAn98Mgjjwhvb2+xdu1aTTplYWGhZZ3q/gsy3fKGG24QqampYsWKFcLf379ZpZ1W1w/p6eniH//4h9i2bZs4duyY+Pnnn0VUVJQYOHCgZR8toR+effZZsW7dOnHs2DGxe/du8eyzzwqdTid+//13IcS1cS4IUXU/NPS5wCLFzH//+18RFhYmDAaD6N27t9i0aVNjN6leGT9+vAgODhYGg0G0adNGjB8/XqSnp1uWFxUVialTp4rWrVsLd3d3MXbsWHHu3LlGbHHd8McffwgANo+JEycKISgN+YUXXhCBgYHCaDSKoUOHirS0NM0+srKyxJ133ik8PDyEl5eXmDRpksjLy2uEo7lyquqHwsJCccMNNwh/f3/h4uIiwsPDxeTJk21Ee0voB3t9AEB8/vnnlnVq8l84fvy4GDFihHBzcxN+fn7iqaeeEmVlZQ18NFdOdf1w8uRJMXDgQOHj4yOMRqOIiYkRM2fO1NTGEKL598P9998vwsPDhcFgEP7+/mLo0KEWgSLEtXEuCFF1PzT0uaATQoja2V4YhmEYhmHqn2s+JoVhGIZhmKYJixSGYRiGYZokLFIYhmEYhmmSsEhhGIZhGKZJwiKFYRiGYZgmCYsUhmEYhmGaJCxSGIZhGIZpkrBIYRiGYRimScIihWEYhmGYJgmLFIZhGIZhmiQsUhiGYRiGaZL8P4ghvL23MyAKAAAAAElFTkSuQmCC", + "image/png": "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adSoEU8//TRr1qxRXzt06FDy8/OpV68eY8eOZenSpRgMhiu28eTJk+j1elq3bq0+1qBBA7y8vCr9Pv9rJJMihBDVQOPkRKP9+6rt2DWJvb09I0aMYMGCBdxzzz0sXryY2bNnX9U+rO9yrNFoytz1WKPR2NS3DBw4kDp16jBv3jyCg4MxmUw0bdqUoqIiAFq3bk1MTAwrV65k3bp1DBs2jL59+/LLL78QEhLCyZMnWbduHWvXruWpp57i/fffZ/Pmzdf1bsv/RRKkCCFENdBoNFc15FKddu3aZfPzzp07CQ8PJzIyEoPBwK5du9ThntTUVE6ePElkZCQAERER5b6+tDFjxtC0aVM+++wzDAYD99xzz3V6N5faOG/ePLp16wbAtm3bymzn7u7O/fffz/333899991H//79SUtLw9vbGycnJwYOHMjAgQMZP348jRs35siRIzZZktIaNWqEwWDgwIEDtGljHuqLiooiPT39+rzRW4AEKUIIIS4rNjaWyZMn8/jjj7N//34++eQTPvzwQ8LDwxk0aBBjx45l7ty5uLm58eKLL1KrVi0GDRoEwNNPP02XLl344IMPGDRoEKtXr7apR7GIiIigY8eOTJkyhcceewyn65jt8fLywsfHhy+//JKgoCBiY2N58cUXbbb56KOPCAoKolWrVmi1Wn7++WcCAwPx9PRk4cKFGI1GOnTogLOzM9999x1OTk42dSvlady4MX379mXcuHF8/vnn2NnZ8eyzz+J0lYXM/yVSkyKEEOKyRowYQX5+Pu3bt2f8+PE888wzjBs3DjDPwmnTpg133XUXnTp1QlEUVqxYoQ57dOzYkXnz5jF79mxatGjBmjVrePXVV8s9zujRoykqKuKxxx67ru9Hq9WyZMkS9u3bR9OmTfm///s/3n//fZtt3NzceO+992jbti3t2rXj7NmzrFixAq1Wi6enJ/PmzaNLly40b96cdevW8ccff+Dj43PFY3/77bcEBATQvXt3hgwZwtixY3Fzc8PR0fF6vd2bmkaxTHa/SVimIGdmZuLu7l7dzRFCiEopKCggJiaGunXrSodUgbfeeouff/6Zw4cPV3dTbpjz588TEhLCunXr6NOnT3U351+73Pf8WvpvGe4RQghRrXJycjh79iyffvop06ZNq+7mXFcbNmwgJyeHZs2akZCQwAsvvEBYWBjdu3ev7qbV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", "text/plain": [ "
" ] @@ -690,7 +700,7 @@ }, { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjAAAALECAYAAAAW8gpgAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAhHZJREFUeJzs3XlcTfnjP/DXbS91K2mlVUkRypptLJF1bMNYRqgYRtZBY8a+T58xYqxjC8PYBmMn2YYiRdlDolD2Skr77w+/7tedG8NMdTqd1/Px6PFwzzn33Ndt7tSrc97nfWSFhYWFICIiIhIRNaEDEBEREX0qFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHRYYIiIiEh0WGCIiIhIdDaEDlJaCggI8evQIBgYGkMlkQschIiKij1BYWIhXr17BysoKamrvP85SYQvMo0ePYG1tLXQMIiIi+heSkpJQrVq1966vsAXGwMAAwNtvgFwuFzgNERERfYz09HRYW1srfo+/T4UtMEWnjeRyOQsMERGRyPzT8A8O4iUiIiLRYYEhIiIi0WGBISIiItGpsGNgPlZ+fj5yc3OFjkFUojQ1NaGuri50DCKiUiPZAlNYWIiUlBSkpqYKHYWoVBgZGcHCwoLzIBFRhSTZAlNUXszMzKCnp8cf8lRhFBYWIjMzE0+ePAEAWFpaCpyIiKjkSbLA5OfnK8qLiYmJ0HGISpyuri4A4MmTJzAzM+PpJCKqcCQ5iLdozIuenp7ASYhKT9Hnm2O8iKgikmSBKcLTRlSR8fNNRBWZpAsMERERiRMLDBEREYmOJAfxvo/ddwfK9PXuLehcpq8XEhKCsWPHlvtLx1u1aoV69eohODhY6Cg4efIkWrdujZcvX8LIyEjoOERE9P/xCAzR/9eqVSuMHTtW6BhERPQRWGCIiIhIdFhgRKagoABBQUFwdHSEtrY2bGxsMHfuXJw8eRIymUzp9FBMTAxkMhnu3btX7L5mzJiBevXqYd26dbCxsYG+vj6++eYb5OfnIygoCBYWFjAzM8PcuXOVnpeamgp/f3+YmppCLpejTZs2iI2NVdnvpk2bYGdnB0NDQ/Tt2xevXr36V+85OzsbEyZMQNWqVVGpUiU0btwYJ0+eVKwPCQmBkZERjhw5AhcXF+jr66NDhw5ITk5WbJOXl4fRo0fDyMgIJiYmCAwMxKBBg9C9e3cAwODBg3Hq1CksXrwYMplM5fsWHR2NBg0aQE9PD02bNkVcXNxHZf+332OZTIZVq1ahS5cu0NPTg4uLCyIiInDnzh20atUKlSpVQtOmTREfH/+vvqdERGLHMTAiM3nyZKxevRqLFi1C8+bNkZycjJs3b/7r/cXHx+PQoUM4fPgw4uPj8cUXX+Du3buoUaMGTp06hfDwcPj6+sLLywuNGzcGAPTu3Ru6uro4dOgQDA0NsWrVKrRt2xa3bt1C5cqVFfvds2cP9u/fj5cvX6JPnz5YsGCByi/qjxEQEIDr169j69atsLKywu7du9GhQwdcuXIFTk5OAIDMzEz89NNP2LRpE9TU1PDVV19hwoQJ2Lx5MwDgxx9/xObNm7F+/Xq4uLhg8eLF2LNnD1q3bg0AWLx4MW7duoXatWtj1qxZAABTU1NFifnhhx+wcOFCmJqaYvjw4fD19cXZs2dL7XsMALNnz8bPP/+Mn3/+GYGBgejfvz8cHBwwefJk2NjYwNfXFwEBATh06NAnf0+JSBxu1HQp8X263LxR4vsUwicdgZkxY4bir9Oir5o1ayrWv3nzBiNHjoSJiQn09fXRq1cvPH78WGkfiYmJ6Ny5M/T09GBmZoaJEyciLy9PaZuTJ0/Cw8MD2tracHR0REhIyL9/hxXIq1evsHjxYgQFBWHQoEGoXr06mjdvDn9//3+9z4KCAqxbtw6urq7o2rUrWrdujbi4OAQHB8PZ2RlDhgyBs7MzTpw4AQA4c+YMIiMjsWPHDjRo0ABOTk746aefYGRkhJ07dyrtNyQkBLVr10aLFi0wcOBAhIWFfXK+xMRErF+/Hjt27ECLFi1QvXp1TJgwAc2bN8f69esV2+Xm5mLlypVo0KABPDw8EBAQoPR6v/zyCyZPnowePXqgZs2aWLp0qdKgXENDQ2hpaUFPTw8WFhawsLBQmr127ty5+Oyzz+Dq6orvvvsO4eHhePPmTal8j4sMGTIEffr0QY0aNRAYGIh79+5hwIAB8Pb2houLC8aMGaN0JIqISEo++QhMrVq1cOzYsf/bgcb/7WLcuHE4cOAAduzYAUNDQwQEBKBnz56Kv1Tz8/PRuXNnWFhYIDw8HMnJyfDx8YGmpibmzZsHAEhISEDnzp0xfPhwbN68GWFhYfD394elpSW8vb3/6/sVtRs3biA7Oxtt27YtsX3a2dnBwMBA8djc3Bzq6upQU1NTWlZ0X53Y2FhkZGSo3IIhKytL6XTG3/draWmp2MenuHLlCvLz81GjRg2l5dnZ2UoZ9PT0UL169WJfLy0tDY8fP0ajRo0U69XV1VG/fn0UFBR8VI46deoo7Rt4O02/jY3NPz73U7/Hxb2mubk5AMDNzU1p2Zs3b5Ceng65XP5R74OIqKL45AKjoaEBCwsLleVpaWlYu3YttmzZgjZt2gCA4nD9uXPn0KRJExw9ehTXr1/HsWPHYG5ujnr16mH27NkIDAzEjBkzoKWlhZUrV8Le3h4LFy4EALi4uODMmTNYtGiR5AtM0f1tilP0y7CwsFCx7GOmkNfU1FR6LJPJil1W9Is+IyMDlpaWxf7l/+4RjQ/t41NkZGRAXV0d0dHRKvfz0dfX/+Drvfu9+K/e3X/RDLcf+34+9Xv8odf8LzmIiCqSTx7Ee/v2bVhZWcHBwQEDBgxAYmIigLeDHHNzc+Hl5aXYtmbNmrCxsUFERAQAICIiAm5uboq/JgHA29sb6enpuHbtmmKbd/dRtE3RPt4nOzsb6enpSl8VjZOTE3R1dYs9FWNqagoASgNXY2JiSjyDh4cHUlJSoKGhAUdHR6WvKlWqlPjrubu7Iz8/H0+ePFF5veKKdHEMDQ1hbm6OCxcuKJbl5+fj4sWLSttpaWkhPz+/RPMTEVHp+KQC07hxY4SEhODw4cNYsWIFEhIS0KJFC7x69QopKSnQ0tJSmezL3NwcKSkpAICUlBSl8lK0vmjdh7ZJT09HVlbWe7PNnz8fhoaGii9ra+tPeWuioKOjg8DAQEyaNAkbN25EfHw8zp07h7Vr18LR0RHW1taYMWMGbt++jQMHDiiOYpUkLy8veHp6onv37jh69Cju3buH8PBw/PDDD4iKiirx16tRowYGDBgAHx8f7Nq1CwkJCYiMjMT8+fNx4MDHTzw4atQozJ8/H3/++Sfi4uIwZswYvHz5Uul+QXZ2djh//jzu3buHZ8+e8cgGEVE59kmnkDp27Kj4d506ddC4cWPY2tpi+/btHzy9URYmT56M8ePHKx6np6d/cokp65lx/42pU6dCQ0MD06ZNw6NHj2BpaYnhw4dDU1MTv//+O0aMGIE6deqgYcOGmDNnDnr37l2iry+TyXDw4EH88MMPGDJkCJ4+fQoLCwu0bNlSpXiWlPXr12POnDn49ttv8fDhQ1SpUgVNmjRBly5dPnofgYGBSElJgY+PD9TV1TFs2DB4e3srnZaaMGECBg0aBFdXV2RlZSEhIaE03g4REZUAWeF/HCjQsGFDeHl5oV27dmjbtq3KlOu2trYYO3Ysxo0bh2nTpmHv3r1KpzYSEhLg4OCAixcvwt3dHS1btoSHh4fSNPLr16/H2LFjkZaW9tG50tPTYWhoiLS0NJUBjm/evEFCQgLs7e2ho6Pzb986iVhBQQFcXFzQp08fzJ49W+g4pYKfcyLxk+Jl1B/6/f2u/zSRXUZGBuLj42FpaYn69etDU1NTaXxGXFwcEhMT4enpCQDw9PTElStXlK62CA0NhVwuh6urq2Kbv4/xCA0NVeyD6N+4f/8+Vq9ejVu3buHKlSsYMWIEEhIS0L9/f6GjERHRv/BJBWbChAk4deqUYtxDjx49oK6ujn79+sHQ0BB+fn4YP348Tpw4gejoaAwZMgSenp5o0qQJAKB9+/ZwdXXFwIEDERsbiyNHjmDKlCkYOXIktLW1AQDDhw/H3bt3MWnSJNy8eRPLly/H9u3bMW7cuJJ/91TmEhMToa+v/96vokHhJU1NTQ0hISFo2LAhmjVrhitXruDYsWNwcflvf93UqlXrve+laBI9IiIqeZ80BubBgwfo168fnj9/DlNTUzRv3hznzp1TXAGzaNEiqKmpoVevXsjOzoa3tzeWL1+ueL66ujr279+PESNGwNPTE5UqVcKgQYMUM58CgL29PQ4cOIBx48Zh8eLFqFatGtasWSP5S6grCisrqw9eHWVlZVUqr2ttbf3RM+d+ioMHD773cvXSGhNEREQlMAamvOIYGJI6fs6JxI9jYEppDAwRERGREFhgiIiISHRYYIiIiEh0WGCIiIhIdFhgiIiISHQ++W7UFdoMwzJ+vY+fWbgkhISEYOzYsUhNTS3T1y0JrVq1Qr169ZRmaC4tMpkMu3fvRvfu3Uv9tYiI6N/hERiSrBkzZqBevXpCxyAion+BBYaIiIhEhwVGZAoKChAUFARHR0doa2vDxsYGc+fOxcmTJyGTyZROD8XExEAmk+HevXvF7qvoCMS6detgY2MDfX19fPPNN8jPz0dQUBAsLCxgZmaGuXPnKj0vNTUV/v7+MDU1hVwuR5s2bRAbG6uy302bNsHOzg6Ghobo27cvXr169VHv8fXr1/Dx8YG+vj4sLS2xcOFClW2ys7MxYcIEVK1aFZUqVULjxo1x8uRJxfqQkBAYGRlhz549cHJygo6ODry9vZGUlKRYP3PmTMTGxkImk0EmkyEkJETx/GfPnqFHjx7Q09ODk5MT9u7d+1HZi/47HDlyBO7u7tDV1UWbNm3w5MkTHDp0CC4uLpDL5ejfvz8yMzMVz2vVqhVGjRqFsWPHwtjYGObm5li9ejVev36NIUOGwMDAAI6Ojjh06NBH5SAiquhYYERm8uTJWLBgAaZOnYrr169jy5Yt/2nK+vj4eBw6dAiHDx/G77//jrVr16Jz58548OABTp06hR9//BFTpkzB+fPnFc/p3bu34hdydHQ0PDw80LZtW7x48UJpv3v27MH+/fuxf/9+nDp1CgsWLPioTBMnTsSpU6fw559/4ujRozh58iQuXryotE1AQAAiIiKwdetWXL58Gb1790aHDh1w+/ZtxTaZmZmYO3cuNm7ciLNnzyI1NRV9+/YFAHz55Zf49ttvUatWLSQnJyM5ORlffvml4rkzZ85Enz59cPnyZXTq1AkDBgxQen//ZMaMGVi6dCnCw8ORlJSEPn36IDg4GFu2bMGBAwdw9OhR/PLLL0rP2bBhA6pUqYLIyEiMGjUKI0aMQO/evdG0aVNcvHgR7du3x8CBA5WKDxGRVLHAiMirV6+wePFiBAUFYdCgQahevTqaN28Of3//f73PgoICrFu3Dq6urujatStat26NuLg4BAcHw9nZGUOGDIGzszNOnDgBADhz5gwiIyOxY8cONGjQAE5OTvjpp59gZGSEnTt3Ku03JCQEtWvXRosWLTBw4ECVu4wXJyMjA2vXrsVPP/2Etm3bws3NDRs2bEBeXp5im8TERKxfvx47duxAixYtUL16dUyYMAHNmzfH+vXrFdvl5uZi6dKl8PT0RP369bFhwwaEh4cjMjISurq60NfXh4aGBiwsLGBhYQFdXV3FcwcPHox+/frB0dER8+bNQ0ZGBiIjIz/6+zpnzhw0a9YM7u7u8PPzw6lTp7BixQq4u7ujRYsW+OKLLxTf0yJ169bFlClT4OTkhMmTJ0NHRwdVqlTB0KFD4eTkhGnTpuH58+e4fPnyR+cgIqqoeBWSiNy4cQPZ2dlo27Ztie3Tzs4OBgYGisfm5uZQV1eHmpqa0rInT54AAGJjY5GRkQETExOl/WRlZSE+Pv69+7W0tFTs40Pi4+ORk5ODxo0bK5ZVrlwZzs7OisdXrlxBfn4+atSoofTc7OxspVwaGhpo2LCh4nHNmjVhZGSEGzduoFGjRh/MUadOHcW/K1WqBLlc/lH5i3u+ubk59PT04ODgoLTs74Xo3eeoq6vDxMQEbm5uSs8B8Ek5iIgqKhYYEXn3CMHfFRWOd+/N+b67JL9LU1NT6bFMJit2WUFBAYC3R0gsLS2VxpsUMTIy+uB+i/bxX2VkZEBdXR3R0dFQV1dXWqevr18ir/Ff87/7/H/6nn7oNf++HwAl9n0kIhIznkISEScnJ+jq6hZ7KsbU1BQAkJycrFgWExNT4hk8PDyQkpICDQ0NODo6Kn1VqVLlP++/evXq0NTUVBpz8/LlS9y6dUvx2N3dHfn5+Xjy5IlKBgsLC8V2eXl5iIqKUjyOi4tDamoqXFze3t1VS0sL+fn5/zkzERGVPRYYEdHR0UFgYCAmTZqEjRs3Ij4+HufOncPatWvh6OgIa2trzJgxA7dv38aBAweKvXrnv/Ly8oKnpye6d++Oo0eP4t69ewgPD8cPP/ygVBb+LX19ffj5+WHixIk4fvw4rl69isGDByud0qpRowYGDBgAHx8f7Nq1CwkJCYiMjMT8+fNx4MABxXaampoYNWoUzp8/j+joaAwePBhNmjRRnD6ys7NDQkICYmJi8OzZM2RnZ//n/EREVDZ4CuldZTwz7r8xdepUaGhoYNq0aXj06BEsLS0xfPhwaGpq4vfff8eIESNQp04dNGzYEHPmzEHv3r1L9PVlMhkOHjyIH374AUOGDMHTp09hYWGBli1b/qerod71v//9DxkZGejatSsMDAzw7bffIi1N+b/N+vXrMWfOHHz77bd4+PAhqlSpgiZNmqBLly6KbfT09BAYGIj+/fvj4cOHaNGiBdauXatY36tXL+zatQutW7dGamoq1q9fj8GDB5fIeyAiotIlK3x30EQFkp6eDkNDQ6SlpUEulyute/PmDRISEmBvbw8dHR2BElJpEvNtE0oKP+dE4nejpkuJ79Pl5o0S32dJ+tDv73fxFBIRERGJDgsMlanExETo6+u/9ysxMVHoiB80fPjw92YfPny40PGIiCSDp5B4aL1M5eXlvffWBsDbgbUaGuV3aNaTJ0+Qnp5e7Dq5XA4zM7MyTvR+/JwTiR9PIb3/FFL5/U1BFVLR5ddiZWZmVq5KChGRVPEUEhEREYkOCwwRERGJDgsMERERiQ4LDBEREYkOCwwRERGJDq9CeofbBrcyfb0rg6588nMKCwvx9ddfY+fOnXj58iUMDQ0xePBgBAcHA3h7GfLYsWMxduzYkg1bCmQyGXbv3o3u3bsLHQUzZszAnj17SuUGmEREVPJ4BEZkDh8+jJCQEOzfvx/JycmoXbu20voLFy5g2LBhAqUTB5lMhj179ggdg4iI/gMegRGZ+Ph4WFpaomnTpgCgMumbqampELFU5OTkQEtLS+gYRERUQfEIjIgMHjwYo0aNQmJiImQyGezs7FS2sbOzU5xOAt4ebVixYgU6duwIXV1dODg4YOfOnYr19+7dg0wmw9atW9G0aVPo6Oigdu3aOHXqlNJ+r169io4dO0JfXx/m5uYYOHAgnj17pljfqlUrBAQEYOzYsahSpQq8vb0/+f0lJSWhT58+MDIyQuXKldGtWzelWXsHDx6M7t2746effoKlpSVMTEwwcuRI5ObmKrZJTk5G586doaurC3t7e2zZskXpe1L0PevRo0ex38NNmzbBzs4OhoaG6Nu3L169evVR2Vu1aoVRo0Zh7NixMDY2hrm5OVavXo3Xr19jyJAhMDAwgKOjIw4dOqR4zsmTJyGTyXDkyBG4u7tDV1cXbdq0wZMnT3Do0CG4uLhALpejf//+yMzM/OTvJxFRRcYCIyKLFy/GrFmzUK1aNSQnJ+PChQsf9bypU6eiV69eiI2NxYABA9C3b1/cuKE8lfTEiRPx7bff4tKlS/D09ETXrl3x/PlzAEBqairatGkDd3d3REVF4fDhw3j8+DH69OmjtI8NGzZAS0sLZ8+excqVKz/pveXm5sLb2xsGBgb466+/cPbsWejr66NDhw7IyclRbHfixAnEx8fjxIkT2LBhA0JCQhASEqJY7+Pjg0ePHuHkyZP4448/8Ouvv+LJkyeK9UXfs/Xr16t8D+Pj47Fnzx7s378f+/fvx6lTp7BgwYKPfg8bNmxAlSpVEBkZiVGjRmHEiBHo3bs3mjZtiosXL6J9+/YYOHCgShmZMWMGli5divDwcEWJCw4OxpYtW3DgwAEcPXoUv/zyyyd9P4mIKjoWGBExNDSEgYEB1NXVYWFh8dGni3r37g1/f3/UqFEDs2fPRoMGDVR+IQYEBKBXr15wcXHBihUrYGhoiLVr1wIAli5dCnd3d8ybNw81a9aEu7s71q1bhxMnTuDWrVuKfTg5OSEoKAjOzs5wdnb+pPe2bds2FBQUYM2aNXBzc4OLiwvWr1+PxMREnDx5UrGdsbExli5dipo1a6JLly7o3LkzwsLCAAA3b97EsWPHsHr1ajRu3BgeHh5Ys2YNsrKyFM8v+p4ZGRmpfA8LCgoQEhKC2rVro0WLFhg4cKBi3x+jbt26mDJlCpycnDB58mTo6OigSpUqGDp0KJycnDBt2jQ8f/4cly9fVnrenDlz0KxZM7i7u8PPzw+nTp3CihUr4O7ujhYtWuCLL77AiRMnPun7SURU0XEMjAR4enqqPP771TbvbqOhoYEGDRoojtLExsbixIkT0NfXV9l3fHw8atSoAQCoX7/+v84YGxuLO3fuwMDAQGn5mzdvEB8fr3hcq1YtqKurKx5bWlriypW3V3PFxcVBQ0MDHh4eivWOjo4wNjb+qAx2dnZKr29paal09Oaf1KlTR/FvdXV1mJiYwM3t/65sMzc3BwCVfb77PHNzc+jp6cHBwUFpWWRk5EfnICKSAhYY+kcZGRno2rUrfvzxR5V1lpaWin9XqlTpP71G/fr1sXnzZpV17x4l0dTUVFonk8lQUFDwr1/3Xf9138U9/91lMpkMAFT2+fdtSvM9EhFVFDyFJAHnzp1Teezi4vLebfLy8hAdHa3YxsPDA9euXYOdnR0cHR2Vvv5LaXmXh4cHbt++DTMzM5XXMDQ0/Kh9ODs7Iy8vD5cuXVIsu3PnDl6+fKm0naamJvLz80skNxERCYMFRgJ27NiBdevW4datW5g+fToiIyMREBCgtM2yZcuwe/du3Lx5EyNHjsTLly/h6+sLABg5ciRevHiBfv364cKFC4iPj8eRI0cwZMiQEisCAwYMQJUqVdCtWzf89ddfSEhIwMmTJzF69Gg8ePDgo/ZRs2ZNeHl5YdiwYYiMjMSlS5cwbNgw6OrqKo5+AG9PFYWFhSElJUWl3BARkTjwFNI7/s3MuGIwc+ZMbN26Fd988w0sLS3x+++/w9XVVWmbBQsWYMGCBYiJiYGjoyP27t2LKlWqAACsrKxw9uxZBAYGon379sjOzoatrS06dOgANbWS6cB6eno4ffo0AgMD0bNnT7x69QpVq1ZF27ZtIZfLP3o/GzduhJ+fH1q2bAkLCwvMnz8f165dg46OjmKbhQsXYvz48Vi9ejWqVq2qdKk2ERGJg6ywsLBQ6BClIT09HYaGhkhLS1P5BfjmzRskJCTA3t5e6RdbRfRP0/Xfu3cP9vb2uHTpEurVq1em2crCgwcPYG1tjWPHjqFt27ZCxylTUvqcE1VUN2q6/PNGn8jl5o1/3khAH/r9/S4egaEK5fjx48jIyICbmxuSk5MxadIk2NnZoWXLlkJHIyKiEsQxMFQqNm/eDH19/WK/atWqVWqvm5ubi++//x61atVCjx49YGpqipMnT6pc2fMpEhMT3/te9PX1kZiYWILvgIiIPgaPwFRw/3SG0M7O7h+3+Tc+//xzNG7cuNh1/6VM/BNvb+9/dRuDD7GysvrgXaqtrKxK9PWIiOifscBQqTAwMFCZlE6sNDQ04OjoKHQMIiJ6B08hERERkeiwwBAREZHosMAQERGR6LDAEBERkeiwwBAREZHo8Cqkd5TGjIcf8qmzIbZq1Qr16tVDcHBwiWUICQnB2LFjkZqaWmL7JCIiKm08AkNERESiwwJDREREosMCIzJ5eXkICAiAoaEhqlSpgqlTpypm0n358iV8fHxgbGwMPT09dOzYEbdv31Z6fkhICGxsbKCnp4cePXrg+fPninX37t2DmpoaoqKilJ4THBwMW1tbFBQUfDDbyZMnIZPJcOTIEbi7u0NXVxdt2rTBkydPcOjQIbi4uEAul6N///7IzMxUPO/w4cNo3rw5jIyMYGJigi5duiA+Pl6xPicnBwEBAbC0tISOjg5sbW0xf/58AG9nGp4xYwZsbGygra0NKysrjB49+qO+l8nJyejcuTN0dXVhb2+PLVu2wM7OrkRP0RERUelggRGZDRs2QENDA5GRkVi8eDF+/vlnrFmzBgAwePBgREVFYe/evYiIiEBhYSE6deqE3NxcAMD58+fh5+eHgIAAxMTEoHXr1pgzZ45i33Z2dvDy8sL69euVXnP9+vUYPHgw1NQ+7uMyY8YMLF26FOHh4UhKSkKfPn0QHByMLVu24MCBAzh69Ch++eUXxfavX7/G+PHjERUVhbCwMKipqaFHjx6KwrRkyRLs3bsX27dvR1xcHDZv3gw7OzsAwB9//IFFixZh1apVuH37Nvbs2QM3N7ePyunj44NHjx7h5MmT+OOPP/Drr7/iyZMnH/VcIiISFgfxioy1tTUWLVoEmUwGZ2dnXLlyBYsWLUKrVq2wd+9enD17Fk2bNgXw9oaK1tbW2LNnD3r37o3FixejQ4cOmDRpEgCgRo0aCA8Px+HDhxX79/f3x/Dhw/Hzzz9DW1sbFy9exJUrV/Dnn39+dMY5c+agWbNmAAA/Pz9MnjwZ8fHxcHBwAAB88cUXOHHiBAIDAwEAvXr1Unr+unXrYGpqiuvXr6N27dpITEyEk5MTmjdvDplMBltbW8W2iYmJsLCwgJeXFzQ1NWFjY4NGjRr9Y8abN2/i2LFjuHDhAho0aAAAWLNmDZycnD76fRIRkXB4BEZkmjRpAplMpnjs6emJ27dv4/r169DQ0FC6gaKJiQmcnZ1x48bbq51u3LihcoNFT09Ppcfdu3eHuro6du/eDeDtKafWrVsrjnh8jDp16ij+bW5uDj09PUV5KVr27pGO27dvo1+/fnBwcIBcLle8VtFdngcPHoyYmBg4Oztj9OjROHr0qOK5vXv3RlZWFhwcHDB06FDs3r0beXl5/5gxLi4OGhoa8PDwUCxzdHSEsbHxR79PIiISDgsMKdHS0oKPjw/Wr1+PnJwcbNmyBb6+vp+0j3fvNi2TyVTuPi2TyZTG03Tt2hUvXrzA6tWrcf78eZw/fx7A27EvAODh4YGEhATMnj0bWVlZ6NOnD7744gsAb49IxcXFYfny5dDV1cU333yDli1bKk6bERFRxcQCIzJFv9yLnDt3Dk5OTnB1dUVeXp7S+ufPnyMuLg6urq4AABcXl2Kf/3f+/v44duwYli9fjry8PPTs2bMU3olyxilTpqBt27ZwcXHBy5cvVbaTy+X48ssvsXr1amzbtg1//PEHXrx4AQDQ1dVF165dsWTJEpw8eRIRERG4cuXKB1/X2dkZeXl5uHTpkmLZnTt3in1tIiIqfzgGRmQSExMxfvx4fP3117h48SJ++eUXLFy4EE5OTujWrRuGDh2KVatWwcDAAN999x2qVq2Kbt26AQBGjx6NZs2a4aeffkK3bt1w5MgRpfEvRVxcXNCkSRMEBgbC19cXurq6pfZ+jI2NYWJigl9//RWWlpZITEzEd999p7TNzz//DEtLS7i7u0NNTQ07duyAhYUFjIyMEBISgvz8fDRu3Bh6enr47bffoKurqzROpjg1a9aEl5cXhg0bhhUrVkBTUxPffvstdHV1lU7RERFR+cQC845PnRlXCD4+PsjKykKjRo2grq6OMWPGYNiwYQDeXi00ZswYdOnSBTk5OWjZsiUOHjyoOIXTpEkTrF69GtOnT8e0adPg5eWFKVOmYPbs2Sqv4+fnh/Dw8E8+ffSp1NTUsHXrVowePRq1a9eGs7MzlixZglatWim2MTAwQFBQEG7fvg11dXU0bNgQBw8ehJqaGoyMjLBgwQKMHz8e+fn5cHNzw759+2BiYvKPr71x40b4+fmhZcuWsLCwwPz583Ht2jXo6OiU4jsmIqKSICssmkSkgklPT4ehoSHS0tIgl8uV1r158wYJCQmwt7fnL6v3mD17Nnbs2IHLly8LHaXMPHjwANbW1jh27Bjatm0rdJz/jJ9zIvErjVvclPc/1j/0+/td/2kMzIIFCyCTyTB27FjFsjdv3mDkyJEwMTGBvr4+evXqhcePHys9LzExEZ07d4aenh7MzMwwceJElStHTp48CQ8PD2hra8PR0REhISH/JSp9pIyMDFy9ehVLly7FqFGjhI5Tqo4fP469e/ciISEB4eHh6Nu3L+zs7NCyZUuhoxER0T/41wXmwoULWLVqldIlswAwbtw47Nu3Dzt27MCpU6fw6NEjpUGg+fn56Ny5M3JychAeHo4NGzYgJCQE06ZNU2yTkJCAzp07o3Xr1oiJicHYsWPh7++PI0eO/Nu49JECAgJQv359tGrVSuX00fDhw6Gvr1/s1/DhwwVKXLy//vrrvVn19fUBALm5ufj+++9Rq1Yt9OjRA6ampjh58qTKVVNERFT+/KtTSBkZGfDw8MDy5csxZ84cxR2S09LSYGpqii1btiguc7158yZcXFwQERGBJk2a4NChQ+jSpQsePXoEc3NzAMDKlSsRGBiIp0+fQktLC4GBgThw4ACuXr2qeM2+ffsiNTW12EGnxeEppJL35MkTpKenF7tOLpfDzMysjBO9X1ZWFh4+fPje9Y6OjmWYRhj8nBOJH08hvf8U0r8axDty5Eh07twZXl5eSlPRR0dHIzc3F15eXoplNWvWhI2NjaLAREREwM3NTVFeAMDb2xsjRozAtWvX4O7ujoiICKV9FG3z7qmqv8vOzkZ2drbi8ft+0dK/Z2ZmVq5Kyofo6upKoqQQEUnVJxeYrVu34uLFi7hw4YLKupSUFGhpacHIyEhpubm5OVJSUhTbvFteitYXrfvQNunp6cjKyir2st758+dj5syZn/ReKuj4ZSIA/HwTUcX2SWNgkpKSMGbMGGzevLncHZKePHky0tLSFF9JSUnv3bZojMO7d0QmqmiKPt8c00NEFdEnHYGJjo7GkydPlO4fk5+fj9OnT2Pp0qU4cuQIcnJykJqaqnQU5vHjx7CwsAAAWFhYIDIyUmm/RVcpvbvN369cevz4MeRy+XsnVdPW1oa2tvZHvQ91dXUYGRkp7sejp6fHycuowigsLERmZiaePHkCIyMjqKurCx2JiKjEfVKBadu2rcoU7UOGDEHNmjURGBgIa2traGpqIiwsTHGH4bi4OCQmJipuGujp6Ym5c+fiyZMnivEUoaGhkMvliinvPT09cfDgQaXXCQ0NVbnx4H9RVJbevakgUUViZGSk+JwTEVU0n1RgDAwMULt2baVllSpVgomJiWK5n58fxo8fj8qVK0Mul2PUqFHw9PREkyZNAADt27eHq6srBg4ciKCgIKSkpGDKlCkYOXKk4gjK8OHDsXTpUkyaNAm+vr44fvw4tm/fjgMHDpTEewbw9oaClpaWMDMz443/qMLR1NTkkRciqtBK/FYCixYtgpqaGnr16oXs7Gx4e3tj+fLlivXq6urYv38/RowYAU9PT1SqVAmDBg3CrFmzFNvY29vjwIEDGDduHBYvXoxq1aphzZo18Pb2Lum4UFdX5w96IiIikZHkrQSIiIjEgPPAlNKtBIiIiIiEwAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREolPiN3MkIhKrkr7vTHm/5wyRmPEIDBEREYkOj8CQIKR4h1UiIio5PAJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RERKLDAkNERESiwwJDREREosMCQ0RE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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": [ "
" ] @@ -720,7 +730,7 @@ "outputs": [ { "data": { - "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==", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAyUAAAGFCAYAAADjF1xYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+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": [ "
" ] @@ -829,6 +839,16 @@ "execution_count": 18, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:239: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" + ] + }, { "name": "stdout", "output_type": "stream", @@ -1010,77 +1030,77 @@ " \n", " \n", " 0\n", - " 3271.548077\n", - " Biscoe\n", - " 37.9\n", - " 18.6\n", - " 172.0\n", + " 3444.135246\n", + " Dream\n", + " 42.2\n", + " 18.5\n", + " 180.0\n", " FEMALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3150.0\n", + " 3550.0\n", " \n", " \n", " 1\n", - " 3224.661209\n", - " Biscoe\n", - " 37.7\n", - " 16.0\n", - " 183.0\n", - " FEMALE\n", + " 3735.564386\n", + " Torgersen\n", + " 39.1\n", + " 18.7\n", + " 181.0\n", + " MALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3075.0\n", + " 3750.0\n", " \n", " \n", " 2\n", - " 3395.403541\n", - " Biscoe\n", - " 34.5\n", - " 18.1\n", - " 187.0\n", - " FEMALE\n", + " 3879.370094\n", + " Dream\n", + " 40.9\n", + " 18.9\n", + " 184.0\n", + " MALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 2900.0\n", + " 3900.0\n", " \n", " \n", " 3\n", - " 3943.436439\n", + " 3787.401253\n", " Biscoe\n", - " 40.1\n", - " 18.9\n", - " 188.0\n", + " 38.2\n", + " 18.1\n", + " 185.0\n", " MALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 4300.0\n", + " 3950.0\n", " \n", " \n", " 4\n", - " 3986.662895\n", - " Biscoe\n", - " 41.4\n", - " 18.6\n", - " 191.0\n", - " MALE\n", + " 3435.804331\n", + " Dream\n", + " 36.0\n", + " 18.5\n", + " 186.0\n", + " FEMALE\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " 3700.0\n", + " 3100.0\n", " \n", " \n", "\n", "" ], "text/plain": [ - " 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", + " predicted_body_mass_g island culmen_length_mm culmen_depth_mm \\\n", + "0 3444.135246 Dream 42.2 18.5 \n", + "1 3735.564386 Torgersen 39.1 18.7 \n", + "2 3879.370094 Dream 40.9 18.9 \n", + "3 3787.401253 Biscoe 38.2 18.1 \n", + "4 3435.804331 Dream 36.0 18.5 \n", "\n", " 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 " + "0 180.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3550.0 \n", + "1 181.0 MALE Adelie Penguin (Pygoscelis adeliae) 3750.0 \n", + "2 184.0 MALE Adelie Penguin (Pygoscelis adeliae) 3900.0 \n", + "3 185.0 MALE Adelie Penguin (Pygoscelis adeliae) 3950.0 \n", + "4 186.0 FEMALE Adelie Penguin (Pygoscelis adeliae) 3100.0 " ] }, "execution_count": 21, @@ -1145,12 +1165,12 @@ " \n", " \n", " 0\n", - " 231.914252\n", - " 78873.600421\n", - " 0.005172\n", - " 178.724985\n", - " 0.890549\n", - " 0.890566\n", + " 212.800303\n", + " 72655.272611\n", + " 0.004369\n", + " 144.426983\n", + " 0.877546\n", + " 0.877991\n", " \n", " \n", "\n", @@ -1159,10 +1179,10 @@ ], "text/plain": [ " mean_absolute_error mean_squared_error mean_squared_log_error \\\n", - " 231.914252 78873.600421 0.005172 \n", + " 212.800303 72655.272611 0.004369 \n", "\n", " median_absolute_error r2_score explained_variance \n", - " 178.724985 0.890549 0.890566 \n", + " 144.426983 0.877546 0.877991 \n", "\n", "[1 rows x 6 columns]" ] @@ -1191,7 +1211,7 @@ { "data": { "text/plain": [ - "np.float64(0.8905492944632485)" + "np.float64(0.8775458183087934)" ] }, "execution_count": 23, @@ -1300,18 +1320,107 @@ "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 [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." + "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." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/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", + "/home/swast/src/github.com/googleapis/python-bigquery-dataframes/bigframes/core/array_value.py:263: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\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
1BQML stands for **BigQuery Machine Learning**....<NA>What is BQML?
0BigQuery is a fully managed, serverless data w...<NA>What is BigQuery?
2BigQuery DataFrames are a Python library that ...<NA>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", + "1 BQML stands for **BigQuery Machine Learning**.... \n", + "0 BigQuery is a fully managed, serverless data w... \n", + "2 BigQuery DataFrames are a Python library that ... \n", + "\n", + " ml_generate_text_rai_result ml_generate_text_status \\\n", + "1 \n", + "0 \n", + "2 \n", + "\n", + " prompt \n", + "1 What is BQML? \n", + "0 What is BigQuery? \n", + "2 What is BigQuery DataFrames? \n", + "\n", + "[3 rows x 4 columns]" + ] + }, + "execution_count": 25, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# from bigframes.ml.llm import GeminiTextGenerator\n", "\n", - "# model = GeminiTextGenerator(model_name=\"gemini-2.5-flash\")\n", + "# model = GeminiTextGenerator(model_name=\"gemini-2.0-flash-001\")\n", "\n", "# pred = model.predict(df)\n", "# pred" @@ -1326,9 +1435,40 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": null, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/markdown": [ + "BigQuery DataFrames are a Python library that provides a Pandas-like interface for interacting with BigQuery data. Instead of loading entire datasets into memory (which is impossible for very large BigQuery tables), BigQuery DataFrames allow you to work with BigQuery data in a way that feels familiar if you've used Pandas, but leverages BigQuery's processing power for efficiency. This means you can perform data analysis and manipulation on datasets that are too large for Pandas to handle directly.\n", + "\n", + "Key features and characteristics include:\n", + "\n", + "* **Lazy Evaluation:** BigQuery DataFrames don't load the entire dataset into memory. Operations are expressed as queries that are executed in BigQuery only when necessary (e.g., when you call `.to_dataframe()` to materialize a result, or when you explicitly trigger execution). This significantly reduces memory consumption.\n", + "\n", + "* **Pandas-like API:** The library aims for a familiar API similar to Pandas. You can use many of the same functions and methods you would use with Pandas DataFrames, such as filtering, selecting columns, aggregations, and joining.\n", + "\n", + "* **Integration with BigQuery:** The library seamlessly integrates with BigQuery. It allows you to read data from BigQuery tables and write data back to BigQuery.\n", + "\n", + "* **Scalability:** Because the processing happens in BigQuery, BigQuery DataFrames can scale to handle datasets of virtually any size. It's designed to efficiently process terabytes or even petabytes of data.\n", + "\n", + "* **Performance:** While providing a user-friendly interface, BigQuery DataFrames leverages BigQuery's optimized query engine for fast execution of operations.\n", + "\n", + "* **SQL integration:** While providing a Pythonic interface, you can easily incorporate SQL queries directly within the DataFrame operations providing flexibility and control over the data manipulation.\n", + "\n", + "\n", + "**In short:** BigQuery DataFrames provide a powerful and efficient way to work with large BigQuery datasets using a familiar Pandas-like syntax without the memory limitations of loading the entire dataset into local memory. They bridge the gap between the ease of use of Pandas and the scalability of BigQuery.\n" + ], + "text/plain": [ + "" + ] + }, + "execution_count": 26, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "# import IPython.display\n", "\n", @@ -1399,7 +1539,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.16" + "version": "3.12.6" } }, "nbformat": 4, diff --git a/notebooks/getting_started/getting_started_bq_dataframes.ipynb b/notebooks/getting_started/getting_started_bq_dataframes.ipynb index f9fb950c534..384f3b9c106 100644 --- a/notebooks/getting_started/getting_started_bq_dataframes.ipynb +++ b/notebooks/getting_started/getting_started_bq_dataframes.ipynb @@ -137,112 +137,11 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": { "id": "mfPoOwPLGpSr" }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "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" - ] - } - ], + "outputs": [], "source": [ "!pip install bigframes" ] @@ -331,9 +230,20 @@ "metadata": { "id": "oM1iC_MfAts1" }, - "outputs": [], + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Updated property [core/project].\n" + ] + } + ], "source": [ - "PROJECT_ID = \"\" # @param {type:\"string\"}" + "PROJECT_ID = \"\" # @param {type:\"string\"}\n", + "\n", + "# Set the project id\n", + "! gcloud config set project {PROJECT_ID}" ] }, { @@ -471,13 +381,7 @@ "# 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", - "# 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" + "bpd.options.bigquery.location = REGION" ] }, { @@ -528,7 +432,20 @@ "metadata": { "id": "Vyex9BQI-BNa" }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "Query job badadf0b-27c8-4dac-a468-be3c40745538 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# bq_df_sample = bpd.read_gbq(\"bigquery-samples.wikipedia_pageviews.200809h\")" ] @@ -560,6 +477,18 @@ "id": "XfGq5apK-D_e" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job c8669c7f-bca3-4f54-b354-8e57b3321f5a is DONE. 34.9 GB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -787,7 +716,20 @@ "metadata": { "id": "EDAaIwHpQCDZ" }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "Load job 93903930-10b8-48b8-b41b-3da54917b281 is DONE. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "# If order is not important, use the \"bigquery\" engine to\n", "# allow BigQuery DataFrames to read directly from GCS.\n", @@ -810,6 +752,18 @@ "id": "_gPD0Zn1Stdb" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job 17f58b5c-88b2-4b26-8d0d-cc3d9a979a06 is DONE. 28.9 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -842,53 +796,53 @@ " \n", " \n", " \n", - " 41\n", - " Gentoo penguin (Pygoscelis papua)\n", - " Biscoe\n", - " 49.8\n", - " 16.8\n", - " 230\n", - " 5700\n", - " MALE\n", + " 78\n", + " Chinstrap penguin (Pygoscelis antarctica)\n", + " Dream\n", + " 47.0\n", + " 17.3\n", + " 185\n", + " 3700\n", + " FEMALE\n", " \n", " \n", - " 73\n", - " Gentoo penguin (Pygoscelis papua)\n", + " 130\n", + " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 46.8\n", - " 16.1\n", - " 215\n", - " 5500\n", - " MALE\n", + " 40.5\n", + " 17.9\n", + " 187\n", + " 3200\n", + " FEMALE\n", " \n", " \n", - " 75\n", + " 84\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 49.6\n", - " 16.0\n", - " 225\n", - " 5700\n", - " MALE\n", + " 49.1\n", + " 14.5\n", + " 212\n", + " 4625\n", + " FEMALE\n", " \n", " \n", - " 93\n", + " 334\n", " Adelie Penguin (Pygoscelis adeliae)\n", " Biscoe\n", - " 35.5\n", - " 16.2\n", - " 195\n", - " 3350\n", - " FEMALE\n", + " 38.2\n", + " 20.0\n", + " 190\n", + " 3900\n", + " MALE\n", " \n", " \n", - " 299\n", + " 67\n", " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 52.0\n", - " 18.1\n", - " 201\n", - " 4050\n", + " 55.8\n", + " 19.8\n", + " 207\n", + " 4000\n", " MALE\n", " \n", " \n", @@ -897,18 +851,18 @@ ], "text/plain": [ " 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", + "78 Chinstrap penguin (Pygoscelis antarctica) Dream 47.0 \n", + "130 Adelie Penguin (Pygoscelis adeliae) Biscoe 40.5 \n", + "84 Gentoo penguin (Pygoscelis papua) Biscoe 49.1 \n", + "334 Adelie Penguin (Pygoscelis adeliae) Biscoe 38.2 \n", + "67 Chinstrap penguin (Pygoscelis antarctica) Dream 55.8 \n", "\n", " 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 " + "78 17.3 185 3700 FEMALE \n", + "130 17.9 187 3200 FEMALE \n", + "84 14.5 212 4625 FEMALE \n", + "334 20.0 190 3900 MALE \n", + "67 19.8 207 4000 MALE " ] }, "execution_count": 15, @@ -982,6 +936,18 @@ "id": "oP1NIAmUBjop" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job 55aa9cc4-29b6-4052-aae4-5499dc5f1168 is DONE. 28.9 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/plain": [ @@ -1026,6 +992,18 @@ "id": "IBuo-d6dWfsA" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job 7b2ff811-1563-4ac4-9d21-69f87e8e85bc is DONE. 28.9 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -1058,73 +1036,73 @@ " \n", " \n", " \n", - " 79\n", + " 12\n", " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 43.3\n", - " 14.0\n", + " 42.7\n", + " 13.7\n", " 208\n", - " 4575\n", + " 3950\n", " FEMALE\n", " \n", " \n", - " 118\n", - " Adelie Penguin (Pygoscelis adeliae)\n", + " 24\n", + " Gentoo penguin (Pygoscelis papua)\n", " Biscoe\n", - " 40.6\n", - " 18.6\n", - " 183\n", - " 3550\n", + " 45.0\n", + " 15.4\n", + " 220\n", + " 5050\n", " MALE\n", " \n", " \n", - " 213\n", + " 62\n", " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 42.1\n", - " 19.1\n", - " 195\n", - " 4000\n", + " Dream\n", + " 38.8\n", + " 20.0\n", + " 190\n", + " 3950\n", " MALE\n", " \n", " \n", - " 315\n", - " Adelie Penguin (Pygoscelis adeliae)\n", - " Torgersen\n", - " 38.7\n", - " 19.0\n", - " 195\n", - " 3450\n", - " FEMALE\n", - " \n", - " \n", - " 338\n", + " 123\n", " Chinstrap penguin (Pygoscelis antarctica)\n", " Dream\n", - " 40.9\n", - " 16.6\n", + " 42.5\n", + " 17.3\n", " 187\n", - " 3200\n", + " 3350\n", " FEMALE\n", " \n", + " \n", + " 27\n", + " Adelie Penguin (Pygoscelis adeliae)\n", + " Dream\n", + " 44.1\n", + " 19.7\n", + " 196\n", + " 4400\n", + " MALE\n", + " \n", " \n", "\n", "" ], "text/plain": [ - " 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", + " species island culmen_length_mm \\\n", + "12 Gentoo penguin (Pygoscelis papua) Biscoe 42.7 \n", + "24 Gentoo penguin (Pygoscelis papua) Biscoe 45.0 \n", + "62 Adelie Penguin (Pygoscelis adeliae) Dream 38.8 \n", + "123 Chinstrap penguin (Pygoscelis antarctica) Dream 42.5 \n", + "27 Adelie Penguin (Pygoscelis adeliae) Dream 44.1 \n", "\n", " 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 " + "12 13.7 208 3950 FEMALE \n", + "24 15.4 220 5050 MALE \n", + "62 20.0 190 3950 MALE \n", + "123 17.3 187 3350 FEMALE \n", + "27 19.7 196 4400 MALE " ] }, "execution_count": 18, @@ -1174,6 +1152,18 @@ "id": "6i6HkFJZa8na" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job b396baed-6242-4478-9092-f5e86811b045 is DONE. 31.7 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/plain": [ @@ -1181,10 +1171,10 @@ "279 3150\n", "34 3400\n", "96 3600\n", - "208 3950\n", "18 3800\n", - "64 2850\n", + "208 3950\n", "310 3175\n", + "64 2850\n", "118 3550\n", "2 3075\n", "Name: body_mass_g, dtype: Int64" @@ -1219,7 +1209,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "average_body_mass: 4201.754385964914\n" + "average_body_mass: 4201.754385964913\n" ] } ], @@ -1244,6 +1234,18 @@ "id": "4PyKMR61-Mjy" }, "outputs": [ + { + "data": { + "text/html": [ + "Query job fef05ee2-9690-41a4-bd35-7cded77310f2 is DONE. 15.6 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -1329,6 +1331,20 @@ "Running your own Python functions (or being able to bring your packages) and using them at scale is a challenge many data scientists face. BigQuery DataFrames makes it easy to deploy [remote functions](https://cloud.google.com/python/docs/reference/bigframes/latest/bigframes.pandas#bigframes_pandas_remote_function) that run scalar Python functions at BigQuery scale. These functions are persisted as [BigQuery remote functions](https://cloud.google.com/bigquery/docs/remote-functions) that you can then re-use." ] }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "# Python 3.13 is not yet a supported runtime for remote functions.\n", + "# See: https://cloud.google.com/functions/docs/runtime-support#python for the supported runtimes.\n", + "if sys.version_info >= (3, 13, 0):\n", + " sys.exit(0)" + ] + }, { "cell_type": "markdown", "metadata": { @@ -1350,7 +1366,20 @@ "metadata": { "id": "rSWTOG-vb2Fc" }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "Query job c7b6c009-d2c4-4739-a6f8-5ef51e6b1851 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "@bpd.remote_function(cloud_function_service_account=\"default\")\n", "def get_bucket(num: float) -> str:\n", @@ -1381,8 +1410,8 @@ "name": "stdout", "output_type": "stream", "text": [ - "Cloud Function Name projects/bigframes-dev/locations/us-central1/functions/bigframes-sessioncf7a5d-aa59468b9d6c757c1256e46c9f71ebe3\n", - "Remote Function Name bigframes-dev._63cfa399614a54153cc386c27d6c0c6fdb249f9e.bigframes_sessioncf7a5d_aa59468b9d6c757c1256e46c9f71ebe3\n" + "Cloud Function Name projects/bigframes-dev/locations/us-central1/functions/bigframes-sessiondf1983-1d02aa9bc80939ba72e7ff69e37e27c8\n", + "Remote Function Name bigframes-dev._f36a8f778c434a1ec421979eaa3bf562a8561e38.bigframes_sessiondf1983_1d02aa9bc80939ba72e7ff69e37e27c8\n" ] } ], @@ -1456,19 +1485,14 @@ " at_or_above_3500\n", " \n", " \n", - " 208\n", - " 3950\n", - " at_or_above_3500\n", - " \n", - " \n", " 18\n", " 3800\n", " at_or_above_3500\n", " \n", " \n", - " 64\n", - " 2850\n", - " below_3500\n", + " 208\n", + " 3950\n", + " at_or_above_3500\n", " \n", " \n", " 310\n", @@ -1476,6 +1500,11 @@ " below_3500\n", " \n", " \n", + " 64\n", + " 2850\n", + " below_3500\n", + " \n", + " \n", " 118\n", " 3550\n", " at_or_above_3500\n", @@ -1495,10 +1524,10 @@ "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", + "208 3950 at_or_above_3500\n", "310 3175 below_3500\n", + "64 2850 below_3500\n", "118 3550 at_or_above_3500\n", "2 3075 below_3500" ] @@ -1629,7 +1658,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.16" + "version": "3.10.15" } }, "nbformat": 4, diff --git a/notebooks/getting_started/magics.ipynb b/notebooks/getting_started/magics.ipynb deleted file mode 100644 index 1f2cf7a409b..00000000000 --- a/notebooks/getting_started/magics.ipynb +++ /dev/null @@ -1,406 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "91edcf7b", - "metadata": {}, - "source": [ - "# %%bqsql cell magics\n", - "\n", - "The BigQuery DataFrames (aka BigFrames) package provides a `%%bqsql` cell magics for Jupyter environments.\n", - "\n", - "To use it, first activate the extension:" - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "98cd0489", - "metadata": {}, - "outputs": [], - "source": [ - "%load_ext bigframes" - ] - }, - { - "cell_type": "markdown", - "id": "f18fdc63", - "metadata": {}, - "source": [ - "Now, use the magics by including SQL in the body." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "269c5862", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes. [Job bigframes-dev:US.job_UVe7FsupxF3CbYuLcLT7fpw9dozg details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "1e2fb7b019754d31b11323a054f97f47", - "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
0HIF1999Ariana10
1HIF2002Jordyn10
2HIF2006Mya10
3HIF2010Jordyn10
4HIM1921Nobuo10
5HIM1925Ralph10
6HIM1926Hisao10
7HIM1927Moses10
8HIM1933Larry10
9HIM1933Alfredo10
\n", - "

10 rows × 5 columns

\n", - "
[5552452 rows x 5 columns in total]" - ], - "text/plain": [ - "state gender year name number\n", - " HI F 1999 Ariana 10\n", - " HI F 2002 Jordyn 10\n", - " HI F 2006 Mya 10\n", - " HI F 2010 Jordyn 10\n", - " HI M 1921 Nobuo 10\n", - " HI M 1925 Ralph 10\n", - " HI M 1926 Hisao 10\n", - " HI M 1927 Moses 10\n", - " HI M 1933 Larry 10\n", - " HI M 1933 Alfredo 10\n", - "...\n", - "\n", - "[5552452 rows x 5 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql\n", - "SELECT * FROM `bigquery-public-data.usa_names.usa_1910_2013`" - ] - }, - { - "cell_type": "markdown", - "id": "8771e10f", - "metadata": {}, - "source": [ - "The output DataFrame can be saved to a variable." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "30bb6327", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes. [Job bigframes-dev:US.c142adf3-cd95-42da-bbdc-c176b36b934f details]\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql mydf\n", - "SELECT * FROM `bigquery-public-data.usa_names.usa_1910_2013`" - ] - }, - { - "cell_type": "markdown", - "id": "533e2e9e", - "metadata": {}, - "source": [ - "You can chain cells together using format strings. DataFrame objects are automatically turned into table expressions." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "6a8a8123", - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 88.1 MB in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "c4889de9296440428de90defb5c58070", - "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", - "
total_countname
0304036Tracy
1293876Travis
2203784Troy
3150127Trevor
496397Tristan
589996Tracey
665546Trinity
750112Traci
849657Trenton
945692Trent
\n", - "

10 rows × 2 columns

\n", - "
[238 rows x 2 columns in total]" - ], - "text/plain": [ - " total_count name\n", - "0 304036 Tracy\n", - "1 293876 Travis\n", - "2 203784 Troy\n", - "3 150127 Trevor\n", - "4 96397 Tristan\n", - "5 89996 Tracey\n", - "6 65546 Trinity\n", - "7 50112 Traci\n", - "8 49657 Trenton\n", - "9 45692 Trent\n", - "...\n", - "\n", - "[238 rows x 2 columns]" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "%%bqsql\n", - "SELECT sum(number) as total_count, name\n", - "FROM {mydf}\n", - "WHERE name LIKE 'Tr%'\n", - "GROUP BY name\n", - "ORDER BY total_count DESC" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d2a17078", - "metadata": {}, - "outputs": [], - "source": [] - } - ], - "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.18" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} diff --git a/notebooks/getting_started/pandas_extensions.ipynb b/notebooks/getting_started/pandas_extensions.ipynb deleted file mode 100644 index c511eab9b4a..00000000000 --- a/notebooks/getting_started/pandas_extensions.ipynb +++ /dev/null @@ -1,160 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# BigQuery extension for pandas\n", - "\n", - "BigQuery DataFrames provides a pandas extension to execute BigQuery SQL scalar functions directly on pandas DataFrames." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "metadata": {}, - "outputs": [], - "source": [ - "import pandas as pd\n", - "import bigframes # This import registers the bigquery accessor." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "By default, BigQuery DataFrames selects a location to process data based on the\n", - "data location, but using a pandas object doesn't provide such informat. If\n", - "processing location is important to you, configure the location before using the\n", - "accessor." - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "metadata": {}, - "outputs": [], - "source": [ - "import bigframes.pandas as bpd\n", - "\n", - "bpd.reset_session()\n", - "bpd.options.bigquery.location = \"US\"" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Using `sql_scalar`\n", - "\n", - "The `bigquery.sql_scalar` method allows you to apply a SQL scalar function to a pandas DataFrame by converting it to BigFrames, executing the SQL in BigQuery, and returning the result as a pandas Series." - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "0 2.0\n", - "1 3.0\n", - "2 4.0\n", - "dtype: Float64" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = pd.DataFrame({\"a\": [1.5, 2.5, 3.5]})\n", - "result = df.bigquery.sql_scalar(\"ROUND({0}, 0)\")\n", - "result" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "You can also use multiple columns." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time.\n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - }, - { - "data": { - "text/plain": [ - "0 11\n", - "1 22\n", - "2 33\n", - "dtype: Int64" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "df = pd.DataFrame({\"a\": [1, 2, 3], \"b\": [10, 20, 30]})\n", - "result = df.bigquery.sql_scalar(\"{a} + {b}\")\n", - "result" - ] - } - ], - "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": 4 -} diff --git a/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb index 87ef9f6e96d..ebccb2c7548 100644 --- a/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb +++ b/notebooks/kaggle/bq_dataframes_ai_forecast.ipynb @@ -1,1741 +1 @@ -{ - "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 -} +{"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":"# BigQuery DataFrames (BigFrames) AI Forecast\n\nThis 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\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-18T19:16:10.449563Z","iopub.execute_input":"2025-08-18T19:16:10.449828Z","iopub.status.idle":"2025-08-18T19:16:10.618943Z","shell.execute_reply.started":"2025-08-18T19:16:10.449803Z","shell.execute_reply":"2025-08-18T19:16:10.617631Z"}},"outputs":[],"execution_count":1},{"cell_type":"code","source":"PROJECT = \"swast-scratch\" # replace with your project\n\n\nimport bigframes.pandas as bpd\nbpd.options.bigquery.project = PROJECT\nbpd.options.bigquery.ordering_mode = \"partial\" # Optional: partial ordering mode can accelerate executions and save costs\n\nimport bigframes.exceptions\nimport warnings\nwarnings.filterwarnings(\"ignore\", category=bigframes.exceptions.AmbiguousWindowWarning)","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:00.851472Z","iopub.execute_input":"2025-08-18T19:20:00.851870Z","iopub.status.idle":"2025-08-18T19:20:00.858175Z","shell.execute_reply.started":"2025-08-18T19:20:00.851842Z","shell.execute_reply":"2025-08-18T19:20:00.857098Z"}},"outputs":[],"execution_count":4},{"cell_type":"markdown","source":"## 1. Create a BigFrames DataFrames from BigQuery public data.","metadata":{}},{"cell_type":"code","source":"df = bpd.read_gbq(\"bigquery-public-data.san_francisco_bikeshare.bikeshare_trips\")\ndf","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:02.254706Z","iopub.execute_input":"2025-08-18T19:20:02.255184Z","iopub.status.idle":"2025-08-18T19:20:04.754064Z","shell.execute_reply.started":"2025-08-18T19:20:02.255149Z","shell.execute_reply":"2025-08-18T19:20:04.752940Z"}},"outputs":[{"name":"stderr","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\nincompatibilies with previous reads of this table. To read the latest\nversion, set `use_cache=False` or close the current session with\nSession.close() or bigframes.pandas.close_session().\n return method(*args, **kwargs)\n","output_type":"stream"},{"execution_count":5,"output_type":"execute_result","data":{"text/plain":" trip_id duration_sec start_date \\\n201802092135083596 788 2018-02-09 21:35:08+00:00 \n201708152357422491 965 2017-08-15 23:57:42+00:00 \n201802281657253632 560 2018-02-28 16:57:25+00:00 \n201711170046091337 497 2017-11-17 00:46:09+00:00 \n201802201913231257 596 2018-02-20 19:13:23+00:00 \n201708242325001279 1341 2017-08-24 23:25:00+00:00 \n201801161800473291 489 2018-01-16 18:00:47+00:00 \n 20180408155601183 1105 2018-04-08 15:56:01+00:00 \n201803141857032204 619 2018-03-14 18:57:03+00:00 \n201708192053311490 743 2017-08-19 20:53:31+00:00 \n201711181823281960 353 2017-11-18 18:23:28+00:00 \n 20170810204454839 1256 2017-08-10 20:44:54+00:00 \n201801171656553504 500 2018-01-17 16:56:55+00:00 \n201801111613101305 858 2018-01-11 16:13:10+00:00 \n201802241826551215 1235 2018-02-24 18:26:55+00:00 \n201803091621483450 857 2018-03-09 16:21:48+00:00 \n201801021932232717 914 2018-01-02 19:32:23+00:00 \n201803161910283751 564 2018-03-16 19:10:28+00:00 \n 20171212152403227 854 2017-12-12 15:24:03+00:00 \n201803131437033724 917 2018-03-13 14:37:03+00:00 \n201712061755593426 519 2017-12-06 17:55:59+00:00 \n 20180404210034451 366 2018-04-04 21:00:34+00:00 \n201801231907161787 626 2018-01-23 19:07:16+00:00 \n201708271057061157 973 2017-08-27 10:57:06+00:00 \n201709071348372074 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 \nEl 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 ... \\\n10th Ave at E 15th St 222 3596 ... \n10th Ave at E 15th St 222 2491 ... \n10th Ave at E 15th St 222 3632 ... \n10th Ave at E 15th St 222 1337 ... \n10th Ave at E 15th St 222 1257 ... \n10th Ave at E 15th St 222 1279 ... \n10th Ave at E 15th St 222 3291 ... \n10th Ave at E 15th St 222 183 ... \n10th Ave at E 15th St 222 2204 ... \n10th Ave at E 15th St 222 1490 ... \n10th Ave at E 15th St 222 1960 ... \n10th Ave at E 15th St 222 839 ... \n10th Ave at E 15th St 222 3504 ... \n10th Ave at E 15th St 222 1305 ... \n10th Ave at E 15th St 222 1215 ... \n10th Ave at E 15th St 222 3450 ... \n10th Ave at E 15th St 222 2717 ... \n10th Ave at E 15th St 222 3751 ... \n10th Ave at E 15th St 222 227 ... \n10th Ave at E 15th St 222 3724 ... \n10th Ave at E 15th St 222 3426 ... \n10th Ave at E 15th St 222 451 ... \n10th Ave at E 15th St 222 1787 ... \n10th Ave at E 15th St 222 1157 ... \n10th Ave at E 15th St 222 2074 ... \n\nc_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 \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \nPOINT (-122.24878 37.79271) \n...\n\n[1947417 rows x 21 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 \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \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]"},"metadata":{}}],"execution_count":5},{"cell_type":"markdown","source":"## 2. Preprocess Data\n\nOnly take the `start_date` after 2018 and the \"Subscriber\" category as input. `start_date` are truncated to each hour.","metadata":{}},{"cell_type":"code","source":"df = df[df[\"start_date\"] >= \"2018-01-01\"]\ndf = df[df[\"subscriber_type\"] == \"Subscriber\"]\ndf[\"trip_hour\"] = df[\"start_date\"].dt.floor(\"h\")\ndf = df[[\"trip_hour\", \"trip_id\"]]","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:44.397712Z","iopub.execute_input":"2025-08-18T19:20:44.398876Z","iopub.status.idle":"2025-08-18T19:20:44.421504Z","shell.execute_reply.started":"2025-08-18T19:20:44.398742Z","shell.execute_reply":"2025-08-18T19:20:44.420509Z"}},"outputs":[],"execution_count":6},{"cell_type":"markdown","source":"Group and count each hour's num of trips.","metadata":{}},{"cell_type":"code","source":"df_grouped = df.groupby(\"trip_hour\").count()\ndf_grouped = df_grouped.reset_index().rename(columns={\"trip_id\": \"num_trips\"})\ndf_grouped","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:20:57.499571Z","iopub.execute_input":"2025-08-18T19:20:57.500413Z","iopub.status.idle":"2025-08-18T19:21:02.999663Z","shell.execute_reply.started":"2025-08-18T19:20:57.500376Z","shell.execute_reply":"2025-08-18T19:21:02.998792Z"}},"outputs":[{"output_type":"display_data","data":{"text/plain":"","text/html":"Query job e3df71d2-9248-491a-8e5f-4bb5bfedb686 is DONE. 58.7 MB processed. Open Job"},"metadata":{}},{"execution_count":7,"output_type":"execute_result","data":{"text/plain":" trip_hour num_trips\n2018-01-01 00:00:00+00:00 20\n2018-01-01 01:00:00+00:00 25\n2018-01-01 02:00:00+00:00 13\n2018-01-01 03:00:00+00:00 11\n2018-01-01 05:00:00+00:00 4\n2018-01-01 06:00:00+00:00 8\n2018-01-01 07:00:00+00:00 8\n2018-01-01 08:00:00+00:00 20\n2018-01-01 09:00:00+00:00 30\n2018-01-01 10:00:00+00:00 41\n2018-01-01 11:00:00+00:00 45\n2018-01-01 12:00:00+00:00 54\n2018-01-01 13:00:00+00:00 57\n2018-01-01 14:00:00+00:00 68\n2018-01-01 15:00:00+00:00 86\n2018-01-01 16:00:00+00:00 72\n2018-01-01 17:00:00+00:00 72\n2018-01-01 18:00:00+00:00 47\n2018-01-01 19:00:00+00:00 32\n2018-01-01 20:00:00+00:00 34\n2018-01-01 21:00:00+00:00 27\n2018-01-01 22:00:00+00:00 15\n2018-01-01 23:00:00+00:00 6\n2018-01-02 00:00:00+00:00 2\n2018-01-02 01:00:00+00:00 1\n...\n\n[2842 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 \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \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]"},"metadata":{}}],"execution_count":7},{"cell_type":"markdown","source":"## 3. Make forecastings for next 1 week with DataFrames.ai.forecast API","metadata":{}},{"cell_type":"code","source":"# Using all the data except the last week (2842-168) for training. And predict the last week (168).\nresult = df_grouped.head(2842-168).ai.forecast(timestamp_column=\"trip_hour\", data_column=\"num_trips\", horizon=168) \nresult","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:22:58.943589Z","iopub.execute_input":"2025-08-18T19:22:58.944068Z","iopub.status.idle":"2025-08-18T19:23:11.364356Z","shell.execute_reply.started":"2025-08-18T19:22:58.944036Z","shell.execute_reply":"2025-08-18T19:23:11.363152Z"}},"outputs":[{"output_type":"display_data","data":{"text/plain":"","text/html":"Query job 3f1225a8-b80b-4dfa-a7cf-94b93e7c18c2 is DONE. 68.2 kB processed. Open Job"},"metadata":{}},{"execution_count":8,"output_type":"execute_result","data":{"text/plain":" forecast_timestamp forecast_value confidence_level \\\n2018-04-24 12:00:00+00:00 144.577728 0.95 \n2018-04-25 00:00:00+00:00 54.215515 0.95 \n2018-04-26 05:00:00+00:00 8.140533 0.95 \n2018-04-26 14:00:00+00:00 198.744949 0.95 \n2018-04-27 02:00:00+00:00 9.91806 0.95 \n2018-04-29 03:00:00+00:00 32.063339 0.95 \n2018-04-27 04:00:00+00:00 25.757111 0.95 \n2018-04-30 06:00:00+00:00 89.808456 0.95 \n2018-04-30 02:00:00+00:00 -10.584175 0.95 \n2018-04-30 05:00:00+00:00 18.118111 0.95 \n2018-04-24 07:00:00+00:00 359.036957 0.95 \n2018-04-25 10:00:00+00:00 227.272049 0.95 \n2018-04-27 15:00:00+00:00 208.631363 0.95 \n2018-04-25 13:00:00+00:00 159.799911 0.95 \n2018-04-26 12:00:00+00:00 190.226944 0.95 \n2018-04-24 04:00:00+00:00 11.162338 0.95 \n2018-04-24 14:00:00+00:00 136.70816 0.95 \n2018-04-28 21:00:00+00:00 65.308899 0.95 \n2018-04-29 20:00:00+00:00 71.788849 0.95 \n2018-04-30 15:00:00+00:00 142.560944 0.95 \n2018-04-26 18:00:00+00:00 533.783813 0.95 \n2018-04-28 03:00:00+00:00 25.379761 0.95 \n2018-04-30 12:00:00+00:00 158.313385 0.95 \n2018-04-25 07:00:00+00:00 358.756592 0.95 \n2018-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\nai_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]","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]"},"metadata":{}}],"execution_count":8},{"cell_type":"markdown","source":"# 4. Process the raw result and draw a line plot along with the training data","metadata":{}},{"cell_type":"code","source":"result = result.sort_values(\"forecast_timestamp\")\nresult = result[[\"forecast_timestamp\", \"forecast_value\"]]\nresult = result.rename(columns={\"forecast_timestamp\": \"trip_hour\", \"forecast_value\": \"num_trips_forecast\"})\ndf_all = bpd.concat([df_grouped, result])\ndf_all = df_all.tail(672) # 4 weeks","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:27:08.305886Z","iopub.execute_input":"2025-08-18T19:27:08.306367Z","iopub.status.idle":"2025-08-18T19:27:08.318514Z","shell.execute_reply.started":"2025-08-18T19:27:08.306336Z","shell.execute_reply":"2025-08-18T19:27:08.317016Z"}},"outputs":[],"execution_count":9},{"cell_type":"markdown","source":"Plot a line chart and compare with the actual result.","metadata":{}},{"cell_type":"code","source":"df_all = df_all.set_index(\"trip_hour\")\ndf_all.plot.line(figsize=(16, 8))","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-18T19:27:19.461164Z","iopub.execute_input":"2025-08-18T19:27:19.461528Z","iopub.status.idle":"2025-08-18T19:27:20.737558Z","shell.execute_reply.started":"2025-08-18T19:27:19.461497Z","shell.execute_reply":"2025-08-18T19:27:20.736422Z"}},"outputs":[{"execution_count":10,"output_type":"execute_result","data":{"text/plain":""},"metadata":{}},{"output_type":"display_data","data":{"text/plain":"
","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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\n"},"metadata":{}}],"execution_count":10},{"cell_type":"code","source":"","metadata":{"trusted":true},"outputs":[],"execution_count":null}]} diff --git a/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb b/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb index 1a7de9b837f..1c2e2b53a83 100644 --- a/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb +++ b/notebooks/kaggle/describe-product-images-with-bigframes-multimodal.ipynb @@ -1,1131 +1 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "876eb80c", - "metadata": { - "_cell_guid": "b1076dfc-b9ad-4769-8c92-a6c4dae69d19", - "_uuid": "8f2839f25d086af736a60e9eeb907d3b93b6e0e5" - }, - "source": [ - "# Describe product images with BigFrames multimodal DataFrames\n", - "\n", - "Based on notebook at https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/multimodal/multimodal_dataframe.ipynb\n", - "\n", - "This notebook is introducing BigFrames Multimodal features:\n", - "\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", - "\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": 1, - "id": "0506e15e", - "metadata": { - "trusted": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Requirement already satisfied: bigframes in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (2.39.0)\n", - "Requirement already satisfied: google-cloud-automl in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (2.19.0)\n", - "Requirement already satisfied: google-cloud-translate in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (3.26.0)\n", - "Requirement already satisfied: google-ai-generativelanguage in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (0.11.0)\n", - "Requirement already satisfied: tensorflow in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (2.21.0)\n", - "Requirement already satisfied: cloudpickle>=2.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (3.1.2)\n", - "Requirement already satisfied: fsspec>=2023.3.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2026.1.0)\n", - "Requirement already satisfied: gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2026.1.0)\n", - "Requirement already satisfied: geopandas>=0.12.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.1.3)\n", - "Requirement already satisfied: google-auth<3.0,>=2.15.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.49.1)\n", - "Requirement already satisfied: google-cloud-bigquery>=3.36.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (3.41.0)\n", - "Requirement already satisfied: google-cloud-bigquery-storage<3.0.0,>=2.30.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.37.0)\n", - "Requirement already satisfied: google-cloud-functions>=1.12.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.23.0)\n", - "Requirement already satisfied: google-cloud-bigquery-connection>=1.12.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.21.0)\n", - "Requirement already satisfied: google-cloud-resource-manager>=1.10.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.17.0)\n", - "Requirement already satisfied: google-cloud-storage>=2.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (3.10.1)\n", - "Requirement already satisfied: google-crc32c<2.0.0,>=1.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.8.0)\n", - "Requirement already satisfied: grpc-google-iam-v1>=0.14.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (0.14.4)\n", - "Requirement already satisfied: numpy>=1.24.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.4.4)\n", - "Requirement already satisfied: pandas>=1.5.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.3.3)\n", - "Requirement already satisfied: pandas-gbq>=0.26.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (0.34.1)\n", - "Requirement already satisfied: pyarrow>=15.0.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (21.0.0)\n", - "Requirement already satisfied: pydata-google-auth>=1.8.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.9.1)\n", - "Requirement already satisfied: requests>=2.27.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.33.1)\n", - "Requirement already satisfied: shapely>=1.8.5 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.1.2)\n", - "Requirement already satisfied: tabulate>=0.9 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (0.10.0)\n", - "Requirement already satisfied: humanize>=4.6.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (4.15.0)\n", - "Requirement already satisfied: matplotlib>=3.7.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (3.10.8)\n", - "Requirement already satisfied: db-dtypes>=1.4.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.5.1)\n", - "Requirement already satisfied: pyiceberg>=0.7.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (0.11.1)\n", - "Requirement already satisfied: atpublic<6,>=2.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (5.1)\n", - "Requirement already satisfied: python-dateutil<3,>=2.8.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2.9.0.post0)\n", - "Requirement already satisfied: pytz>=2022.7 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (2026.1.post1)\n", - "Requirement already satisfied: toolz<2,>=0.11 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (1.1.0)\n", - "Requirement already satisfied: typing-extensions<5,>=4.5.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (4.15.0)\n", - "Requirement already satisfied: rich<14,>=12.4.4 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from bigframes) (13.9.4)\n", - "Requirement already satisfied: google-api-core<3.0.0,>=2.11.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-api-core[grpc]<3.0.0,>=2.11.0->google-cloud-automl) (2.30.2)\n", - "Requirement already satisfied: grpcio<2.0.0,>=1.33.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-automl) (1.80.0)\n", - "Requirement already satisfied: proto-plus<2.0.0,>=1.22.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-automl) (1.27.2)\n", - "Requirement already satisfied: protobuf<8.0.0,>=4.25.8 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-automl) (6.33.6)\n", - "Requirement already satisfied: google-cloud-core<3.0.0,>=2.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-translate) (2.5.1)\n", - "Requirement already satisfied: absl-py>=1.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (2.4.0)\n", - "Requirement already satisfied: astunparse>=1.6.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (1.6.3)\n", - "Requirement already satisfied: flatbuffers>=25.9.23 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (25.12.19)\n", - "Requirement already satisfied: gast!=0.5.0,!=0.5.1,!=0.5.2,>=0.2.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (0.7.0)\n", - "Requirement already satisfied: google_pasta>=0.1.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (0.2.0)\n", - "Requirement already satisfied: libclang>=13.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (18.1.1)\n", - "Requirement already satisfied: opt_einsum>=2.3.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (3.4.0)\n", - "Requirement already satisfied: packaging in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (26.0)\n", - "Requirement already satisfied: setuptools in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (82.0.1)\n", - "Requirement already satisfied: six>=1.12.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (1.17.0)\n", - "Requirement already satisfied: termcolor>=1.1.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (3.3.0)\n", - "Requirement already satisfied: wrapt>=1.11.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (2.1.2)\n", - "Requirement already satisfied: keras>=3.12.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (3.14.0)\n", - "Requirement already satisfied: h5py<3.15.0,>=3.11.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (3.14.0)\n", - "Requirement already satisfied: ml_dtypes<1.0.0,>=0.5.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from tensorflow) (0.5.4)\n", - "Requirement already satisfied: wheel<1.0,>=0.23.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from astunparse>=1.6.0->tensorflow) (0.47.0)\n", - "Requirement already satisfied: aiohttp!=4.0.0a0,!=4.0.0a1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (3.13.5)\n", - "Requirement already satisfied: decorator>4.1.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (5.2.1)\n", - "Requirement already satisfied: google-auth-oauthlib in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (1.3.1)\n", - "Requirement already satisfied: google-cloud-storage-control in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (1.11.0)\n", - "Requirement already satisfied: pyogrio>=0.7.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from geopandas>=0.12.2->bigframes) (0.12.1)\n", - "Requirement already satisfied: pyproj>=3.5.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from geopandas>=0.12.2->bigframes) (3.7.2)\n", - "Requirement already satisfied: googleapis-common-protos<2.0.0,>=1.63.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-api-core<3.0.0,>=2.11.0->google-api-core[grpc]<3.0.0,>=2.11.0->google-cloud-automl) (1.74.0)\n", - "Requirement already satisfied: grpcio-status<2.0.0,>=1.33.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-api-core[grpc]<3.0.0,>=2.11.0->google-cloud-automl) (1.80.0)\n", - "Requirement already satisfied: pyasn1-modules>=0.2.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (0.4.2)\n", - "Requirement already satisfied: cryptography>=38.0.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-auth<3.0,>=2.15.0->bigframes) (46.0.7)\n", - "Requirement already satisfied: google-resumable-media<3.0.0,>=2.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-cloud-bigquery>=3.36.0->google-cloud-bigquery[bqstorage,pandas]>=3.36.0->bigframes) (2.8.2)\n", - "Requirement already satisfied: namex in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from keras>=3.12.0->tensorflow) (0.1.0)\n", - "Requirement already satisfied: optree in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from keras>=3.12.0->tensorflow) (0.19.0)\n", - "Requirement already satisfied: contourpy>=1.0.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (1.3.3)\n", - "Requirement already satisfied: cycler>=0.10 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (0.12.1)\n", - "Requirement already satisfied: fonttools>=4.22.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (4.62.1)\n", - "Requirement already satisfied: kiwisolver>=1.3.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (1.5.0)\n", - "Requirement already satisfied: pillow>=8 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (12.2.0)\n", - "Requirement already satisfied: pyparsing>=3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from matplotlib>=3.7.1->bigframes) (3.3.2)\n", - "Requirement already satisfied: tzdata>=2022.7 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pandas>=1.5.3->bigframes) (2026.1)\n", - "Requirement already satisfied: psutil>=5.9.8 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pandas-gbq>=0.26.1->bigframes) (7.2.2)\n", - "Requirement already satisfied: mmh3<6.0.0,>=4.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (5.2.1)\n", - "Requirement already satisfied: click<9.0.0,>=7.1.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (8.3.2)\n", - "Requirement already satisfied: strictyaml<2.0.0,>=1.7.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (1.7.3)\n", - "Requirement already satisfied: pydantic!=2.12.0,!=2.12.1,!=2.4.0,!=2.4.1,<3.0,>=2.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (2.12.5)\n", - "Requirement already satisfied: tenacity<10.0.0,>=8.2.3 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (9.1.4)\n", - "Requirement already satisfied: pyroaring<2.0.0,>=1.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (1.0.4)\n", - "Requirement already satisfied: cachetools<7.0,>=5.5 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (6.2.6)\n", - "Requirement already satisfied: zstandard<1.0.0,>=0.13.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyiceberg>=0.7.1->bigframes) (0.25.0)\n", - "Requirement already satisfied: charset_normalizer<4,>=2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from requests>=2.27.1->bigframes) (3.4.7)\n", - "Requirement already satisfied: idna<4,>=2.5 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from requests>=2.27.1->bigframes) (3.11)\n", - "Requirement already satisfied: urllib3<3,>=1.26 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from requests>=2.27.1->bigframes) (2.6.3)\n", - "Requirement already satisfied: certifi>=2023.5.7 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from requests>=2.27.1->bigframes) (2026.2.25)\n", - "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/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/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from rich<14,>=12.4.4->bigframes) (2.20.0)\n", - "Requirement already satisfied: aiohappyeyeballs>=2.5.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (2.6.1)\n", - "Requirement already satisfied: aiosignal>=1.4.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (1.4.0)\n", - "Requirement already satisfied: attrs>=17.3.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (26.1.0)\n", - "Requirement already satisfied: frozenlist>=1.1.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (1.8.0)\n", - "Requirement already satisfied: multidict<7.0,>=4.5 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (6.7.1)\n", - "Requirement already satisfied: propcache>=0.2.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (0.4.1)\n", - "Requirement already satisfied: yarl<2.0,>=1.17.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from aiohttp!=4.0.0a0,!=4.0.0a1->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (1.23.0)\n", - "Requirement already satisfied: cffi>=2.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from cryptography>=38.0.3->google-auth<3.0,>=2.15.0->bigframes) (2.0.0)\n", - "Requirement already satisfied: requests-oauthlib>=0.7.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from google-auth-oauthlib->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (2.0.0)\n", - "Requirement already satisfied: mdurl~=0.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from markdown-it-py>=2.2.0->rich<14,>=12.4.4->bigframes) (0.1.2)\n", - "Requirement already satisfied: pyasn1<0.7.0,>=0.6.1 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pyasn1-modules>=0.2.1->google-auth<3.0,>=2.15.0->bigframes) (0.6.3)\n", - "Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pydantic!=2.12.0,!=2.12.1,!=2.4.0,!=2.4.1,<3.0,>=2.0->pyiceberg>=0.7.1->bigframes) (0.7.0)\n", - "Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pydantic!=2.12.0,!=2.12.1,!=2.4.0,!=2.4.1,<3.0,>=2.0->pyiceberg>=0.7.1->bigframes) (2.41.5)\n", - "Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from pydantic!=2.12.0,!=2.12.1,!=2.4.0,!=2.4.1,<3.0,>=2.0->pyiceberg>=0.7.1->bigframes) (0.4.2)\n", - "Requirement already satisfied: pycparser in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from cffi>=2.0.0->cryptography>=38.0.3->google-auth<3.0,>=2.15.0->bigframes) (3.0)\n", - "Requirement already satisfied: oauthlib>=3.0.0 in /usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/.venv/lib/python3.13/site-packages (from requests-oauthlib>=0.7.0->google-auth-oauthlib->gcsfs!=2025.5.0,!=2026.2.0,!=2026.3.0,>=2023.3.0->bigframes) (3.3.1)\n", - "\n", - "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m A new release of pip is available: \u001b[0m\u001b[31;49m24.2\u001b[0m\u001b[39;49m -> \u001b[0m\u001b[32;49m26.1\u001b[0m\n", - "\u001b[1m[\u001b[0m\u001b[34;49mnotice\u001b[0m\u001b[1;39;49m]\u001b[0m\u001b[39;49m To update, run: \u001b[0m\u001b[32;49mpip install --upgrade pip\u001b[0m\n", - "Note: you may need to restart the kernel to use updated packages.\n" - ] - } - ], - "source": [ - "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " - ] - }, - { - "cell_type": "markdown", - "id": "c749e07c", - "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": 2, - "id": "5e00777d", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:17:14.873201Z", - "iopub.status.busy": "2025-08-18T20:17:14.872905Z", - "iopub.status.idle": "2025-08-18T20:17:14.946971Z", - "shell.execute_reply": "2025-08-18T20:17:14.945996Z", - "shell.execute_reply.started": "2025-08-18T20:17:14.873171Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "Not running on Kaggle, skipping Kaggle secrets initialization.\n" - ] - } - ], - "source": [ - "try:\n", - " 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)\n", - " print(\"Successfully authenticated using Kaggle secrets.\")\n", - "except ImportError:\n", - " print(\"Not running on Kaggle, skipping Kaggle secrets initialization.\")\n", - "except Exception as e:\n", - " print(f\"Could not initialize Kaggle secrets: {e}\")" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "b2e171de", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:17:25.574192Z", - "iopub.status.busy": "2025-08-18T20:17:25.573874Z", - "iopub.status.idle": "2025-08-18T20:17:45.102002Z", - "shell.execute_reply": "2025-08-18T20:17:45.101140Z", - "shell.execute_reply.started": "2025-08-18T20:17:25.574168Z" - }, - "trusted": true - }, - "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", - "LOCATION = \"us\" # replace with your location.\n", - "DATASET_ID = \"bigframes_samples\" # replace with your dataset ID.\n", - "OUTPUT_BUCKET = \"bigframes_blob_test\" # replace with your GCS bucket. \n", - "\n", - "FULL_CONNECTION_ID = f\"{PROJECT}.{LOCATION}.bigframes-default-connection\"\n", - "\n", - "import bigframes\n", - "# Setup project\n", - "bigframes.options.bigquery.project = PROJECT\n", - "bigframes.options.bigquery.location = LOCATION\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\n", - "import bigframes.bigquery as bbq\n", - "\n", - "def get_runtime_json_str(series, mode=\"R\", with_metadata=False):\n", - " \"\"\"Get runtime JSON from objectref.\"\"\"\n", - " s = bbq.obj.fetch_metadata(series) if with_metadata else series\n", - " runtime = bbq.obj.get_access_url(s, mode=mode)\n", - " return bbq.to_json_string(runtime)\n", - "\n", - "def get_metadata(series):\n", - " metadata_obj = bbq.obj.fetch_metadata(series)\n", - " return bbq.json_query(metadata_obj.struct.field(\"details\"), \"$.gcs_metadata\")\n", - "\n", - "def get_content_type(series):\n", - " return bbq.json_value(get_metadata(series), \"$.content_type\")\n", - "\n", - "def get_size(series):\n", - " return bbq.json_value(get_metadata(series), \"$.size\").astype(\"Int64\")\n", - "\n", - "def get_updated(series):\n", - " return bpd.to_datetime(bbq.json_value(get_metadata(series), \"$.updated\").astype(\"Int64\"), unit=\"us\", utc=True)\n", - "\n", - "from IPython.display import HTML, display\n", - "\n", - "def render_images(df):\n", - " \"\"\"Helper to display BigFrames DataFrame with rendered image previews.\"\"\"\n", - " import bigframes.pandas as bpd\n", - " import bigframes.bigquery as bbq\n", - " import bigframes\n", - " from bigframes import dtypes\n", - " import json\n", - " \n", - " if isinstance(df, bpd.Series):\n", - " df = df.to_frame()\n", - " \n", - " object_cols = [\n", - " col for col, dtype in zip(df.columns, df.dtypes)\n", - " if dtype == dtypes.OBJ_REF_DTYPE\n", - " ]\n", - " \n", - " if not object_cols:\n", - " display(df)\n", - " return\n", - "\n", - " limit = bigframes.options.display.max_rows or 10\n", - " view_df = df.head(limit)\n", - " \n", - " runtime_cols = {\n", - " col: get_runtime_json_str(view_df[col], mode=\"R\", with_metadata=False) \n", - " for col in object_cols\n", - " }\n", - " \n", - " pandas_json_df = bpd.DataFrame(runtime_cols).to_pandas()\n", - " final_pd = view_df.to_pandas()\n", - " \n", - " width = bigframes.options.display.blob_display_width or 300\n", - " IMAGE_EXTENSIONS = (\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\")\n", - " \n", - " def format_cell_html(raw_json):\n", - " if not raw_json:\n", - " return \"\"\n", - " try:\n", - " obj_rt = json.loads(raw_json)\n", - " if \"access_urls\" not in obj_rt:\n", - " err = obj_rt.get(\"errors\", [{\"message\": \"URL Generation Failed\"}])[0].get(\"message\")\n", - " return f'Error: {err}'\n", - " \n", - " uri = obj_rt.get(\"objectref\", {}).get(\"uri\", \"\")\n", - " url = obj_rt[\"access_urls\"][\"read_url\"]\n", - " \n", - " if uri and str(uri).lower().endswith(IMAGE_EXTENSIONS):\n", - " return f''\n", - " \n", - " return f'{uri if uri else \"view\"}'\n", - " except:\n", - " return \"Format Error\"\n", - "\n", - " for col in object_cols:\n", - " final_pd[col] = pandas_json_df[col].map(format_cell_html)\n", - " \n", - " display(HTML(final_pd.to_html(escape=False)))" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "d17afaf1", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:17:45.103530Z", - "iopub.status.busy": "2025-08-18T20:17:45.103249Z", - "iopub.status.idle": "2025-08-18T20:17:47.424586Z", - "shell.execute_reply": "2025-08-18T20:17:47.423762Z", - "shell.execute_reply.started": "2025-08-18T20:17:45.103499Z" - }, - "trusted": true - }, - "outputs": [], - "source": [ - "import gcsfs\n", - "import bigframes.bigquery as bbq\n", - "\n", - "# List files using gcsfs (public bucket)\n", - "fs = gcsfs.GCSFileSystem(anon=True)\n", - "uris = fs.glob(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*\")\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "# Read the URIs into a BigQuery DataFrame using UNNEST\n", - "# We take the first 5 for this example\n", - "df_image = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "\n", - "# Create the object reference column\n", - "df_image['image'] = bbq.obj.make_ref(df_image['uri'], authorizer=FULL_CONNECTION_ID)\n", - "df_image = df_image[['image']]" - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "3e84b922", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:17:47.425873Z", - "iopub.status.busy": "2025-08-18T20:17:47.425578Z", - "iopub.status.idle": "2025-08-18T20:18:07.919961Z", - "shell.execute_reply": "2025-08-18T20:18:07.918942Z", - "shell.execute_reply.started": "2025-08-18T20:17:47.425844Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
image
0
1
2
3
4
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame\n", - "df_image = df_image.head(5)\n", - "render_images(df_image)" - ] - }, - { - "cell_type": "markdown", - "id": "b0eaa73c", - "metadata": {}, - "source": [ - "# 2. Combine unstructured data with structured data\n", - "\n", - "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, - "id": "7d64fb54", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:18:07.922593Z", - "iopub.status.busy": "2025-08-18T20:18:07.921884Z", - "iopub.status.idle": "2025-08-18T20:18:35.549725Z", - "shell.execute_reply": "2025-08-18T20:18:35.548942Z", - "shell.execute_reply.started": "2025-08-18T20:18:07.922551Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
imageauthorcontent_typesizeupdated
0aliceimage/png7157662025-03-20 17:44:38+00:00
1bobimage/png11674062025-03-20 17:44:38+00:00
2bobimage/png11508922025-03-20 17:44:39+00:00
3aliceimage/png17365332025-03-20 17:44:39+00:00
4bobimage/png4397402025-03-20 17:44:39+00:00
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Combine unstructured data with structured data\n", - "df_image[\"author\"] = [\"alice\", \"bob\", \"bob\", \"alice\", \"bob\"] # type: ignore\n", - "df_image[\"content_type\"] = get_content_type(df_image[\"image\"])\n", - "df_image[\"size\"] = get_size(df_image[\"image\"])\n", - "df_image[\"updated\"] = get_updated(df_image[\"image\"])\n", - "render_images(df_image)" - ] - }, - { - "cell_type": "markdown", - "id": "a23ef0e4", - "metadata": {}, - "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, - "id": "ce102df0", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:18:55.300314Z", - "iopub.status.busy": "2025-08-18T20:18:55.299993Z", - "iopub.status.idle": "2025-08-18T20:19:09.154492Z", - "shell.execute_reply": "2025-08-18T20:19:09.153315Z", - "shell.execute_reply.started": "2025-08-18T20:18:55.300289Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
imageauthorcontent_typesizeupdated
0aliceimage/png7157662025-03-20 17:44:38+00:00
3aliceimage/png17365332025-03-20 17:44:39+00:00
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# filter images and display, you can also display audio and video types\n", - "filtered_df = df_image[df_image[\"author\"] == \"alice\"]\n", - "render_images(filtered_df)" - ] - }, - { - "cell_type": "markdown", - "id": "db2b3b12", - "metadata": {}, - "source": [ - "# 3. Conduct image transformations\n", - "\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, - "id": "283036f5", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:19:22.950652Z", - "iopub.status.busy": "2025-08-18T20:19:22.950277Z", - "iopub.status.idle": "2025-08-18T20:31:51.799997Z", - "shell.execute_reply": "2025-08-18T20:31:51.798840Z", - "shell.execute_reply.started": "2025-08-18T20:19:22.950625Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/pandas/__init__.py:211: PreviewWarning: udf is in preview.\n", - " return global_session.with_default_session(\n", - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dataframe.py:4695: FunctionAxisOnePreviewWarning: DataFrame.apply with parameter axis=1 scenario is in preview.\n", - " warnings.warn(msg, category=bfe.FunctionAxisOnePreviewWarning)\n", - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
imageblurred
0
1
2
3
4
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "@bpd.udf(\n", - " input_types=[str, str, int, int],\n", - " output_type=str,\n", - " dataset=DATASET_ID,\n", - " name=\"image_blur_kaggle\",\n", - " bigquery_connection=FULL_CONNECTION_ID,\n", - " packages=[\"opencv-python-headless\", \"numpy\", \"requests\"],\n", - ")\n", - "def image_blur(src_rt: str, dst_rt: str, kx: int, ky: int) -> str:\n", - " import json\n", - " import cv2 as cv\n", - " import numpy as np\n", - " import requests\n", - " \n", - " src_obj = json.loads(src_rt)\n", - " if \"access_urls\" not in src_obj:\n", - " raise ValueError(f\"Missing 'access_urls' in source object. Response: {src_obj}\")\n", - " src_url = src_obj[\"access_urls\"][\"read_url\"]\n", - " \n", - " response = requests.get(src_url, timeout=30)\n", - " response.raise_for_status()\n", - " \n", - " img = cv.imdecode(np.frombuffer(response.content, np.uint8), cv.IMREAD_UNCHANGED)\n", - " if img is None:\n", - " raise ValueError(\"cv.imdecode failed\")\n", - " \n", - " img_blurred = cv.blur(img, ksize=(int(kx), int(ky)))\n", - " success, encoded = cv.imencode(\".jpeg\", img_blurred)\n", - " \n", - " if not success:\n", - " raise ValueError(\"cv.imencode failed\")\n", - " \n", - " if dst_rt: # GCS Output Mode\n", - " dst_obj = json.loads(dst_rt)\n", - " if \"access_urls\" not in dst_obj:\n", - " raise ValueError(f\"Missing 'access_urls' in destination object. Response: {dst_obj}\")\n", - " dst_url = dst_obj[\"access_urls\"][\"write_url\"]\n", - " \n", - " requests.put(dst_url, data=encoded.tobytes(), headers={\"Content-Type\": \"image/jpeg\"}, timeout=30).raise_for_status()\n", - " return dst_obj[\"objectref\"][\"uri\"]\n", - " return \"\"\n", - "\n", - "def apply_transformation(series, dst_folder, udf, *args, verbose=False):\n", - " import os\n", - " dst_folder = os.path.join(dst_folder, \"\")\n", - " metadata = bbq.obj.fetch_metadata(series)\n", - " current_uri = metadata.struct.field(\"uri\")\n", - " dst_uri = current_uri.str.replace(r\"^.*\\/(.*)$\", rf\"{dst_folder}\\1\", regex=True)\n", - " \n", - " # Bypass synchronous validation via JSON initialization\n", - " dst_blob_df = bpd.DataFrame({\"uri\": dst_uri})\n", - " dst_blob_df[\"authorizer\"] = FULL_CONNECTION_ID\n", - " dst_blob = bbq.obj.make_ref(bbq.to_json(bbq.struct(dst_blob_df)))\n", - "\n", - " df_transform = bpd.DataFrame({\n", - " \"src_rt\": get_runtime_json_str(series, mode=\"R\"),\n", - " \"dst_rt\": get_runtime_json_str(dst_blob, mode=\"RW\"),\n", - " })\n", - " res = df_transform[[\"src_rt\", \"dst_rt\"]].apply(udf, axis=1, args=args)\n", - " \n", - " if verbose:\n", - " return res\n", - " \n", - " res_df = bpd.DataFrame({\"uri\": res})\n", - " res_df[\"authorizer\"] = FULL_CONNECTION_ID\n", - " return bbq.obj.make_ref(bbq.to_json(bbq.struct(res_df)))\n", - "\n", - "# Apply Blur Transformation\n", - "df_image[\"blurred\"] = apply_transformation(\n", - " df_image[\"image\"], f\"gs://{OUTPUT_BUCKET}/image_blur_transformed/\",\n", - " image_blur, 20, 20\n", - ")\n", - "render_images(df_image[[\"image\", \"blurred\"]])" - ] - }, - { - "cell_type": "markdown", - "id": "2d68a468", - "metadata": {}, - "source": [ - "# 4. Use LLM models to ask questions and generate embeddings on images" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "662054a0", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:36:13.954686Z", - "iopub.status.busy": "2025-08-18T20:36:13.954340Z", - "iopub.status.idle": "2025-08-18T20:36:43.225449Z", - "shell.execute_reply": "2025-08-18T20:36:43.224579Z", - "shell.execute_reply.started": "2025-08-18T20:36:13.954661Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/core/logging/log_adapter.py:183: 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/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/session/__init__.py:437: FutureWarning: You are using the BigFrames session default connection: bigframes-\n", - "default-connection, which can be different from the\n", - "BigQuery project default connection. This default\n", - "connection may change in the future.\n", - " warnings.warn(msg, category=FutureWarning)\n" - ] - } - ], - "source": [ - "from bigframes.ml import llm\n", - "gemini = llm.GeminiTextGenerator()" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "a31730ff", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:36:43.227798Z", - "iopub.status.busy": "2025-08-18T20:36:43.227457Z", - "iopub.status.idle": "2025-08-18T20:37:25.238649Z", - "shell.execute_reply": "2025-08-18T20:37:25.237623Z", - "shell.execute_reply.started": "2025-08-18T20:36:43.227764Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
ml_generate_text_llm_resultimage
0Please provide me with the picture! I need to see the image to tell you what the item is and what color the picture is.\\n
1To answer your question accurately, I need you to provide me with the picture you are referring to. Once you provide the picture, I can analyze it and tell you what item is in the picture and what color the picture is.
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "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?\", \"what color is the picture?\"])\n", - "render_images(answer[[\"ml_generate_text_llm_result\", \"image\"]])" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "f5d2a1ed", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:37:25.239875Z", - "iopub.status.busy": "2025-08-18T20:37:25.239607Z", - "iopub.status.idle": "2025-08-18T20:37:25.263034Z", - "shell.execute_reply": "2025-08-18T20:37:25.262002Z", - "shell.execute_reply.started": "2025-08-18T20:37:25.239847Z" - }, - "trusted": true - }, - "outputs": [], - "source": [ - "# Ask different questions\n", - "df_image[\"question\"] = [\"what item is it?\", \"what color is the picture?\"]" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "fb67bf8e", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:37:25.264585Z", - "iopub.status.busy": "2025-08-18T20:37:25.264072Z", - "iopub.status.idle": "2025-08-18T20:38:10.129667Z", - "shell.execute_reply": "2025-08-18T20:38:10.128677Z", - "shell.execute_reply.started": "2025-08-18T20:37:25.264518Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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", - "
ml_generate_text_llm_resultimage
0The item is a glass aquarium.
1Dark brown
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "answer_alt = gemini.predict(df_image, prompt=[df_image[\"question\"], df_image[\"image\"]])\n", - "render_images(answer_alt[[\"ml_generate_text_llm_result\", \"image\"]])" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "0cf33170", - "metadata": { - "execution": { - "iopub.execute_input": "2025-08-18T20:38:10.130851Z", - "iopub.status.busy": "2025-08-18T20:38:10.130617Z", - "iopub.status.idle": "2025-08-18T20:39:04.790416Z", - "shell.execute_reply": "2025-08-18T20:39:04.789398Z", - "shell.execute_reply.started": "2025-08-18T20:38:10.130833Z" - }, - "trusted": true - }, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/core/logging/log_adapter.py:183: 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/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/session/__init__.py:437: FutureWarning: You are using the BigFrames session default connection: bigframes-\n", - "default-connection, which can be different from the\n", - "BigQuery project default connection. This default\n", - "connection may change in the future.\n", - " warnings.warn(msg, category=FutureWarning)\n", - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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.03416207 0.0419732 -0.0227391 ... -0.03...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2026-05-02T03:3...
1[ 0.01908903 0.0193082 -0.00221754 ... 0.00...<NA><NA>{\"access_urls\":{\"expiry_time\":\"2026-05-02T03:3...
\n", - "

2 rows × 5 columns

\n", - "
[2 rows x 5 columns in total]" - ], - "text/plain": [ - " ml_generate_embedding_result \\\n", - "0 [ 0.03416207 0.0419732 -0.0227391 ... -0.03... \n", - "1 [ 0.01908903 0.0193082 -0.00221754 ... 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\":\"2026-05-02T03:3... \n", - "1 {\"access_urls\":{\"expiry_time\":\"2026-05-02T03:3... \n", - "\n", - "[2 rows x 5 columns]" - ] - }, - "execution_count": 14, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "# Generate embeddings.\n", - "embed_model = llm.MultimodalEmbeddingGenerator()\n", - "embeddings = embed_model.predict(df_image[\"image\"])\n", - "embeddings" - ] - } - ], - "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 -} +{"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 index 317ba0f1adb..fe2d567d1b3 100644 --- a/notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb +++ b/notebooks/kaggle/vector-search-with-bigframes-over-national-jukebox.ipynb @@ -1,8 +1,23 @@ { "cells": [ { - "id": "f4ece66a", "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", @@ -20,42 +35,42 @@ "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'] &": { - "zoom": "194%" + "z-index": "0", + "zoom": "216%" } } } }, - "editable": true, "slideshow": { - "slide_type": "subslide" - }, - "tags": [] + "slide_type": "slide" + } }, - "execution_count": null - }, - { - "id": "bc01a1d3", - "cell_type": "markdown", "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": "216%" + "zoom": "181%" } } } @@ -64,11 +79,6 @@ "slide_type": "slide" } }, - "execution_count": null - }, - { - "id": "4fc7c468", - "cell_type": "markdown", "source": [ "\n", "To search the National Jukebox, we combine powerful features of BigQuery:\n", @@ -86,32 +96,10 @@ "3. BigQuery DataFrames to use Python instead of SQL.\n", "\n", " https://cloud.google.com/bigquery/docs/bigquery-dataframes-introduction" - ], - "metadata": { - "@deathbeds/jupyterlab-fonts": { - "styles": { - "": { - "body[data-jp-deck-mode='presenting'] &": { - "z-index": "0", - "zoom": "181%" - } - } - } - }, - "slideshow": { - "slide_type": "slide" - } - }, - "execution_count": null + ] }, { - "id": "90f2e543", "cell_type": "markdown", - "source": [ - "## Getting started with BigQuery DataFrames (bigframes)\n", - "\n", - "Install the bigframes package." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -126,14 +114,15 @@ "slide_type": "slide" } }, - "execution_count": null + "source": [ + "## Getting started with BigQuery DataFrames (bigframes)\n", + "\n", + "Install the bigframes package." + ] }, { - "id": "56694cb4", "cell_type": "code", - "source": [ - "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -153,17 +142,13 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "%pip install --upgrade bigframes google-cloud-automl google-cloud-translate google-ai-generativelanguage tensorflow " + ] }, { - "id": "fa84ad03", "cell_type": "markdown", - "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," - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -176,17 +161,15 @@ } } }, - "execution_count": null + "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," + ] }, { - "id": "1fbd4f9e", "cell_type": "code", - "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)" - ], + "execution_count": null, "metadata": { "execution": { "iopub.execute_input": "2025-08-14T15:53:08.494636Z", @@ -197,22 +180,17 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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)" + ] }, { - "id": "0b0b1cd8", "cell_type": "code", - "source": [ - "import bigframes._config\n", - "import bigframes.pandas as bpd\n", - "\n", - "PROJECT_ID = \"your-project-id\" # @param {type:\"string\"}\n", - "bpd.options.bigquery.location = \"US\"\n", - "\n", - "# Set to your GCP project ID.\n", - "bpd.options.bigquery.project = PROJECT_ID" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -232,17 +210,19 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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\"" + ] }, { - "id": "32e58a7f", "cell_type": "markdown", - "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." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -257,19 +237,15 @@ "slide_type": "slide" } }, - "execution_count": null + "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." + ] }, { - "id": "e52aa9e8", "cell_type": "code", - "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", - ")" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -289,16 +265,19 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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", + ")" + ] }, { - "id": "0c1fca97", "cell_type": "code", - "source": [ - "# Use `peek()` instead of `head()` to see arbitrary rows rather than the \"first\" rows.\n", - "df.peek()" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -321,15 +300,15 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "# Use `peek()` instead of `head()` to see arbitrary rows rather than the \"first\" rows.\n", + "df.peek()" + ] }, { - "id": "4a13e789", "cell_type": "code", - "source": [ - "df.shape" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -349,18 +328,14 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "df.shape" + ] }, { - "id": "26b8baba", "cell_type": "code", - "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" - ], + "execution_count": null, "metadata": { "execution": { "iopub.execute_input": "2025-08-14T15:55:55.448664Z", @@ -371,32 +346,17 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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" + ] }, { - "id": "af84cb21", "cell_type": "code", - "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()" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -419,15 +379,31 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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()" + ] }, { - "id": "085deffd", "cell_type": "code", - "source": [ - "flattened.shape" - ], + "execution_count": null, "metadata": { "execution": { "iopub.execute_input": "2025-08-14T15:56:06.546531Z", @@ -438,15 +414,13 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "flattened.shape" + ] }, { - "id": "f8e653ee", "cell_type": "markdown", - "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." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -463,14 +437,13 @@ }, "tags": [] }, - "execution_count": null + "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." + ] }, { - "id": "dbd1a844", "cell_type": "code", - "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})\\nflattened[\"GCS URI\"] = flattened[\"GCS Prefix\"] + flattened[\"GCS Stub\"] + \".mp3\"" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -495,15 +468,18 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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()" + ] }, { - "id": "fae13ec5", "cell_type": "markdown", - "source": [ - "BigQuery (and BigQuery DataFrames) provide access to powerful models and multimodal capabilities. Here, we transcribe audio to text." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -520,35 +496,13 @@ }, "tags": [] }, - "execution_count": null + "source": [ + "BigQuery (and BigQuery DataFrames) provide access to powerful models and multimodal capabilities. Here, we transcribe audio to text." + ] }, { - "id": "f08f92b1", "cell_type": "code", - "source": [ - "import bigframes.bigquery as bbq\n", - "\n", - "# Replace with your own connection name.\n", - "CONNECTION_ID = 'your-project-id.your-location.your-connection' # @param {type:\"string\"}\n", - "\n", - "# Convert the audio URI to the runtime representation required by the model.\n", - "audio_ref = bbq.obj.make_ref(flattened[\"GCS URI\"], authorizer=CONNECTION_ID)\n", - "audio_metadata = bbq.obj.fetch_metadata(audio_ref)\n", - "audio_runtime = bbq.obj.get_access_url(audio_metadata, mode=\"R\")\n", - "\n", - "# Call GenAI model to perform audio transcription\n", - "raw_results = bbq.ai.generate(\n", - " prompt=(\"Transcribe the provided audio.\", audio_runtime),\n", - " endpoint=\"gemini-2.5-flash\"\n", - ")\n", - "\n", - "# Package result struct to contain 'content' and 'status' expected by downstream cells\n", - "transcription_df = bpd.DataFrame({\n", - " \"content\": raw_results.struct.field(\"result\"),\n", - " \"status\": raw_results.struct.field(\"status\")\n", - "})\n", - "flattened[\"Transcription\"] = bbq.struct(transcription_df)" - ], + "execution_count": null, "metadata": { "editable": true, "execution": { @@ -564,15 +518,17 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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\"]" + ] }, { - "id": "30969ae1", "cell_type": "markdown", - "source": [ - "Sometimes the model has transient errors. Check the status column to see if there are errors." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -587,16 +543,13 @@ "slide_type": "slide" } }, - "execution_count": null + "source": [ + "Sometimes the model has transient errors. Check the status column to see if there are errors." + ] }, { - "id": "7d0dbc38", "cell_type": "code", - "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" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -621,16 +574,16 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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" + ] }, { - "id": "6cddf53b", "cell_type": "code", - "source": [ - "# Show transcribed lyrics.\n", - "flattened[\"Transcription\"].struct.field(\"content\")" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -650,19 +603,15 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "# Show transcribed lyrics.\n", + "flattened[\"Transcription\"].struct.field(\"content\")" + ] }, { - "id": "ba0386cc", "cell_type": "code", - "source": [ - "# Find all instrumentatal songs\n", - "instrumental = flattened[flattened[\"Transcription\"].struct.field(\"content\") == \"\"]\n", - "print(instrumental.shape)\n", - "song = instrumental.peek(1)\n", - "song" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -685,22 +634,18 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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" + ] }, { - "id": "61a883b2", "cell_type": "code", - "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)" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -725,19 +670,20 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] - }, - { - "id": "e8a25c46", - "cell_type": "markdown", + "outputs": [], "source": [ - "## Creating a searchable index\n", + "import gcsfs\n", + "import IPython.display\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", + "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", - "See also, this example: https://github.com/googleapis/python-bigquery-dataframes/blob/main/notebooks/generative_ai/bq_dataframes_llm_vector_search.ipynb" - ], + "IPython.display.Audio(song_bytes)" + ] + }, + { + "cell_type": "markdown", "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -752,16 +698,17 @@ "slide_type": "slide" } }, - "execution_count": null + "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" + ] }, { - "id": "ead0fa8c", "cell_type": "code", - "source": [ - "from bigframes.ml.llm import TextEmbeddingGenerator\n", - "\n", - "text_model = TextEmbeddingGenerator(model_name=\"text-multilingual-embedding-002\")" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -781,21 +728,16 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "from bigframes.ml.llm import TextEmbeddingGenerator\n", + "\n", + "text_model = TextEmbeddingGenerator(model_name=\"text-multilingual-embedding-002\")" + ] }, { - "id": "5ed7776d", "cell_type": "code", - "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)" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -815,18 +757,20 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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)" + ] }, { - "id": "c96e9832", "cell_type": "code", - "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" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -851,15 +795,16 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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" + ] }, { - "id": "0e2a5d7b", "cell_type": "markdown", - "source": [ - "We're now ready to save this to a table." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -871,15 +816,13 @@ } } }, - "execution_count": null + "source": [ + "We're now ready to save this to a table." + ] }, { - "id": "51819a0c", "cell_type": "code", - "source": [ - "embedding_table_id = f\"{bpd.options.bigquery.project}.kaggle.national_jukebox\"\n", - "embedding.to_gbq(embedding_table_id, if_exists=\"replace\")" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -899,20 +842,14 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "embedding_table_id = f\"{bpd.options.bigquery.project}.kaggle.national_jukebox\"\n", + "embedding.to_gbq(embedding_table_id, if_exists=\"replace\")" + ] }, { - "id": "5e16fb14", "cell_type": "markdown", - "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." - ], "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -927,17 +864,18 @@ "slide_type": "slide" } }, - "execution_count": null + "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." + ] }, { - "id": "1bad3317", "cell_type": "code", - "source": [ - "import bigframes.pandas as bpd\n", - "\n", - "df_written = bpd.read_gbq(embedding_table_id)\n", - "df_written.peek(1)" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -960,22 +898,17 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "df_written = bpd.read_gbq(embedding_table_id)\n", + "df_written.peek(1)" + ] }, { - "id": "8aaaef1f", "cell_type": "code", - "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" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -995,24 +928,21 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "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" + ] }, { - "id": "908a2340", "cell_type": "code", - "source": [ - "import bigframes.bigquery as bbq\n", - "\n", - "vector_search_results = bbq.vector_search(\n", - " base_table=embedding_table_id,\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", - ")" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -1037,15 +967,23 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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", + ")" + ] }, { - "id": "f84ebe70", "cell_type": "code", - "source": [ - "vector_search_results.dtypes" - ], + "execution_count": null, "metadata": { "execution": { "iopub.execute_input": "2025-08-14T16:05:50.566930Z", @@ -1056,16 +994,14 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "vector_search_results.dtypes" + ] }, { - "id": "eeff1c72", "cell_type": "code", - "source": [ - "results = vector_search_results[[\"Title\", \"Summary\", \"Names\", \"GCS URI\", \"Transcription\", \"distance\"]].sort_values(\"distance\").to_pandas()\n", - "results" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -1088,15 +1024,15 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "results = vector_search_results[[\"Title\", \"Summary\", \"Names\", \"GCS URI\", \"Transcription\", \"distance\"]].sort_values(\"distance\").to_pandas()\n", + "results" + ] }, { - "id": "7ec53675", "cell_type": "code", - "source": [ - "print(results[\"Transcription\"].struct.field(\"content\").iloc[0])" - ], + "execution_count": null, "metadata": { "@deathbeds/jupyterlab-fonts": { "styles": { @@ -1116,22 +1052,14 @@ }, "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [ + "print(results[\"Transcription\"].struct.field(\"content\").iloc[0])" + ] }, { - "id": "a96552fb", "cell_type": "code", - "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)" - ], + "execution_count": null, "metadata": { "editable": true, "execution": { @@ -1148,18 +1076,26 @@ "tags": [], "trusted": true }, - "execution_count": null, - "outputs": [] + "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)" + ] }, { - "id": "72af7c7f", "cell_type": "code", - "source": [], + "execution_count": null, "metadata": { "trusted": true }, - "execution_count": null, - "outputs": [] + "outputs": [], + "source": [] } ], "metadata": { @@ -1196,6 +1132,6 @@ "version": "3.11.13" } }, - "nbformat_minor": 4, - "nbformat": 4 -} \ No newline at end of file + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebooks/location/regionalized.ipynb b/notebooks/location/regionalized.ipynb index b1e9e010d48..066cd181364 100644 --- a/notebooks/location/regionalized.ipynb +++ b/notebooks/location/regionalized.ipynb @@ -17,7 +17,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Infer location and set up data in that location if needed" + "### Infer location and set up data in that location if needed" ] }, { @@ -126,7 +126,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Set BigQuery DataFrames options" + "### Set BigQuery DataFrames options" ] }, { @@ -1339,12 +1339,26 @@ "# Using the Remote Functions" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "# Python 3.13 is not yet a supported runtime for remote functions.\n", + "# See: https://cloud.google.com/functions/docs/runtime-support#python for the supported runtimes.\n", + "if sys.version_info >= (3, 13, 0):\n", + " sys.exit(0)" + ] + }, { "attachments": {}, "cell_type": "markdown", "metadata": {}, "source": [ - "## BigQuery DataFrames gives you the ability to turn your custom scalar functions into a BigQuery remote function.", + "### BigQuery DataFrames gives you the ability to turn your custom scalar functions into a BigQuery remote function.\n", "\n", "It requires the GCP project to be set up appropriately and the user having sufficient privileges to use them. One can find more details on it via `help` command." ] @@ -1643,7 +1657,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Initialize a DataFrame from a BigQuery table" + "### Initialize a DataFrame from a BigQuery table" ] }, { diff --git a/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb b/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb index 3dc0eabf5a1..501bfc88d31 100644 --- a/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb +++ b/notebooks/ml/bq_dataframes_ml_cross_validation.ipynb @@ -991,7 +991,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv (3.10.14)", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -1005,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 210922eab94..00aa7a347cb 100644 --- a/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb +++ b/notebooks/ml/bq_dataframes_ml_linear_regression.ipynb @@ -1,760 +1,760 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "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", - " \"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 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", - "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 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": 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", - "# 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\"}\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": null, - "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": null, - "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": 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 = REGION" - ] - }, - { - "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": null, - "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": null, - "metadata": { - "id": "_gPD0Zn1Stdb" - }, - "outputs": [], - "source": [ - "df.head()" - ] - }, - { - "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": null, - "metadata": { - "id": "6i6HkFJZa8na" - }, - "outputs": [], - "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": null, - "metadata": { - "id": "0am3hdlXZfxZ" - }, - "outputs": [], - "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": "M_-0X7NxYK5f" - }, - "source": [ - "Specify your feature (or input) columns and the label (or output) column:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "YKwCW7Nsavap" - }, - "outputs": [], - "source": [ - "feature_columns = training_data[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']]\n", - "label_columns = training_data[['body_mass_g']]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "CjyM7vZJZ0sQ" - }, - "source": [ - "There is a row within the `adelie_data` BigQuery DataFrames DataFrame that has a `NULL` value for the `body mass` column. `body mass` is the label column, which is the value that the model you are creating is trying to predict.\n", - "\n", - "Create a new BigQuery DataFrames DataFrame, `test_data`, for this row so that you can use it as test data on which to make a prediction later:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "wej78IDUaRW9" - }, - "outputs": [], - "source": [ - "test_data = adelie_data[adelie_data.body_mass_g.isnull()]\n", - "\n", - "test_data" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Fx4lsNqMorJ-" - }, - "source": [ - "## Create the linear regression model\n", - "\n", - "BigQuery DataFrames ML lets you move 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 linear regression 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 locally and limited to the BigQuery 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", - "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": null, - "metadata": { - "id": "GskyyUQPowBT" - }, - "outputs": [], - "source": [ - "from bigframes.ml.linear_model import LinearRegression\n", - "\n", - "model = LinearRegression()\n", - "\n", - "model.fit(feature_columns, label_columns)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "UGjeMPC2caKK" - }, - "source": [ - "### Score the model\n", - "\n", - "Check how the model performed by using the `score` method. More information on 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": [ - "model.score(feature_columns, label_columns)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "P2lUiZZ_cjri" - }, - "source": [ - "### Predict using the model\n", - "\n", - "Use the model to predict the body mass of the data row you saved earlier to the `test_data` DataFrame:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "id": "bsQ9cmoWo0Ps" - }, - "outputs": [], - "source": [ - "model.predict(test_data)" - ] - }, - { - "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", - "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": "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 + \".penguin_weight\" , 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.developers.google.com/bigquery?p={PROJECT_ID}')" - ] - }, - { - "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", - "# 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": "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 + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "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 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", + "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 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": 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", + "# 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\"}\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": null, + "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": null, + "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": 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 = REGION" + ] + }, + { + "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": null, + "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": null, + "metadata": { + "id": "_gPD0Zn1Stdb" + }, + "outputs": [], + "source": [ + "df.head()" + ] + }, + { + "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": null, + "metadata": { + "id": "6i6HkFJZa8na" + }, + "outputs": [], + "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": null, + "metadata": { + "id": "0am3hdlXZfxZ" + }, + "outputs": [], + "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": "M_-0X7NxYK5f" + }, + "source": [ + "Specify your feature (or input) columns and the label (or output) column:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "YKwCW7Nsavap" + }, + "outputs": [], + "source": [ + "feature_columns = training_data[['island', 'culmen_length_mm', 'culmen_depth_mm', 'flipper_length_mm', 'sex']]\n", + "label_columns = training_data[['body_mass_g']]" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "CjyM7vZJZ0sQ" + }, + "source": [ + "There is a row within the `adelie_data` BigQuery DataFrames DataFrame that has a `NULL` value for the `body mass` column. `body mass` is the label column, which is the value that the model you are creating is trying to predict.\n", + "\n", + "Create a new BigQuery DataFrames DataFrame, `test_data`, for this row so that you can use it as test data on which to make a prediction later:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "wej78IDUaRW9" + }, + "outputs": [], + "source": [ + "test_data = adelie_data[adelie_data.body_mass_g.isnull()]\n", + "\n", + "test_data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Fx4lsNqMorJ-" + }, + "source": [ + "## Create the linear regression model\n", + "\n", + "BigQuery DataFrames ML lets you move 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 linear regression 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 locally and limited to the BigQuery 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", + "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": null, + "metadata": { + "id": "GskyyUQPowBT" + }, + "outputs": [], + "source": [ + "from bigframes.ml.linear_model import LinearRegression\n", + "\n", + "model = LinearRegression()\n", + "\n", + "model.fit(feature_columns, label_columns)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "UGjeMPC2caKK" + }, + "source": [ + "### Score the model\n", + "\n", + "Check how the model performed by using the `score` method. More information on 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": [ + "model.score(feature_columns, label_columns)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "P2lUiZZ_cjri" + }, + "source": [ + "### Predict using the model\n", + "\n", + "Use the model to predict the body mass of the data row you saved earlier to the `test_data` DataFrame:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "bsQ9cmoWo0Ps" + }, + "outputs": [], + "source": [ + "model.predict(test_data)" + ] + }, + { + "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", + "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": "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 + \".penguin_weight\" , 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.developers.google.com/bigquery?p={PROJECT_ID}')" + ] + }, + { + "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", + "# 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": "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/bq_dataframes_ml_linear_regression_bbq.ipynb b/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb deleted file mode 100644 index 396fde5a397..00000000000 --- a/notebooks/ml/bq_dataframes_ml_linear_regression_bbq.ipynb +++ /dev/null @@ -1,2637 +0,0 @@ -{ - "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", - " \"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 index d286f5ce31d..5c016f9157d 100644 --- a/notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb +++ b/notebooks/ml/bq_dataframes_ml_linear_regression_big.ipynb @@ -1,1064 +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", - " \"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 + "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/timeseries_analysis.ipynb b/notebooks/ml/timeseries_analysis.ipynb deleted file mode 100644 index 3b227460230..00000000000 --- a/notebooks/ml/timeseries_analysis.ipynb +++ /dev/null @@ -1,1135 +0,0 @@ -{ - "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": null, - "id": "c0b2db75", - "metadata": {}, - "outputs": [], - "source": [ - "import bigframes.pandas as bpd\n", - "from bigframes.ml import forecasting\n", - "bpd.options.display.render_mode = \"anywidget\"" - ] - }, - { - "cell_type": "markdown", - "id": "0eba46b9", - "metadata": {}, - "source": [ - "## 1. Data Loading and Preprocessing", - "\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 (3.13.0)", - "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 index cd363db6f36..f6f80b0009c 100644 --- a/notebooks/multimodal/multimodal_dataframe.ipynb +++ b/notebooks/multimodal/multimodal_dataframe.ipynb @@ -1,1114 +1,462 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": null, - "id": "9edad7a6", - "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": "816ab253", - "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", - "id": "77d821d4", - "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\n", - "7. Extract EXIF metadata from images" - ] - }, - { - "cell_type": "markdown", - "id": "75ab1c13", - "metadata": { - "id": "PEAJQQ6AFg-n" - }, - "source": [ - "## Setup" - ] - }, - { - "cell_type": "markdown", - "id": "750954c4", - "metadata": {}, - "source": [ - "Install the latest bigframes package if bigframes version < 2.4.0" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "2a6fafb1", - "metadata": {}, - "outputs": [], - "source": [ - "# !pip install bigframes --upgrade" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "df561d04", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "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." + ] }, - "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", - "LOCATION = \"us\" # replace with your location.\n", - "\n", - "# Dataset where the UDF will be created.\n", - "DATASET_ID = \"bigframes_samples\" # replace with your dataset ID.\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", - "FULL_CONNECTION_ID = f\"{PROJECT}.{LOCATION}.bigframes-default-connection\"\n", - "\n", - "import bigframes\n", - "# Setup project\n", - "bigframes.options.bigquery.project = PROJECT\n", - "bigframes.options.bigquery.location = LOCATION\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\n", - "import bigframes.bigquery as bbq" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "id": "35bd6e6e", - "metadata": {}, - "outputs": [], - "source": [ - "import bigframes.bigquery as bbq\n", - "\n", - "def get_runtime_json_str(series, mode=\"R\", with_metadata=False):\n", - " \"\"\"\n", - " Get the runtime (contains signed URL to access gcs data) and apply the\n", - " ToJSONSTring transformation.\n", - " \n", - " Args:\n", - " series: bigframes.series.Series to operate on.\n", - " mode: \"R\" for read, \"RW\" for read/write.\n", - " with_metadata: Whether to fetch and include blob metadata.\n", - " \"\"\"\n", - " # 1. Optionally fetch metadata\n", - " s = (\n", - " bbq.obj.fetch_metadata(series)\n", - " if with_metadata\n", - " else series\n", - " )\n", - " \n", - " # 2. Retrieve the access URL runtime object\n", - " runtime = bbq.obj.get_access_url(s, mode=mode)\n", - " \n", - " # 3. Convert the runtime object to a JSON string\n", - " return bbq.to_json_string(runtime)\n", - "\n", - "def get_metadata(series):\n", - " # Fetch metadata and extract GCS metadata from the details JSON field\n", - " metadata_obj = bbq.obj.fetch_metadata(series)\n", - " return bbq.json_query(metadata_obj.struct.field(\"details\"), \"$.gcs_metadata\")\n", - "\n", - "def get_content_type(series):\n", - " return bbq.json_value(get_metadata(series), \"$.content_type\")\n", - "\n", - "def get_size(series):\n", - " return bbq.json_value(get_metadata(series), \"$.size\").astype(\"Int64\")\n", - "\n", - "def get_updated(series):\n", - " return bpd.to_datetime(bbq.json_value(get_metadata(series), \"$.updated\").astype(\"Int64\"), unit=\"us\", utc=True)\n", - "\n", - "from IPython.display import HTML, display\n", - "\n", - "def render_images(df):\n", - " \"\"\"Helper to display BigFrames DataFrame with rendered image previews.\"\"\"\n", - " import bigframes.pandas as bpd\n", - " import bigframes.bigquery as bbq\n", - " import bigframes\n", - " from bigframes import dtypes\n", - " import json\n", - " \n", - " if isinstance(df, bpd.Series):\n", - " df = df.to_frame()\n", - " \n", - " # 1. Auto-detect columns holding ObjectRefs\n", - " object_cols = [\n", - " col for col, dtype in zip(df.columns, df.dtypes)\n", - " if dtype == dtypes.OBJ_REF_DTYPE\n", - " ]\n", - " \n", - " if not object_cols:\n", - " display(df)\n", - " return\n", - "\n", - " limit = bigframes.options.display.max_rows or 10\n", - " view_df = df.head(limit)\n", - " \n", - " # 2. Bulk-fetch access runtime URLs ONLY (disable with_metadata to bypass potential \n", - " # race conditions on new files where BigQuery may error before async writes finalize)\n", - " runtime_cols = {\n", - " col: get_runtime_json_str(view_df[col], mode=\"R\", with_metadata=False) \n", - " for col in object_cols\n", - " }\n", - " \n", - " pandas_json_df = bpd.DataFrame(runtime_cols).to_pandas()\n", - " final_pd = view_df.to_pandas()\n", - " \n", - " width = bigframes.options.display.blob_display_width or 300\n", - " IMAGE_EXTENSIONS = (\".png\", \".jpg\", \".jpeg\", \".gif\", \".webp\")\n", - " \n", - " def format_cell_html(raw_json):\n", - " if not raw_json:\n", - " return \"\"\n", - " try:\n", - " obj_rt = json.loads(raw_json)\n", - " \n", - " if \"access_urls\" not in obj_rt:\n", - " err = obj_rt.get(\"errors\", [{\"message\": \"URL Generation Failed\"}])[0].get(\"message\")\n", - " return f'Error: {err}'\n", - " \n", - " uri = obj_rt.get(\"objectref\", {}).get(\"uri\", \"\")\n", - " url = obj_rt[\"access_urls\"][\"read_url\"]\n", - " \n", - " # Safely infer type from extension to guarantee immediate display availability\n", - " if uri and str(uri).lower().endswith(IMAGE_EXTENSIONS):\n", - " return f''\n", - " \n", - " return f'{uri if uri else \"view\"}'\n", - " except:\n", - " return \"Format Error\"\n", - "\n", - " for col in object_cols:\n", - " final_pd[col] = pandas_json_df[col].map(format_cell_html)\n", - " \n", - " display(HTML(final_pd.to_html(escape=False)))" - ] - }, - { - "cell_type": "markdown", - "id": "be9ce892", - "metadata": { - "id": "ifKOq7VZGtZy" - }, - "source": [ - "To create a Multimodal DataFrame, you can use `bigframes.bigquery.obj.make_ref` on a series of URIs. You can get the URIs from a BigQuery table or by listing them from Cloud Storage.\n", - "\n", - "In this example, we use `gcsfs` to list the files from Cloud Storage, and then use `read_gbq` to load them into a BigQuery DataFrame before creating the object reference." - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "871d02f4", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/" + { + "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" + ] }, - "id": "fx6YcZJbeYru", - "outputId": "d707954a-0dd0-4c50-b7bf-36b140cf76cf" - }, - "outputs": [], - "source": [ - "import gcsfs\n", - "import bigframes.bigquery as bbq\n", - "\n", - "# List files using gcsfs (public bucket)\n", - "fs = gcsfs.GCSFileSystem(anon=True)\n", - "uris = fs.glob(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*\")\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "# Read the URIs into a BigQuery DataFrame using UNNEST\n", - "# We take the first 5 for this example\n", - "df_image = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "\n", - "# Create the object reference column\n", - "df_image['image'] = bbq.obj.make_ref(df_image['uri'], authorizer=FULL_CONNECTION_ID)\n", - "df_image = df_image[['image']]" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "id": "2e0436b0", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 487 + { + "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" + ] }, - "id": "HhCb8jRsLe9B", - "outputId": "03081cf9-3a22-42c9-b38f-649f592fdada" - }, - "outputs": [ { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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" - ] + "cell_type": "markdown", + "metadata": { + "id": "PEAJQQ6AFg-n" + }, + "source": [ + "### Setup" + ] }, { - "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", - "
image
0
1
2
3
4
" - ], - "text/plain": [ - "" + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Install the latest bigframes package if bigframes version < 2.4.0" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Take only the 5 images to deal with. Preview the content of the Mutimodal DataFrame\n", - "df_image = df_image.head(5)\n", - "render_images(df_image)" - ] - }, - { - "cell_type": "markdown", - "id": "429b0117", - "metadata": { - "id": "b6RRZb3qPi_T" - }, - "source": [ - "### 2. Combine unstructured data with structured data" - ] - }, - { - "cell_type": "markdown", - "id": "991fa065", - "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": 38, - "id": "08722ec5", - "metadata": { - "id": "YYYVn7NDH0Me" - }, - "outputs": [ + }, { - "name": "stderr", - "output_type": "stream", - "text": [ - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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" - ] + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# !pip install bigframes --upgrade" + ] }, { - "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", - "
imageauthorcontent_typesizeupdated
0aliceimage/png7157662025-03-20 17:44:38+00:00
1bobimage/png11674062025-03-20 17:44:38+00:00
2bobimage/png11508922025-03-20 17:44:39+00:00
3aliceimage/png17365332025-03-20 17:44:39+00:00
4bobimage/png4397402025-03-20 17:44:39+00:00
" - ], - "text/plain": [ - "" + "cell_type": "code", + "execution_count": null, + "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" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Combine unstructured data with structured data\n", - "df_image = df_image.head(5)\n", - "df_image[\"author\"] = [\"alice\", \"bob\", \"bob\", \"alice\", \"bob\"] # type: ignore\n", - "df_image[\"content_type\"] = get_content_type(df_image[\"image\"])\n", - "df_image[\"size\"] = get_size(df_image[\"image\"])\n", - "df_image[\"updated\"] = get_updated(df_image[\"image\"])\n", - "render_images(df_image)" - ] - }, - { - "cell_type": "markdown", - "id": "f90826f6", - "metadata": {}, - "source": [ - "### 3. Conduct image transformations" - ] - }, - { - "cell_type": "markdown", - "id": "e24c9f8c", - "metadata": {}, - "source": [ - "This section demonstrates how to perform image transformations like blur, resize, and normalize using custom BigQuery Python UDFs and the `opencv-python` library." - ] - }, - { - "cell_type": "code", - "execution_count": 39, - "id": "db665049", - "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/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/pandas/__init__.py:211: PreviewWarning: udf is in preview.\n", - " return global_session.with_default_session(\n", - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dataframe.py:4695: FunctionAxisOnePreviewWarning: DataFrame.apply with parameter axis=1 scenario is in preview.\n", - " warnings.warn(msg, category=bfe.FunctionAxisOnePreviewWarning)\n", - "/usr/local/google/home/shuowei/src/google-cloud-python/google-cloud-python/packages/bigframes/bigframes/dtypes.py:1044: 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" - ] + "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." + ] }, { - "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", - "
imageblurred
0
1
2
3
4
" - ], - "text/plain": [ - "" + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "fx6YcZJbeYru", + "outputId": "d707954a-0dd0-4c50-b7bf-36b140cf76cf" + }, + "outputs": [], + "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\")" ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "# Construct the canonical connection ID\n", - "FULL_CONNECTION_ID = f\"{PROJECT}.{LOCATION}.bigframes-default-connection\"\n", - "\n", - "@bpd.udf(\n", - " input_types=[str, str, int, int],\n", - " output_type=str,\n", - " dataset=DATASET_ID,\n", - " name=\"image_blur_v2\",\n", - " bigquery_connection=FULL_CONNECTION_ID,\n", - " packages=[\"opencv-python-headless\", \"numpy\", \"requests\"],\n", - ")\n", - "def image_blur(src_rt: str, dst_rt: str, kx: int, ky: int) -> str:\n", - " import json\n", - " import cv2 as cv\n", - " import numpy as np\n", - " import requests\n", - " import base64\n", - "\n", - " src_obj = json.loads(src_rt)\n", - " if \"access_urls\" not in src_obj:\n", - " raise ValueError(f\"Missing 'access_urls' in source object. Response: {src_obj}\")\n", - " src_url = src_obj[\"access_urls\"][\"read_url\"]\n", - " \n", - " response = requests.get(src_url, timeout=30)\n", - " response.raise_for_status()\n", - " \n", - " img = cv.imdecode(np.frombuffer(response.content, np.uint8), cv.IMREAD_UNCHANGED)\n", - " if img is None:\n", - " raise ValueError(\"cv.imdecode failed\")\n", - " \n", - " kx, ky = int(kx), int(ky)\n", - " img_blurred = cv.blur(img, ksize=(kx, ky))\n", - " \n", - " success, encoded = cv.imencode(\".jpeg\", img_blurred)\n", - " if not success:\n", - " raise ValueError(\"cv.imencode failed\")\n", - " \n", - " # Handle two output modes\n", - " if dst_rt: # GCS/Series output mode\n", - " dst_obj = json.loads(dst_rt)\n", - " if \"access_urls\" not in dst_obj:\n", - " raise ValueError(f\"Missing 'access_urls' in destination object. Verify authorizer permissions. Response: {dst_obj}\")\n", - " dst_url = dst_obj[\"access_urls\"][\"write_url\"]\n", - " \n", - " requests.put(dst_url, data=encoded.tobytes(), headers={\"Content-Type\": \"image/jpeg\"}, timeout=30).raise_for_status()\n", - " \n", - " uri = dst_obj[\"objectref\"][\"uri\"]\n", - " return uri\n", - " \n", - " else: # BigQuery bytes output mode \n", - " image_bytes = encoded.tobytes()\n", - " return base64.b64encode(image_bytes).decode()\n", - "\n", - "def apply_transformation(series, dst_folder, udf, *args, verbose=False):\n", - " import os\n", - " dst_folder = os.path.join(dst_folder, \"\")\n", - " # Fetch metadata to get the URI\n", - " metadata = bbq.obj.fetch_metadata(series)\n", - " current_uri = metadata.struct.field(\"uri\")\n", - " dst_uri = current_uri.str.replace(r\"^.*\\/(.*)$\", rf\"{dst_folder}\\1\", regex=True)\n", - " \n", - " # To avoid synchronous 404 validation checks on files that don't exist yet, \n", - " # bypass the validator by explicitly constructing an objectref JSON.\n", - " dst_blob_df = bpd.DataFrame({\"uri\": dst_uri})\n", - " dst_blob_df[\"authorizer\"] = FULL_CONNECTION_ID\n", - " dst_blob = bbq.obj.make_ref(bbq.to_json(bbq.struct(dst_blob_df)))\n", - "\n", - " df_transform = bpd.DataFrame({\n", - " \"src_rt\": get_runtime_json_str(series, mode=\"R\"),\n", - " \"dst_rt\": get_runtime_json_str(dst_blob, mode=\"RW\"),\n", - " })\n", - " res = df_transform[[\"src_rt\", \"dst_rt\"]].apply(\n", - " udf, axis=1, args=args\n", - " )\n", - " \n", - " if verbose:\n", - " return res\n", - " \n", - " # Final return MUST also use JSON bypass to eliminate temporary 404 validation \n", - " # errors from embedded ObjectRefs during fused query execution pipelines.\n", - " res_df = bpd.DataFrame({\"uri\": res})\n", - " res_df[\"authorizer\"] = FULL_CONNECTION_ID\n", - " return bbq.obj.make_ref(bbq.to_json(bbq.struct(res_df)))\n", - "\n", - "# Apply transformations\n", - "df_image[\"blurred\"] = apply_transformation(\n", - " df_image[\"image\"], f\"gs://{OUTPUT_BUCKET}/image_blur_transformed/\",\n", - " image_blur, 20, 20\n", - ")\n", - "render_images(df_image[[\"image\", \"blurred\"]])" - ] - }, - { - "cell_type": "markdown", - "id": "11fcc6ec", - "metadata": { - "id": "Euk5saeVVdTP" - }, - "source": [ - "### 4. Use LLM models to ask questions and generate embeddings on images" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "793b2f45", - "metadata": { - "id": "mRUGfcaFVW-3" - }, - "outputs": [], - "source": [ - "from bigframes.ml import llm\n", - "gemini = llm.GeminiTextGenerator()" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "13d7cb93", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 657 }, - "id": "DNFP7CbjWdR9", - "outputId": "3f90a062-0abc-4bce-f53c-db57b06a14b9" - }, - "outputs": [], - "source": [ - "# Ask the same question on the images\n", - "answer = gemini.predict(df_image, prompt=[\"what item is it?\", \"what color is the picture?\"])\n", - "render_images(answer[[\"ml_generate_text_llm_result\", \"image\"]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "68857305", - "metadata": { - "id": "IG3J3HsKhyBY" - }, - "outputs": [], - "source": [ - "# Ask different questions\n", - "df_image[\"question\"] = [\n", - " \"what item is it?\",\n", - " \"what color is the picture?\",\n", - " \"what is the product name?\",\n", - " \"is it for pets?\",\n", - " \"what is the weight of the product?\",\n", - "]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "829afc69", - "metadata": { - "colab": { - "base_uri": "https://localhost:8080/", - "height": 657 + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 487 + }, + "id": "HhCb8jRsLe9B", + "outputId": "03081cf9-3a22-42c9-b38f-649f592fdada" + }, + "outputs": [], + "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" + ] }, - "id": "qKOb765IiVuD", - "outputId": "731bafad-ea29-463f-c8c1-cb7acfd70e5d" - }, - "outputs": [], - "source": [ - "answer_alt = gemini.predict(df_image, prompt=[df_image[\"question\"], df_image[\"image\"]])\n", - "render_images(answer_alt[[\"ml_generate_text_llm_result\", \"image\"]])" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "e75df430", - "metadata": { + { + "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": null, + "metadata": { + "id": "YYYVn7NDH0Me" + }, + "outputs": [], + "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": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 75 + }, + "id": "UGuAk9PNDRF3", + "outputId": "73feb33d-4a05-48fb-96e5-3c48c2a456f3" + }, + "outputs": [], + "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": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "VWsl5BBPJ6N7", + "outputId": "45d2356e-322b-4982-cfa7-42d034dc4344" + }, + "outputs": [], + "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": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "rWCAGC8w64vU", + "outputId": "d7d456f0-8b56-492c-fe1b-967e9664d813" + }, + "outputs": [], + "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": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 605 + }, + "id": "6NGK6GYSU44B", + "outputId": "859101c1-2ee4-4f9a-e250-e8947127420a" + }, + "outputs": [], + "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": null, + "metadata": { + "id": "mRUGfcaFVW-3" + }, + "outputs": [], + "source": [ + "from bigframes.ml import llm\n", + "gemini = llm.GeminiTextGenerator()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 657 + }, + "id": "DNFP7CbjWdR9", + "outputId": "3f90a062-0abc-4bce-f53c-db57b06a14b9" + }, + "outputs": [], + "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": null, + "metadata": { + "id": "IG3J3HsKhyBY" + }, + "outputs": [], + "source": [ + "# Ask different questions\n", + "df_image[\"question\"] = [\"what item is it?\", \"what color is the picture?\"]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 657 + }, + "id": "qKOb765IiVuD", + "outputId": "731bafad-ea29-463f-c8c1-cb7acfd70e5d" + }, + "outputs": [], + "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": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/", + "height": 300 + }, + "id": "KATVv2CO5RT1", + "outputId": "6ec01f27-70b6-4f69-c545-e5e3c879480c" + }, + "outputs": [], + "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": null, + "metadata": { + "id": "oDDuYtUm5Yiy" + }, + "outputs": [], + "source": [ + "df_pdf = bpd.from_glob_path(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*\", name=\"pdf\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "colab": { + "base_uri": "https://localhost:8080/" + }, + "id": "7jLpMYaj7nj8", + "outputId": "06d5456f-580f-4693-adff-2605104b056c" + }, + "outputs": [], + "source": [ + "df_pdf[\"chunked\"] = df_pdf[\"pdf\"].blob.pdf_chunk(engine=\"pypdf\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "id": "kaPvJATN7zlw" + }, + "outputs": [], + "source": [ + "chunked = df_pdf[\"chunked\"].explode()\n", + "chunked" + ] + } + ], + "metadata": { "colab": { - "base_uri": "https://localhost:8080/", - "height": 300 + "provenance": [] }, - "id": "KATVv2CO5RT1", - "outputId": "6ec01f27-70b6-4f69-c545-e5e3c879480c" - }, - "outputs": [], - "source": [ - "# Generate embeddings.\n", - "embed_model = llm.MultimodalEmbeddingGenerator()\n", - "embeddings = embed_model.predict(df_image[\"image\"])\n", - "embeddings" - ] - }, - { - "cell_type": "markdown", - "id": "23892b0e", - "metadata": { - "id": "iRUi8AjG7cIf" - }, - "source": [ - "### 5. PDF extraction and chunking function\n", - "\n", - "This section demonstrates how to extract text and chunk text from PDF files using custom BigQuery Python UDFs and the `pypdf` library." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "136a18b8", - "metadata": {}, - "outputs": [], - "source": [ - "# Construct the canonical connection ID\n", - "FULL_CONNECTION_ID = f\"{PROJECT}.{LOCATION}.bigframes-default-connection\"\n", - "\n", - "@bpd.udf(\n", - " input_types=[str],\n", - " output_type=str,\n", - " dataset=DATASET_ID,\n", - " name=\"pdf_extract\",\n", - " bigquery_connection=FULL_CONNECTION_ID,\n", - " packages=[\"pypdf\", \"requests\", \"cryptography\"],\n", - ")\n", - "def pdf_extract(src_obj_ref_rt: str) -> str:\n", - " import io\n", - " import json\n", - " from pypdf import PdfReader\n", - " import requests\n", - " src_obj_ref_rt_json = json.loads(src_obj_ref_rt)\n", - " src_url = src_obj_ref_rt_json[\"access_urls\"][\"read_url\"]\n", - " response = requests.get(src_url, timeout=30, stream=True)\n", - " response.raise_for_status()\n", - " pdf_bytes = response.content\n", - " pdf_file = io.BytesIO(pdf_bytes)\n", - " reader = PdfReader(pdf_file, strict=False)\n", - " all_text = \"\"\n", - " for page in reader.pages:\n", - " page_extract_text = page.extract_text()\n", - " if page_extract_text:\n", - " all_text += page_extract_text\n", - " return all_text\n", - "\n", - "@bpd.udf(\n", - " input_types=[str, int, int],\n", - " output_type=list[str],\n", - " dataset=DATASET_ID,\n", - " name=\"pdf_chunk\",\n", - " bigquery_connection=FULL_CONNECTION_ID,\n", - " packages=[\"pypdf\", \"requests\", \"cryptography\"],\n", - ")\n", - "def pdf_chunk(src_obj_ref_rt: str, chunk_size: int, overlap_size: int) -> list[str]:\n", - " import io\n", - " import json\n", - " from pypdf import PdfReader\n", - " import requests\n", - " src_obj_ref_rt_json = json.loads(src_obj_ref_rt)\n", - " src_url = src_obj_ref_rt_json[\"access_urls\"][\"read_url\"]\n", - " response = requests.get(src_url, timeout=30, stream=True)\n", - " response.raise_for_status()\n", - " pdf_bytes = response.content\n", - " pdf_file = io.BytesIO(pdf_bytes)\n", - " reader = PdfReader(pdf_file, strict=False)\n", - " all_text_chunks = []\n", - " curr_chunk = \"\"\n", - " for page in reader.pages:\n", - " page_text = page.extract_text()\n", - " if page_text:\n", - " curr_chunk += page_text\n", - " while len(curr_chunk) >= chunk_size:\n", - " split_idx = curr_chunk.rfind(\" \", 0, chunk_size)\n", - " if split_idx == -1:\n", - " split_idx = chunk_size\n", - " actual_chunk = curr_chunk[:split_idx]\n", - " all_text_chunks.append(actual_chunk)\n", - " overlap = curr_chunk[split_idx + 1 : split_idx + 1 + overlap_size]\n", - " curr_chunk = overlap + curr_chunk[split_idx + 1 + overlap_size :]\n", - " if curr_chunk:\n", - " all_text_chunks.append(curr_chunk)\n", - " return all_text_chunks" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "234a5f86", - "metadata": {}, - "outputs": [], - "source": [ - "import gcsfs\n", - "import bigframes.bigquery as bbq\n", - "\n", - "# List files using gcsfs\n", - "fs = gcsfs.GCSFileSystem(anon=True)\n", - "uris = fs.glob(\"gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*\")\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "# Read the URIs into a BigQuery DataFrame\n", - "df_pdf = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "\n", - "# Create the object reference column\n", - "df_pdf['pdf'] = bbq.obj.make_ref(df_pdf['uri'], authorizer=FULL_CONNECTION_ID)\n", - "df_pdf = df_pdf[['pdf']]\n", - "\n", - "# Generate a JSON string containing the runtime information (including signed read URLs)\n", - "access_urls = get_runtime_json_str(df_pdf[\"pdf\"], mode=\"R\")\n", - "\n", - "# Apply PDF extraction\n", - "df_pdf[\"extracted_text\"] = access_urls.apply(pdf_extract)\n", - "\n", - "# Apply PDF chunking\n", - "df_pdf[\"chunked\"] = access_urls.apply(pdf_chunk, args=(2000, 200))\n", - "\n", - "df_pdf[[\"extracted_text\", \"chunked\"]]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "d80effbe", - "metadata": {}, - "outputs": [], - "source": [ - "# Explode the chunks to see each chunk as a separate row\n", - "chunked = df_pdf[\"chunked\"].explode()\n", - "chunked" - ] - }, - { - "cell_type": "markdown", - "id": "118cf1c7", - "metadata": {}, - "source": [ - "### 6. Audio transcribe" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "1794c54f", - "metadata": {}, - "outputs": [], - "source": [ - "import gcsfs\n", - "import bigframes.bigquery as bbq\n", - "\n", - "audio_gcs_path = \"gs://bigframes_blob_test/audio/*\"\n", - "\n", - "# List files using gcsfs\n", - "fs = gcsfs.GCSFileSystem()\n", - "uris = fs.glob(audio_gcs_path)\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "# Read the URIs into a BigQuery DataFrame\n", - "# If the bucket is empty or doesn't exist, this will result in an empty DataFrame\n", - "if not uris:\n", - " # Fallback to a dummy list or just let it be empty\n", - " uris = [\"gs://bigframes_blob_test/audio/dummy.mp3\"]\n", - "\n", - "df = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "\n", - "# Create the object reference column\n", - "df['audio'] = bbq.obj.make_ref(df['uri'], authorizer=FULL_CONNECTION_ID)\n", - "df = df[['audio']]" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "c9f9d484", - "metadata": {}, - "outputs": [], - "source": [ - "# The audio_transcribe function is a convenience wrapper around bigframes.bigquery.ai.generate.\n", - "# Here's how to perform the same operation directly:\n", - "\n", - "audio_series = df[\"audio\"]\n", - "prompt_text = (\n", - " \"**Task:** Transcribe the provided audio. **Instructions:** - Your response \"\n", - " \"must contain only the verbatim transcription of the audio. - Do not include \"\n", - " \"any introductory text, summaries, or conversational filler in your response. \"\n", - " \"The output should begin directly with the first word of the audio.\"\n", - ")\n", - "\n", - "# Convert the audio series to the runtime representation required by the model.\n", - "# This involves fetching metadata and getting a signed access URL.\n", - "audio_metadata = bbq.obj.fetch_metadata(audio_series)\n", - "audio_runtime = bbq.obj.get_access_url(audio_metadata, mode=\"R\")\n", - "\n", - "transcribed_results = bbq.ai.generate(\n", - " prompt=(prompt_text, audio_runtime),\n", - " endpoint=\"gemini-2.5-flash\",\n", - " model_params={\"generationConfig\": {\"temperature\": 0.0}},\n", - ")\n", - "\n", - "transcribed_series = transcribed_results.struct.field(\"result\").rename(\"transcribed_content\")\n", - "transcribed_series" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "7209a62a", - "metadata": {}, - "outputs": [], - "source": [ - "# To get verbose results (including status), we can extract both fields from the result struct.\n", - "transcribed_content_series = transcribed_results.struct.field(\"result\")\n", - "transcribed_status_series = transcribed_results.struct.field(\"status\")\n", - "\n", - "transcribed_series_verbose = bpd.DataFrame(\n", - " {\n", - " \"status\": transcribed_status_series,\n", - " \"content\": transcribed_content_series,\n", - " }\n", - ")\n", - "# Package as a struct for consistent display\n", - "transcribed_series_verbose = bbq.struct(transcribed_series_verbose).rename(\"transcription_results\")\n", - "transcribed_series_verbose" - ] - }, - { - "cell_type": "markdown", - "id": "c8351cc3", - "metadata": {}, - "source": [ - "### 7. Extract EXIF metadata from images" - ] - }, - { - "cell_type": "markdown", - "id": "e59670b9", - "metadata": {}, - "source": [ - "This section demonstrates how to extract EXIF metadata from images using a custom BigQuery Python UDF and the `Pillow` library." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "fda362f4", - "metadata": {}, - "outputs": [], - "source": [ - "# Construct the canonical connection ID\n", - "FULL_CONNECTION_ID = f\"{PROJECT}.{LOCATION}.bigframes-default-connection\"\n", - "\n", - "@bpd.udf(\n", - " input_types=[str],\n", - " output_type=str,\n", - " dataset=DATASET_ID,\n", - " name=\"extract_exif\",\n", - " bigquery_connection=FULL_CONNECTION_ID,\n", - " packages=[\"pillow\", \"requests\"],\n", - " max_batching_rows=8192,\n", - " container_cpu=0.33,\n", - " container_memory=\"512Mi\"\n", - ")\n", - "def extract_exif(src_obj_ref_rt: str) -> str:\n", - " import io\n", - " import json\n", - " from PIL import ExifTags, Image\n", - " import requests\n", - " src_obj_ref_rt_json = json.loads(src_obj_ref_rt)\n", - " src_url = src_obj_ref_rt_json[\"access_urls\"][\"read_url\"]\n", - " response = requests.get(src_url, timeout=30)\n", - " bts = response.content\n", - " image = Image.open(io.BytesIO(bts))\n", - " exif_data = image.getexif()\n", - " exif_dict = {}\n", - " if exif_data:\n", - " for tag, value in exif_data.items():\n", - " tag_name = ExifTags.TAGS.get(tag, tag)\n", - " exif_dict[tag_name] = value\n", - " return json.dumps(exif_dict)" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "id": "40bb6bc9", - "metadata": {}, - "outputs": [], - "source": [ - "import gcsfs\n", - "import bigframes.bigquery as bbq\n", - "\n", - "# Create a Multimodal DataFrame from the sample image URIs\n", - "fs = gcsfs.GCSFileSystem()\n", - "uris = fs.glob(\"gs://bigframes_blob_test/images_exif/*\")\n", - "\n", - "# Ensure URIs have gs:// prefix\n", - "uris = [u if u.startswith(\"gs://\") else f\"gs://{u}\" for u in uris]\n", - "\n", - "if not uris:\n", - " uris = [\"gs://bigframes_blob_test/images_exif/dummy.jpg\"]\n", - "\n", - "exif_image_df = bpd.read_gbq(f\"SELECT uri FROM UNNEST({uris[:5]}) as uri\")\n", - "exif_image_df['blob_col'] = bbq.obj.make_ref(exif_image_df['uri'], authorizer=FULL_CONNECTION_ID)\n", - "exif_image_df = exif_image_df[['blob_col']]\n", - "\n", - "# Generate a JSON string containing the runtime information (including signed read URLs)\n", - "# This allows the UDF to download the images from Google Cloud Storage\n", - "access_urls = get_runtime_json_str(exif_image_df[\"blob_col\"], mode=\"R\")\n", - "\n", - "# Apply the BigQuery Python UDF to the runtime JSON strings\n", - "# We cast to string to ensure the input matches the UDF's signature\n", - "exif_json = access_urls.astype(str).apply(extract_exif)\n", - "\n", - "# Parse the resulting JSON strings back into a structured JSON type for easier access\n", - "exif_data = bbq.parse_json(exif_json)\n", - "\n", - "exif_data" - ] - } - ], - "metadata": { - "colab": { - "provenance": [] - }, - "kernelspec": { - "display_name": "venv (3.13.0)", - "language": "python", - "name": "python3" + "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.14" + } }, - "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": 0 + "nbformat": 4, + "nbformat_minor": 0 } diff --git a/notebooks/remote_functions/remote_function.ipynb b/notebooks/remote_functions/remote_function.ipynb index a70d05ae062..e2bc88ecae7 100644 --- a/notebooks/remote_functions/remote_function.ipynb +++ b/notebooks/remote_functions/remote_function.ipynb @@ -1,11 +1,18 @@ { "cells": [ { - "cell_type": "markdown", - "id": "title-cell", + "cell_type": "code", + "execution_count": null, + "id": "bcff4fc4", "metadata": {}, + "outputs": [], "source": [ - "# Remote Functions" + "import sys\n", + "\n", + "# Python 3.13 is not yet a supported runtime for remote functions.\n", + "# See: https://cloud.google.com/functions/docs/runtime-support#python for the supported runtimes.\n", + "if sys.version_info >= (3, 13, 0):\n", + " sys.exit(0)" ] }, { diff --git a/notebooks/remote_functions/remote_function_usecases.ipynb b/notebooks/remote_functions/remote_function_usecases.ipynb index e3a94160ad9..03ae6520952 100644 --- a/notebooks/remote_functions/remote_function_usecases.ipynb +++ b/notebooks/remote_functions/remote_function_usecases.ipynb @@ -21,6 +21,20 @@ "# limitations under the License." ] }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "# Python 3.13 is not yet a supported runtime for remote functions.\n", + "# See: https://cloud.google.com/functions/docs/runtime-support#python for the supported runtimes.\n", + "if sys.version_info >= (3, 13, 0):\n", + " sys.exit(0)" + ] + }, { "cell_type": "markdown", "metadata": {}, diff --git a/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb b/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb index dfc993072cf..9792c90205c 100644 --- a/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb +++ b/notebooks/remote_functions/remote_function_vertex_claude_model.ipynb @@ -28,6 +28,20 @@ "" ] }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import sys\n", + "\n", + "# Python 3.13 is not yet a supported runtime for remote functions.\n", + "# See: https://cloud.google.com/functions/docs/runtime-support#python for the supported runtimes.\n", + "if sys.version_info >= (3, 13, 0):\n", + " sys.exit(0)" + ] + }, { "cell_type": "markdown", "metadata": {}, @@ -94,11 +108,11 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, "outputs": [], "source": [ - "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", + "PROJECT = \"bigframes-dev\" # replace with your project\n", "LOCATION = \"us-east5\"" ] }, @@ -115,7 +129,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -123,7 +137,7 @@ "# and location\n", "\n", "import bigframes.pandas as bpd\n", - "bpd.options.bigquery.project = PROJECT_ID\n", + "bpd.options.bigquery.project = PROJECT\n", "bpd.options.bigquery.location = LOCATION" ] }, @@ -141,6 +155,30 @@ "execution_count": 4, "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "Query job c4c27713-51c8-4293-8454-5c904df79318 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 0b1b71d8-8546-45f2-b403-707161fe4002 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -229,15 +267,13 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "\n", - " Query processed 0 Bytes in a moment of slot time. [Job bigframes-dev:us-east5.9bc70627-6891-44a4-b7d7-8a28e213cdec details]\n", - " " + "Query job 488a116f-44b2-4ff7-9f95-bd36473dab0f is DONE. 0 Bytes processed. Open Job" ], "text/plain": [ "" @@ -255,7 +291,7 @@ ")\n", "def anthropic_transformer(message: str) -> str:\n", " from anthropic import AnthropicVertex\n", - " client = AnthropicVertex(region=LOCATION, project_id=PROJECT_ID)\n", + " client = AnthropicVertex(region=LOCATION, project_id=PROJECT)\n", "\n", " message = client.messages.create(\n", " max_tokens=1024,\n", @@ -265,7 +301,7 @@ " \"content\": message,\n", " }\n", " ],\n", - " model=\"claude-3-haiku@20240307\",\n", + " model=\"claude-3-5-sonnet@20240620\",\n", " )\n", " content_text = message.content[0].text if message.content else \"\"\n", " return content_text" @@ -273,16 +309,16 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": 7, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'bigframes-dev._e9a5162ae4daa9f50fda3f95febaa9781131f3b8.bigframes_sessionc10c73_49262141176cbf70037559ae84e834d3'" + "'bigframes-dev._b52b272a35b88e236e1f96fbe3f560c83a8fee85.bigframes_session265649_de1176dd4c57f40ba959503af3981682'" ] }, - "execution_count": 6, + "execution_count": 7, "metadata": {}, "output_type": "execute_result" } @@ -294,16 +330,16 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": 8, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'projects/bigframes-dev/locations/us-east5/functions/bigframes-sessionc10c73-49262141176cbf70037559ae84e834d3'" + "'projects/bigframes-dev/locations/us-east5/functions/bigframes-session265649-de1176dd4c57f40ba959503af3981682'" ] }, - "execution_count": 7, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -315,40 +351,49 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/html": [ - "\n", - " Query started with request ID bigframes-dev:us-east5.821579f4-63ea-4072-a3ce-318e43768432.
SQL
SELECT\n",
-       "`bfuid_col_3` AS `bfuid_col_3`,\n",
-       "`bfuid_col_4` AS `bfuid_col_4`,\n",
-       "`bfuid_col_5` AS `bfuid_col_5`\n",
-       "FROM\n",
-       "(SELECT\n",
-       "  `t1`.`bfuid_col_3`,\n",
-       "  `t1`.`bfuid_col_4`,\n",
-       "  `t1`.`bfuid_col_5`,\n",
-       "  `t1`.`bfuid_col_6` AS `bfuid_col_7`\n",
-       "FROM (\n",
-       "  SELECT\n",
-       "    `t0`.`level_0`,\n",
-       "    `t0`.`column_0`,\n",
-       "    `t0`.`bfuid_col_6`,\n",
-       "    `t0`.`level_0` AS `bfuid_col_3`,\n",
-       "    `t0`.`column_0` AS `bfuid_col_4`,\n",
-       "    `bigframes-dev._e9a5162ae4daa9f50fda3f95febaa9781131f3b8.bigframes_sessionc10c73_49262141176cbf70037559ae84e834d3`(`t0`.`column_0`) AS `bfuid_col_5`\n",
-       "  FROM (\n",
-       "    SELECT\n",
-       "      *\n",
-       "    FROM UNNEST(ARRAY<STRUCT<`level_0` INT64, `column_0` STRING, `bfuid_col_6` INT64>>[STRUCT(0, 'What is the capital of France?', 0), STRUCT(1, 'Explain the concept of photosynthesis in simple terms.', 1), STRUCT(2, 'Write a haiku about artificial intelligence.', 2)]) AS `level_0`\n",
-       "  ) AS `t0`\n",
-       ") AS `t1`)\n",
-       "ORDER BY `bfuid_col_7` ASC NULLS LAST\n",
-       "LIMIT 10
\n", - " " + "Query job 58b230a8-6536-4bac-ab02-dcf574692dd6 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 46d6a1e9-426a-4615-8eb5-98d34d08ec07 is DONE. 1.3 kB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job cf8fcbaa-b233-47cd-b4e3-60876b24879f is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "Query job 2620a544-d1df-4b30-bec4-4221e79ddf0f is DONE. 1.4 kB processed. Open Job" ], "text/plain": [ "" @@ -391,12 +436,13 @@ " \n", " 1\n", " Explain the concept of photosynthesis in simpl...\n", - " Photosynthesis is the process by which plants ...\n", + " Photosynthesis is the process plants use to ma...\n", " \n", " \n", " 2\n", " Write a haiku about artificial intelligence.\n", - " Here is a haiku about artificial intelligence:...\n", + " Here's a haiku about artificial intelligence:\n", + "...\n", " \n", " \n", "\n", @@ -411,13 +457,14 @@ "\n", " answers \n", "0 The capital of France is Paris. \n", - "1 Photosynthesis is the process by which plants ... \n", - "2 Here is a haiku about artificial intelligence:... \n", + "1 Photosynthesis is the process plants use to ma... \n", + "2 Here's a haiku about artificial intelligence:\n", + "... \n", "\n", "[3 rows x 2 columns]" ] }, - "execution_count": 8, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -437,22 +484,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/html": [ - "Session sessionc10c73 closed." - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "bpd.close_session()" ] @@ -460,7 +494,7 @@ ], "metadata": { "kernelspec": { - "display_name": "venv (3.14.2)", + "display_name": "venv", "language": "python", "name": "python3" }, @@ -474,7 +508,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.2" + "version": "3.10.9" } }, "nbformat": 4, diff --git a/notebooks/streaming/streaming_dataframe.ipynb b/notebooks/streaming/streaming_dataframe.ipynb index e3dafa98195..b7da0cfd077 100644 --- a/notebooks/streaming/streaming_dataframe.ipynb +++ b/notebooks/streaming/streaming_dataframe.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# BigFrames StreamingDataFrame", + "### BigFrames StreamingDataFrame\n", "bigframes.streaming.StreamingDataFrame is a special DataFrame type that allows simple operations and can create streaming jobs to process real-time data and reverse ETL output to Bigtable and Pub/Sub using [BigQuery continuous queries](https://cloud.google.com/bigquery/docs/continuous-queries-introduction).\n", "\n", "In this notebook, we will:\n", @@ -97,7 +97,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Create, select, filter and preview", + "### Create, select, filter and preview\n", "Create the StreamingDataFrame from a BigQuery table, select certain columns, filter rows and preview the output" ] }, diff --git a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb index b28df7b0d7d..f0dd5eb6784 100644 --- a/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb +++ b/notebooks/visualization/bq_dataframes_covid_line_graphs.ipynb @@ -1,648 +1,728 @@ { - "cells": [ - { - "cell_type": "code", - "execution_count": 1, - "metadata": { - "id": "9GIt_orUtNvA" - }, - "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": "h7AT6h2ItNvD" - }, - "source": [ - "# Use BigQuery DataFrames to visualize COVID-19 data", - "\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", - "
" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "n-MFJQxLtNvE" - }, - "source": [ - "## Overview\n", - "\n", - "The goal of this notebook is to demonstrate creating line graphs from a ~20 million-row BigQuery dataset using BigQuery DataFrames. We will first create a plain line graph using matplotlip, then we will downsample and download our data to create a graph with a line of best fit using seaborn.\n", - "\n", - "If you're like me, during 2020 (and/or later years) you often found yourself looking at charts like [these](https://health.google.com/covid-19/open-data/explorer/statistics) visualizing COVID-19 cases over time. For our first graph, we're going to recreate one of those charts by filtering, summing, and then graphing COVID-19 data from the United States. BigQuery DataFrame's default integration with matplotlib will get us a satisfying result for this first graph.\n", - "\n", - "For our second graph, though, we want to use a scatterplot with a line of best fit, something that matplotlib will not do for us automatically. So, we'll demonstrate how to downsample our data and use seaborn to make our plot. Our second graph will be of symptom-related search trends against new cases of COVID-19, so we'll see if searches for things like \"cough\" and \"fever\" are more common in the places and times where more new cases of COVID-19 occur." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "ffqBzbNztNvF" - }, - "source": [ - "### Dataset\n", - "\n", - "This notebook uses the [BigQuery COVID-19 Open Data](https://pantheon.corp.google.com/marketplace/product/bigquery-public-datasets/covid19-open-data). In this dataset, each row represents a new observation of the COVID-19 situation in a particular time and place. We will use the \"new_confirmed\" column, which contains the number of new COVID-19 cases at each observation, along with the \"search_trends_cough\", \"search_trends_fever\", and \"search_trends_bruise\" columns, which are [Google Trends](https://trends.google.com/trends/) data for searches related to cough, fever, and bruises. In the first section of the notebook, we will also use the \"country_code\" and \"date\" columns to compile one data point per day for a particular country." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Nf__tMR-tNvF" - }, - "source": [ - "### Costs\n", - "\n", - "This tutorial uses billable components of Google Cloud:\n", - "\n", - "* BigQuery (compute)\n", - "\n", - "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models),\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": "7_rsbkCktNvG" - }, - "source": [ - "## Before you begin\n", - "\n", - "### 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 free 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, you need to install the [Cloud SDK](https://cloud.google.com/sdk)." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "XZKC6iMFxmMG" - }, - "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": 2, - "metadata": { - "id": "4aooKMmnxrWF" - }, - "outputs": [], - "source": [ - "PROJECT_ID = \"\" # @param {type:\"string\"}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "pv5A8Tm-yC1U" - }, - "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": 3, - "metadata": { - "id": "bk03Rt_HyGx-" - }, - "outputs": [], - "source": [ - "REGION = \"US\" # @param {type: \"string\"}" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "B9RWxD1btNvK" - }, - "source": [ - "Now we are ready to use BigQuery DataFrames!" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "wJ0gXezj2w1t" - }, - "source": [ - "## Visualization #1: Cases over time in the US" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "xckgWno6ouHY" - }, - "source": [ - "### Set up project and filter data" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "-uiY0hh4tNvK" - }, - "source": [ - "First, let's do project setup. We use options to tell BigQuery DataFrames what project and what region to use for our cloud computing." - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "metadata": { - "id": "R7STCS8xB5d2" - }, - "outputs": [], - "source": [ - "import bigframes.pandas as bpd\n", - "\n", - "# 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", - "# Improves performance by avoiding generating total row ordering\n", - "bpd.options.bigquery.ordering_mode = \"partial\"" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "v6FGschEowht" - }, - "source": [ - "Next, we read the data from a publicly available BigQuery dataset. This will take ~1 minute." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": { - "id": "zDSwoBo1CU3G" - }, - "outputs": [], - "source": [ - "all_data = bpd.read_gbq(\"bigquery-public-data.covid19_open_data.covid19_open_data\")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "9qV2y3iHp13y" - }, - "source": [ - "Using pandas syntax, we will select from our all_data input dataframe only those rows where the country_code is US. This is called row filtering." - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "metadata": { - "id": "UjMT_qhjf8Fu" - }, - "outputs": [], - "source": [ - "usa_data = all_data[all_data[\"country_code\"] == \"US\"]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "IYCUayWkwq8c" - }, - "source": [ - "We're only concerned with the date and the total number of confirmed cases for now, so select just those two columns as well." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": { - "id": "IaoUf57ZwrJ8" - }, - "outputs": [], - "source": [ - "usa_data = usa_data[[\"date\", \"new_confirmed\"]]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "94oqNRnDvGkr" - }, - "source": [ - "### Sum data" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "TNCQWZW83U0b" - }, - "source": [ - "`usa_data.groupby(\"date\")` will give us a groupby object that lets us perform operations on groups of rows with the same date. We call sum on that object to get the sum for each day. This process might be familiar to pandas users." - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "metadata": { - "id": "tYDoaKgJChiq" - }, - "outputs": [], - "source": [ - "# numeric_only = True because we don't want to sum dates\n", - "new_cases_usa = usa_data.groupby(\"date\").sum(numeric_only = True)" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "3jcwFPgK5BLh" - }, - "source": [ - "### Line graph" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "8GvJAgnH5Nzi" - }, - "source": [ - "BigQuery DataFrames implements some plotting methods with the matplotlib backend. Use `DataFrame.plot.line()` to draw a simple line graph." - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "metadata": { - "id": "gFbCgfFC2gHw" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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", - "text/plain": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "new_cases_usa.plot.line(\n", - " rot=45,\n", - " ylabel=\"New Cases\",\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "sM5-HFDx70RG" - }, - "source": [ - "## Visualization #2: Symptom-related searches compared to new cases" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "se1b6Vf4XB9_" - }, - "source": [ - "### Filter data" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Wl2o-NYMoygb" - }, - "source": [ - "We're curious if searches for symptoms like \"cough\" and \"fever\" went up in the same times and places that new COVID-19 cases occured, compared to non-symptoms like \"bruise.\" Let's plot searches vs. new cases to see if it looks like there's a correlation." - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "olfnCzyg8jYi" - }, - "source": [ - "First, we select the new cases column and the search trends we're interested in." - ] - }, - { - "cell_type": "code", - "execution_count": 55, - "metadata": { - "id": "LqqHzjty8jk0" - }, - "outputs": [], - "source": [ - "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\"]]" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "b3DlJX-k9SPk" - }, - "source": [ - "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": null, - "metadata": { - "id": "g4MeM8Oe9Q6X" - }, - "outputs": [], - "source": [ - "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", - "\n", - "# remove impossible data points\n", - "symptom_data = symptom_data[(symptom_data[\"new_cases_percent_of_pop\"] >= 0)]\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# 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": "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", - "\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": "T9Hub_EAXWvY" - }, - "source": [ - "### Graph with lines of best fit" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "id": "hoQ9TPgUPJnN" - }, - "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 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": 59, - "metadata": { - "id": "EG7qM3R18bOb" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 59, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAh8AAAGdCAYAAACyzRGfAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAAsz5JREFUeJzs/XeQZPd5341+Tu7ck/PMJgCLBbBYLLEgGCVSlEVRDKIYAPnVK8tSlZOqLMv0lSW6JNlyWWLJrlLR9vWVS657JbkcXoCkSNGiKVqiKAYxYReZ2AV2sWly6BxOPuf+caYbE3pmZ2Z7Znpmfp8qFLlzZrp/3bvTz/c84ftIYRiGCAQCgUAgEOwR8n4fQCAQCAQCwdFCiA+BQCAQCAR7ihAfAoFAIBAI9hQhPgQCgUAgEOwpQnwIBAKBQCDYU4T4EAgEAoFAsKcI8SEQCAQCgWBPEeJDIBAIBALBnqLu9wHWEgQBMzMzpNNpJEna7+MIBAKBQCDYAmEYUqlUGBkZQZY3z210nPiYmZlhfHx8v48hEAgEAoFgB0xOTjI2Nrbp93Sc+Ein00B0+Ewms8+nEQgEAoFAsBXK5TLj4+PNOL4ZHSc+GqWWTCYjxIdAIBAIBAeMrbRMiIZTgUAgEAgEe4oQHwKBQCAQCPYUIT4EAoFAIBDsKUJ8CAQCgUAg2FOE+BAIBAKBQLCnCPEhEAgEAoFgTxHiQyAQCAQCwZ4ixIdAIBAIBII9RYgPgUAgEAgEe4oQHwKBQCAQCPYUIT4EAoFAIBDsKR2320UgaBCGIYsVm6rtkTJU+tPGlnYGCAQCgaCzEeJD0LEsVmxenCrhByGKLPHwWJaBTGy/jyUQCASCu0SUXQQdS9X28IOQka44fhBStb39PpJAIBAI2oAQH4KOJWWoKLLETNFEkSVShkjUCQQCwWFAfJoLOpb+tMHDY9lVPR8CgUAgOPgI8SHoWCRJYiATY2C/DyIQCASCtiLKLgKBQCAQCPYUIT4EAoFAIBDsKaLsIugohLeHQCAQHH6E+BB0FMLbQyAQCA4/ouwi6CiEt4dAIBAcfoT4EHQUwttDIBAIDj/ik13QUQhvD4FAIDj8CPEh6CiEt4dAIBAcfoT4OKCIqRCBQCAQHFSE+DigiKkQgUAgEBxURMPpAUVMhQgEAoHgoCLExwFFTIUIBAKB4KAiItYBRUyFCAQCgeCgIsTHAeWoToWIRluBQCA4+AjxIThQiEZbgUAgOPiIng/BgUI02goEAsHBR4gPwYFCNNoKBALBwUd8cgsOFKLRViAQCA4+QnwIDhRHtdFWIBAIDhOi7CIQCAQCgWBPEZkPQUchRmkFAoHg8LPtzMc3vvENPvjBDzIyMoIkSXzhC1/Y8Hv/4T/8h0iSxKc//em7OKLgKNEYpb06X+XFqRKLFXu/jyQQCASCNrNt8VGr1Th37hz/6T/9p02/7/Of/zzf/e53GRkZ2fHhBEcPMUorEAgEh59tl13e97738b73vW/T75menuYf/+N/zFe+8hXe//737/hwgqOHGKUVCASCw0/bP9mDIOBnf/Zn+ZVf+RUefPDBO36/bdvY9hup9XK53O4jCQ4QYpRWIBAIDj9tn3b53d/9XVRV5Zd+6Ze29P2f+tSnyGazzf/Gx8fbfSTBAaIxSnuyP8VAJiaaTQUCgeAQ0lbxcenSJf79v//3/NEf/dGWg8YnP/lJSqVS87/Jycl2HkkgEAgEAkGH0Vbx8c1vfpOFhQUmJiZQVRVVVbl16xb/7J/9M44fP97yZwzDIJPJrPrvMBKGIQtli+uLVRbKFmEY7veR2sphf30CgUAgaB9t7fn42Z/9WX70R3901dfe+9738rM/+7P8/M//fDuf6sDRSdtY7+SlsROvjU56fQKBQCDobLYtPqrVKteuXWv++caNGzz//PP09PQwMTFBb2/vqu/XNI2hoSFOnz5996c9wKwcIZ0pmlRtb98swu8kFHYiJDrp9QkEAoGgs9l22eXixYucP3+e8+fPA/CJT3yC8+fP85u/+ZttP9xhopNGSO/kpbETr41Oen0CgUAg6Gy2HSHe9a53bauef/Pmze0+xaGkk0ZI7yQUdiIkOun1CQQCgaA1puNTtb19/4wWt6d7RCdtY72TUNiJkOik1ycQCASC1dieT77mYDo+urr/O2WF+DiC3EkoCCEhEAgEhwPXDyjUnI5bVSHEh0AgEAgEhww/CCnUHSqW15HWB0J8CAQCgUBwSAiCkJLpUjJdgg4UHQ2E+BAIBAKB4IAThiFl06NoOvhB54qOBkJ8CAQCgUBwgKlYLsW6i+sH+32ULSPEh0AgEAgEB5C645GvOTjewREdDYT4EAgEAoHgAGG50dis5fr7fZQdI8SHQCAQCAQHAMcLKNQdah02NrsThPgQCAQCgaCD8fyAQt2lYrn7fZS2IcSHQCAQCAQdiL9ibLYTvTruBiE+BAKBQCDoIMLwDdFxEMZmd4IQH3tAGIYsVuxVu1IkSdrvYwkEAoGgwyhbLsWaixccvAmW7SDExx6wWLF5caqEH4QossTDY1kGMrH9PpZAIBAIOoSaHY3NHiSvjrtBiI89oGp7+EHIcFeMyzNlLs+WAUQGRCAQCI44luuTqznYB3hsdicI8bEHpAwVRZa4PFNmqmAiIeH6JZEBEQgEgiOK7fkUai515+CPze4EIT72gP60wcNjWS7PlpGQuH84zWzJomp7Ym29QCAQHCFcP/LqqFpHU3Q0kPf7AEcBSZLoTxv0pw3cIODybBlFjjIiAoFAIDj8+EFIrmozVTCPvPAAkfnYMxYrNtMFE02WcXyfka44/Wljv48lEAgEgl3koKy432uE+NgjqrZHEMKZkQwzRZOYphzIZlMxNiwQCAR3JgxDypZHsX4wVtzvNUJ87BGNptOZookiSwe25CLGhgUCgWBzqrZH4QiNze6EgxkBDyCNptOVGYODSGNseKQrzkzRFE2zAoFAsIzp+OTrR29sdicI8bFHSJLEQCZ24AP1YcngCAQCQbuwXJ9C3cF0hOjYKiJyCLZFJ2dwRD+KQCDYS1w/oFBzqB6CFfd7jRAfgm3RyRkc0Y8iEAj2As8PKJouFcs7dNtm9wrh8yE4NKzsR/GDUNyNCASCthIEIfmaw1TBpHwI19zvJSLzITg0iH4UgUCwG4RhSNn0KJpibLZdiE9nwaGhk/tRBALBwaRiuRTrrhibbTNCfAgODZ3cjyIQCA4WdSdace94QnTsBkJ8CAQCgUCwjOX65GsOlvDq2FWE+BAIBALBkcfxom2zNdGovicI8SEQCASCI4vnBxTqLhXL3e+j7All0+VLL81yY6nOf/k7j+6bF5IQHx2MMM0SCASC3cEPQop1h/IR8eqYK1l89tIU//vlWSw36mP5/o08j5/s3ZfzCPHRwdzJNEuIE4FAINgeYfjGivujMDb72nyFp56Z5OuvLbL25f7//uaGEB+C9dxpiZtw9BQIBIKtU7ZcijUXLzjcEyxhGPL9m3mevjjFc7eL665n4xp/923H+TtvPbb3h1tGiI8O5k6mWWLDrEAgENyZmh2NzR52rw7XD/jalQWeujjFjaXauuvD2Rgff3SMDz0ywj0D6X044RsI8dHB3Mk0Szh6CgQCwcZYrk+udvhX3Fdtjz97cZY/eXaKpaqz7vrpoTRPXhjnnff2ocgSurr/m1VEtOpA1vZynOhLtuzlEI6eAoFAsB7b8ynUXOrO4R6bXazYfO7ZKb704iw1Z73AesvJHp58bJyHR7Md1w8oxEcHstVejt129BQNrQKB4CDh+pFXR9U63KLj+mKVpy9O8dUrC+uaZjVF4kfPDPLxC2Mc703u0wnvjBAfHUin9HKIhlaBQHAQ8IOQQt051CvuwzDkuckiTz8zyfdvFtZdTxoKHzo3wkfOj9Kb6vws+LYLP9/4xjf44Ac/yMjICJIk8YUvfKF5zXVdfvVXf5WzZ8+STCYZGRnh7/ydv8PMzEw7z3zo6ZReDrGiXiAQdDJBEFKoOUzm64d2xb0fhPzVlQX+4X97lv/XZ15cJzwG0gb/6F2neOrvv4W/986TB0J4wA4yH7VajXPnzvELv/ALfOQjH1l1rV6v8+yzz/Ibv/EbnDt3jkKhwD/5J/+ED33oQ1y8eLFthz7sdEovR6eIIIFAIFhJGIaULY9i/fCuuDddny+/NMtnL00zV7bWXb+nP8WTj43xw/f1oyr730C6XaTwLqSiJEl8/vOf58Mf/vCG3/PMM8/w5je/mVu3bjExMXHHxyyXy2SzWUqlEplMZqdHaxtHue/hbl/7UX7vBALB7lC1PQqHeGw2X3P4/HPTfPGFGSotelcuHOvmycfGedNE144/T3VVZqw7cbdHXcd24veu38qWSiUkSaKrq6vlddu2sW27+edyubzbR9oWR7nv4W4bWo/yeycQCNqL6fjkavahXXF/O1fn6UuT/MUr87j+6pyAIku8+3Q/T14Y59RAap9O2F52VXxYlsWv/uqv8rf/9t/eUAV96lOf4rd+67d28xh3Rac0fx5ExHsnEAjuFsv1KdQdzBajpAedMAx5ebrMUxcn+fbruXXXE7rC+88O89E3jR66G7ddEx+u6/LEE08QhiG///u/v+H3ffKTn+QTn/hE88/lcpnx8fHdOta2EX0PO0e8dwKBYKc4XkCx7hzKRnc/CPmb15d4+plJXpmtrLvem9L56PlRPvDwCKnY4fzc3JVX1RAet27d4q/+6q82rf0YhoFhdG53bjubP49aD0SnNM4KBIKDQ2PFfdU+fGOztuvzlVfm+eylKaYK5rrrx3oTPHFhnPfcP9ARLqS7SdvFR0N4XL16la997Wv09u7Pxrx20arvYaci4qj1QOy2CZpAIDg8BEFI0XQpmy7BIRMdpbrLn74wzReem6FouuuunxvL8uRj47z5RA/yIb4hXcm2xUe1WuXatWvNP9+4cYPnn3+enp4ehoeH+djHPsazzz7Ln/3Zn+H7PnNzcwD09PSg63r7Tr6P7FREiB4IgUAgWE0YhpRNj6J5+MZmZ4omn7k0xZ+/PIe9plFWluCH7u3nicfGuH9o/yc795pti4+LFy/y7ne/u/nnRr/Gz/3cz/Gv/tW/4otf/CIAjzzyyKqf+9rXvsa73vWunZ+0g9ipiNitHoijVs4RCASHg4rlUqy7h25s9vJs1ET6ratLrNVTMVXmxx8a4mOPjjHSFd+fA3YA245+73rXuzatwx22Gl0rdioidqsH4qiVcwQCwcGm7kQr7g/T2GwQhnzvep6nLk7y4lRp3fWuuMZPnR/lQ4+MkI1r+3DCzuJwttHuMjsVEbvVAyHKOQKB4CBguT75moN1iFbcO17AVy/P8/TFKW7l6+uuj3XHeeLCGH/rzCCGpuzDCTsTIT6W2U7pYqciYqflkTv9nBhpFQgEnYzjRdtma4dobLZiufyvF2b5k+emydecddcfGM7w5GPjvO1UL4osyuBrEVFqmb0oXez0Oe70c1vNxIjeEIFAsJc0xmYr1voJj4PKfNnis5em+N8vzWGuyeBIwNtO9fLkY+M8NJrdnwMeEIT4WGYvShc7fY47/dxWMzGiN0QgEOwFfhBSrDuUD9GK+2sLVZ56ZpKvvbqwrolUUyTe+2DURDrR0/6dKYcRIT6W2YvSxU6fo11nE70hAoFgNwnDkJIZTbAcBq+OMAy5eKvA089Mcul2cd31dEzlQ+dG+Knzo/QkD4eVxF4hxMcye+HGudFz3Kkc0q6zid4QgUCwW5Qtl2LNxQsO/gSL5wf89WuLPPXMJK8v1tZdH8rE+NijY7zv7BBx0US6I0T0WWYv3Dg3eo47lUPadTZhdy4QCNpNzY7GZg+DV0fd8fjSi7N87tlpFir2uuv3DqR48rFxfvi+ftFEepcI8dEB7EU5RDSbCgSCdmK5Prmag30IxmaXqjZ/8uw0/+vFGWr2+tfz5hM9PHlhjEfGu8TnZpsQ4qMD2ItyiGg2FQgE7cD2fAo1l7pz8MdmbyzVePriJF+9vIC3potUlSXec2aAJy6Mc6IvuU8nPLwI8dEB7EU5RDSbCgSCu8H1I6+OqnWwRUcYhrw4VeKpi5N893p+3fWkrvCBh4f5yJvGRGl6FxHiowPYi34T0WwqEAh2gh+EFOoOlQM+NusHId+8ushTF6d4da6y7npfSuejbxrjAw8PkxSfj7uOeIePCJtlV0Q/iEAgWEsQRGOzpQO+4t50ff785Tk+e2mK2ZK17vrJviRPPDbOu0/3oynyPpzwaCLExxFhs+yK6AcRCAQNwjCkbHkU6wd7xX2h7vCF56b50+dnKLcoFb1poosnHxvnwrFucbO1DwjxIRD9IAKBAIg+CwoHfGx2qlDnMxen+Mor8+u25soSvOv0AE9cGOO+wfQ+nVAAQnwIiPpBZAkuz5RxfJ/xnjhhGIq7AYHgiGA6PrmafaBX3P9gpsRTz0zxN9eWWJuviWkyP3F2mI+9aYyhrMjqdgJCfOwSB6mPoj9tMNodZ6FioykyM0WTvpQhSi8CwSHHcn0KdQfTOZheHUEY8u1rOZ66OMkPZsrrrvckdT5yfpQPnhsmHdP24YSdRWPYoBPeCyE+domD1EchSRIxTaEvZXRM6eUgiTeB4KDheAHFukP1gK64d7yA//PKHE9fnGKqYK67PtGT4IkLY/zomUF09Wg3kUqSREJXSBkqCV3pmM9RIT52iYPWR7GTUdzdFAgHSbwJBAeFxor7qn0wx2bLpsufvjDDF56bplB3110/O5rlycfGeMvJXuQOCbL7habIZGIaqZjakVbwQnzsEp3iq7FVgbATo7OFssW3ri01f+Yd9/QxmI235dwHTbwJBJ1MEIQUl8dmD6LomC2ZfPbSNF9+aRZrTV+KBLzz3j6efGycM8OZ/TlghyBLEklDJR1TiXX4wjshPnaJTlnittUMwk6Mzm7n67y+WKMrrjFfrjHRk2ib+OgU8SYQHGTCMKRsehTNgzk2++pchacvTvL11xZZe3xdlfnxB4f4+KNjjHa353PnoBJfLqukDLVjyip3Qnyi7xJ74Vq6FfYmg9D+f+ydIt4EgoNKxXIpHMAV92EY8r0beZ6+OMnzk6V117NxjQ8/MsJPPjJCV0LfhxN2Bqosk46ppGLqgTRHE+KjBYep2XE3MwgTPQlO9iWp2R4n+5JM9CTa9tidIt4EgoNG3YlW3B+0sVnXD/jq5QWevjjJzVx93fWRrhgff3Sc9z442PElhd1CkiSSukI6phHXD/Z7IMRHCw5Ts+NuZhAGMjF+6L5+kZ0QCDoAy/XJ1xysA7bivmp7/NkLM3zuuWlyVWfd9TPDaZ68MM7b7+nryMbJvcDQorJK2lCRD8l7IMRHCw5Ts+NuZhBEdkIg2H8cL9o2WztgY7MLZYvPPTvNl16apd7CZ+StJ3t58rExzo5mD2zm+W5oZKpTMRVDPdhZjlYcGfGxnVLKVkoVe1WaOUwlIIFA0D48PyB/AFfcv75Y5emLU/zVlYV1TbCaIvG3zgzy8QtjHOtN7tMJ9w9JkohrCulYZ3ly7AZHRnxsp5SylVLFXpVm7vQ8QpwIBEcLPwgp1h3KB2jFfRiGPHu7yFPPTHLxVmHd9ZSh8qFzw/zU+VF6U0evfKspy82jhop6AJtHd8KRER/bKaVspZywV6WZOz3PYepPEQgEGxOG0Yr7Yv3grLj3g5C/fnWRpy5Ocm2huu76QNrgY4+O8RNnh0joRyYcAQfLk2M3ODJ/2+2e+tgrH4o7Pc9h6k8RCAStKVsuxQM0Nms6Pl96aZbPXppioWKvu35Pf4onHxvjh+/rPzJ3+g1iy2WVg+TJsRscGfHRrqmPRpmjYrmMdMUwVJl0TNu1SY87nVuYcQkEh5eaHY3NHpQV9/maw+efm+aLL8xQadGLcuFYN08+Ns6bJrqOVOBVZZlULMpyHERPjt3gyESqdk1m7HWZ407n7gQzLtF3IhC0F9Pxydcd7AMyNnsrV+MzF6f4i8vzuP7qkpAiS/zI/QM88egYpwZS+3TCvecweXLsBkdGfGyHzYJpp5U5OmHctZUg608bQpAIBNvE9nwKNZe60/kTLGEY8tJ0iaeemeI713Prrid0hfefHeajbxo9Un1oDU+ORlZa0BohPlqwWXajU8sc+5l9aCXIANEIKxBsEdePvDoOwtisH4T8zbUlnro4yeXZyrrrvSmdj54f5QPnRjrm83G3OeyeHLvB0fiXsU02y250QpmjFfs59dJKkHVahkgg6ET8IKRQd6gcgLFZy/X5yg/m+eylKaaL5rrrx3sTPHFhnPecGTgyfQ0JXT0Snhy7gRAfK2hkD3LVqKF0uhCiKvIq9d4JZY5W7Gew30iQyRJcninj+D7jPXHCMBS/oAIB0Yr70vKK+04fmy3WHb7w/Ax/+vwMJdNdd/2R8SxPXBjn8RM9R+L3+yh6cuwGQnysoJE98IIASYrSh8d6kx2T3diM/SwHtRJk/WmD0e44CxUbTZGZKZr0pQxRehEcacIwpGx5FOudv+J+umDymUtT/PkP5tYtqZMl+OH7+nniwjinh9L7dMK946h7cuwGQnysoJE9GO1KMFM06T1AwbLTykGSJBHTFPpShii9CAREny+FAzA2e3m2zFPPTPLNq0uslUcxVeZ9Z4f52KOjDGfj+3K+vaThyZHUD89Ct05BiI8VdGoz6VboxHLQZu+nGM8VHBUOwor7IAz57vUcTz0zxUvTpXXXuxMaHz4/yofOjZCNa/twwr1DeHLsDQcnuu4B7cgeiKD6Bpu9n8IWXnDYsVyfQt3BbLGxtVNwvIC/vDzP0xenuJ2vr7s+1h3niQtj/NgDQ+jq4Q3EDU+OVEw9cjbv+4V4l1fQjuzBToPqYRQtm72fYhpGcFhxvIBi3WmOnHciFcvlf70wy588N02+5qy7/tBIhicujPO2e3qRD/jn0Gboyw7VwpNj7xHio83sNKgetUzAQS5xCQSt8PyAQt2lanfu2Oxc2eJzl6b40kuzWO7qMpAEvP2ePp58bIwHR7L7c8A9QJHfaB4Vnhz7h/jEbzM7Dap3kwk4iFmTTmuQFQh2ShCEFJfHZjtVdFydr/D0xSm+9uoCa4dsNEXivQ8O8fFHxxjvSezPAfeAhB6ZgCWFJ0dHsG3x8Y1vfIN/9+/+HZcuXWJ2dpbPf/7zfPjDH25eD8OQf/kv/yX/5b/8F4rFIm9/+9v5/d//fe699952nrtj2WlQTeoKVdvl2dsmKSP6BdkqBzFr0okNsgLBdgjDkLLpUTQ7c2w2DEMu3irw1DOTPHu7uO56Jqbyk4+M8OHzo3Qn9L0/4B4gPDk6l22Lj1qtxrlz5/iFX/gFPvKRj6y7/m//7b/lP/yH/8Af//Efc+LECX7jN36D9773vbzyyivEYp0dENvB3QTVMATC5f/dBqJ/Yv85iNknwc6pWC6FDl1x7/oBX7uywNMXp7i+VFt3fTgb42OPjvHjDw0RP4SeFbIkkTAUMjFNeHJ0MNsWH+973/t43/ve1/JaGIZ8+tOf5td//df5yZ/8SQD+63/9rwwODvKFL3yBn/7pn7670x5AWgUlYN3Xao5POqZxeijDTNGkto0OedE/sf8cxOyTYPt08thszfb4sxdn+ZNnp1ms2uuunx5M8+RjY7zz3v5D2VwpPDkOFm2NUjdu3GBubo4f/dEfbX4tm83y+OOP853vfKel+LBtG9t+4xelXC6380j7TqugBOuXrt2NgBD9E/uPyD4dbizXJ19zsDpwxf1ixeZPnp3iz16cbXnT8viJHp58bJxzY9lDl41reHKkDPVQjwIfRtoqPubm5gAYHBxc9fXBwcHmtbV86lOf4rd+67faeYyOYqONr2u/dqIvuWMBIfon9h+RfTqcOF60bbbWgWOzN5ZqPH1xkq9eXsBb03OiyhLvOTPAExfGOdGX3KcT7g6SJJHQleWFbuL37KCy739zn/zkJ/nEJz7R/HO5XGZ8fHwfT9ReNgpKa78mBMTBRmSfDheeH5DvwBX3YRjy/GSRpy5O8f0b+XXXk4bCBx8e4SNvGqUvdbj+DeqqTNrQSMWEJ8dhoK3iY2hoCID5+XmGh4ebX5+fn+eRRx5p+TOGYWAYh+uXZCUbBaW1X9tJw6JocuwchHg8HPhBSLHuUO6wFfd+EPL11xZ5+uIkr81X113vTxl87NFRfuLsMMlDlHUTnhyHl7b+Kz1x4gRDQ0N89atfbYqNcrnM9773Pf7RP/pH7XyqtrHbAXyjoLT2awtla9sNi1E/SZFc1cELQs5PdHFmOLPt8wsRIzjqhOEbK+47aWzWdH2+/NIsn700zVzZWnf9ZH+SJy+M8+7T/YdqlFR4chx+ti0+qtUq165da/75xo0bPP/88/T09DAxMcEv//Iv82/+zb/h3nvvbY7ajoyMrPIC6SQ6ZUphJw2LVdsjV3Wo2B5LFZswDHe0tr5T3gOBYD8oWy7FDhubzdccvvD8NF98foZyi9LPoxNdPPHYOBeOdR+a4Cw8OY4W2xYfFy9e5N3vfnfzz41+jZ/7uZ/jj/7oj/jn//yfU6vV+Pt//+9TLBZ5xzvewZ//+Z93rMfHXk8pbJRl2EnDYspQ8YKQpYpNf9pAV5QdnV9MagiOInUnEu+dtOJ+Ml/nM5em+MoP5nD91RkYWYJ3nx7giQtj3DuY3qcTthfhyXF0kcJOKmwSlWmy2SylUolMJrPrz7eTcsduPN92Sx9hGLJQtnhhqsjrC1V6EgY9KZ1z413bPv9evwcCwX7SiWOzL0+XeOriJN++lmPtB3JMk3n/2WE++ugYQ4fk9zKmRRtkU8KT41Cxnfh9eDqTdsheTynsNMuwVpyEYchL02WCMOofmehJcKw3uaPzi0kNwVGg08ZmgzDk29dyPHVxkh/MrPc36knqfOT8KB88N0w6pu3DCduL8OQQrOTIi4+9nlLYqLxyp76LtdezcRU/CBntSjBTNOndQa9HAzGpITjMNLbNVix3v48CgO36/J9X5vnMpSmmCua668d6EjxxYYz3nBk88EFaeHIINkL8a9hjNsoybJYRCcOQW7ka04U6x/tSmE505yZMrQSCjem0bbMl0+WLz8/w+eemKZrrhdDDY1mevDDO4yd7kA94E6nw5BDcCRGx9piNsgybNZwuVmxu5+vMV2zmKzYn+5Kcn+hCkiRRKhEI1tBp22ZnSyafuTjFn788h7VmJ4wswTvu7ePJC+OcGd79HrfdRJakZllFNI8K7oQQHx3CZn0XVdsjaag8fqKHm7kax3oTq0osDcv2RpOq8O0QHFUqlkux7nbEBMurcxWeemaSb1xdZK0GMlSZH39wiI89OsZod3x/Dtgm4rpCOqYJTw7BthDio0PYrO8iZaiosozlBox2RY2lkiRtOKWyU9+O/RAtQigJ2kGnbJsNwpDv38jz9MVJnp8srbuejWv81PkRfvLcKNnEwW0i1RSZ1LLzqPDkEOwEIT4OANvtE9nORM3K4G+5PtMFkyBkQ9HSbrEgDM4Ed4PtRWOzZottrnuJ4wV89coCT1+c5Fauvu76aFecj18Y470PDGIc0JKEJEkkDYW0oRHXD+ZrEHQOQnzskL28Y99un8h2DMtWBv+lqo0my5wZyWwoWtotFoTBmWAnuH5AoeY0S477RdX2+LMXZvjcc9Pkqs666w8Mp3nisXHefqrvwDZeGlo0rSI8OQTtRIiPHdKuIHw3ImajjMh2fDtWBv9i3cHx/U1FS7vFglhFL9gOfhBSqDtU9nnx20LZ4nPPTvOll2apt8i6vO1UL09eGOeh0e3vWuoEGr+L6Zh24Md9BZ2J+KTfIe0KwncjYjbKiGzHt2Nl8O9N6Yx0xSP3wQ1ES7vFgjA4E2yFIHhj8Vuwj6Lj9YUqT12c5GuvLq6bpNEUib/1wCBPPDrORG9in064cxqeHClDJSGaRwW7jBAfO6RdQXi/yw6tgv9mHzrtFgvC4EywGWEYUrY8SvX9W/wWhiGXbhV46uIUl24V1l1Px1Q+dG6Enzo/Sk9S34cT3h2aIpOJCU8Owd4ixMcO2SwIb6eUsp9lh52UfIRYEOwVVdujUNu/xW+eH/D11xZ56pkpri1W110fzBh8/NEx3vfQ8IFrwJQlieTytIrw5BDsB0J87JDNgvB2Sin7WXY4zJMmYoT34GK5Prmag71Pi9/qjseXXprjc5emWKjY667fM5Dipx8b54fv6z9wmYL4clklZaji90GwrwjxsUW2E8y2U0q5UyZhN4PobpV8OiHwH2ZhdVixPZ9CzaXu7M8ES65q8yfPTfO/XphtOUXz5uPdPPHYOOfHuw5U4G54cqRiKprw5BB0CEdGfGw1IDZW1d/OR7P6Ez2JbRt3tbOUcrdBdLPXvVsln04I/PvdSyPYOp4fkK87VK39ER23cjWevjjFX16ex/VXN5EqssSP3D/AkxfGONmf2pfz7QRJkkguO48etJKQ4GhwZMTHVgPiYsXmW9eWeH2xBsDJviQ/dF//toJZO0spdxtEN3rdYRgShiHZuEoYhiQNtbn1825t2jsh8IsR3s7HD0KKdYfyPozNhmHIi9Mlnnpmku9ez6+7ntAVPvDwMB9909iBmsASnhyCg8KR+UTeakCs2h5V26MrrgESteU/byeYrSyl3G0J4m6D6Eave7Fi89J0GT8IqVgukgQpQ9u2TXur19cJgV+M8HYuYRiNzRbrez826wch37q2xFPPTHJlrrLuem9K56NvGuMDDw8fGMEqPDkEB5GD8dvVBrYaEBvNWPPlNzIfjeC1k2DWKoD3p40tC5K7DaIbve6VouTZWyZIcN/gG86m/WHIrVyN6UKd430pTMfbsuNpOwP/VsTbRt8jpnI6j7LlUqzt/dis5fr8+ctzfObSFLMla931E31Jnrgwxo/cP3Ag+iKEJ4fgoHNkxMdWA2J/2uAd9/Qx0ROZBE30JO4qmLXKPABb7om42yC60eteKUqShooksUqgLFZsbufrzFds5it2U4Rt5fUNZGJtC/xbyb50Qo+JYHP2a/Fbse7whedm+MLz05Rb9JQ8Mt7Fk4+N8ebjPQcigAtPDsFh4ciIj60GcUmSGMzGGcy2Z811q8zDXvRErM0GnOhLrvpwXSlKkssNaTXHbwqUG0s1kobK4yd6uJmrcaw30dLLJFe1qdou08UQVZbbnqreynvVCT0mgtZYbrT4zdrjsdnpgsnTlyb5yg/m1wkeWYIfvq+fJx8b577B9J6eaycITw7BYeTIiI/9YqPMw273RNwpG3AnMZYyVFRZxnIDRrsSHOtdLV4aj+/5AWEIvUmdY73JtvdWbKVc1gk9JoLVOF5Aoe5Q2+PFb6/MlHnq4iTfurrE2m6SmCrzE2eH+dijYwxlOz8zJjw5BIcZ8SndZlr1H6wN8lspAW3W67CVPoi7zQbc6YyNxx/tTizvhTF2pdSxlfdqP5tLO8HTpJPYj7HZIAz5zus5nr44yUvT5XXXuxMaP3V+lA+dGyET1/bsXDtBeHIIjgpCfLSZrfQfbKUEtNnjbOU5tpMN2EnD5lYevx2BeSvv1Va+Z7dEgug3idiPsVnHC/iLV+b5zKWppi/PSsa743z8wjg/9sBgR0+BCE8OwVFEiI8dsFkgu5uMw8rHzVVtvCAqeax9nDs9x0oPD3ijaXYjdhJAt5Jt2I3AvFMRsVsi4aj3m+zHttmK5fLFF2b4k2enKdTdddfPjmZ44sI4bz3Vi9zBWShjeXt02hCeHIKjhxAfO2CzQHY3/QcrH7fhvdHqce70HCs9PBRZQpKkTQP0TgLoVrINuxGYdyoidkskHNV+k8a22WLdWbdafreYK1l89tIU//vlWSx3dROpBLzj3j6evDDOAyOZPTnPTmj8G0nFVAxVZDkER5ej8UnZZjYLZBtlBLbbpzFdCOlN6fSmjHWZhb6UzkhXZALWnzboS+kbPs5WAu1uBdB2PO7a961iuTsSEbv1Gg+zmdlG/2YrVmQQtlfbZl+br/DUM5N8/bVF1uocXZV574ODfPzRMca6E3tynu0iSRLxZedR4ckhEEQI8bGGrYiEzQLZRhmB7fZpqIrMsd5ky7v6parDTNHCD0JmihZ9a5o97xRo177GvpS+KwG0HYF5sWLzwmSRQs3F8X2O9yVR5NYZod0+SysOs5nZ2n+z9w6kUBRpT7w6wjDk4q0CTz0zybO3i+uuZ2IqH35klJ88P0J3Ql//AB2ArsqkDeHJIRC0QogPVgdjy/WZKZr4ARuKhJ0Esq1kI7b6uHd6rFaPs/Y1ThdMgnD1a2x3AG1HYK7aHoWaS8V2WVxeb/6mY93EluvlWxURh1kk7BaNf2d9KYPX5itoisR4z+5mF1w/4GtXFnj64hTXl2rrrg9nY3z80TF+/KGhjvS8UOQ3PDlEWUUg2BghPlh9h7dYsdAUmQdGshuKhJ0Esq2k/bf6uHd6rFaPs1C2mq9xqWqjyTJnRjId3ySZMlQc32exYtOXNtAUmZimHKgNowcVQ5WpWC7zZQtZlkjou/dxUbM9/uzFWT737BRLVWfd9fuH0jz52DjvuKev47IIoqwiEGwfIT5YnUko1V3cIOjo3oC7zbwU6w6O77f1Ne7WKGt/2uBNx7qRJAlVluhN6UemqXO/aHh1WK7Psd4kdccjoav0JNvvkbFYsfncs1N86cVZas56F9S3nOzhycfGeXg023FBXZRVBIKdIz7FWZ1J6E5qjHTFqNkexbrLzaUqYRgykInd1YdfO9P+d5t56U3pjHTFm6WLvpTOfMlseiVM9CS2/Xp3a5RVkiTODGfoSxkblpGEuVd7WOvVIUmR2Oul/T0VN5ZqPH1xkq9eXsBb00WqKRI/emaQj18Y43hvsu3PfTeIsopA0B6E+GB9JiEMQ67MVXh9sbHZ1uSH7uvfcjDtxMDYKlvSONNC2eKbV5eaNfZT/UneeW//trbv7qbfxZ3KSEfZ3KsdBEEYbZvd5RX3YRjy3GSRp5+Z5Ps3C+uuJw2FD50b4SPnR+lNdc7UkNggKxC0HyE+WB/cri9WqdoeXXENkKjZrdfJb0Qnul5uli2p2h4126MrrgMh1eXXC1vfvrvXfhdH3dyrHTS8Okr13V1x7wchX39tkaeemeTqQnXd9YG0wUcfHeP9Z4d2ta9ku4iyikCwe3TOb/ous51sRGOZ03y5kflovU5+I+42MLYrc7LR46z9elJXSBoq85XlzEcque3tu3vtd3FUzb3aRTRF5OyqV4fp+Hz55Vk+e2maubK17vo9/SmefGyMH76vH7VD9pgIE7CDTydmngXrOTKf2BtlI4Ig4MpcpWnYdf9Qmv60wTvu6WNieazwTvbka7nbwNiuzMlGj7P669H44kRPnExMpSuhrdpOu9XXsdejrIfZ3Gs3qTse+Zqzq14d+ZrD55+b5osvzFBpsWDuwrFunnxsnDdNdHVEUBBllcNFJ2aeBes5MuKjYrnkqw6ZuEq+6lKxXAYyMa7MVfjyS3O4ftDcIvnASJbBbJzBbHzLj79SbSd1hbOjGWqOv6PA2K6SwkaPs/Lrr8yUmCtZ9KdjKLLM8b5U8xe1kwO88O3YHpbrU6g7mC0mStrF7Vydz1ya4v+8Mofrr+4dUWSJd5/u58kL45wa6IwxaV2VSce05s2C4HAgSrIHgyMjPmwvYLJQx12KRMZDY9H+h8WKjesHnB7K8OpcuWlktV1aqe2VXhTbLfu0o6Sw0eOs/LoXhOiK0vIXtVMDfCemVTvxTBBtfi3UHWr27qy4D8OQl6fLPHVxkm+/nlt3Pa4pfODhYT76ptGOuPsUZZXDjyjJHgyOzN+KocqMdcfJJjRKdRdjecV2/7Jx1atzZTRF3vHd/Z3U9nZSge3IOGy22Xbl44/3xJkumHvyi9quAN14L70goGZ7TPQkmqWi/Qr4nZbqbXh1VFuUPdqBH4T8zetLPP3MJK/MVtZd703q/NT5UT50boRUbH8/ZhpllXRMJa6Jssphp5MztoI3ODLiIx3T6E0Z+EFIb8ogHYsMk+4fSgOs6vnYCXdS29tJBbYj47DZZtuVjx+G4ToPjd2iXQG68V7GNYUXp0pULY+S6e1rwO+UVO9ar452Y7s+X3llns9emmKqYK67fqw3wRMXxnnP/QPo6v42kYqyytGkUzO2gtUcGfGxkRqW5chKfbcev8Fm4uROGYEwDFkoW9syAdtKMGy1YG43SwftCtCN9/JmLprOOd6XwnL9fa3t7lWqd7MJppK5e14dpbrLn74wzReem6FouuuunxvL8uRj47z5RA/yPmYWRFlFIDgYtP0T0vd9/tW/+lf8t//235ibm2NkZIS/+3f/Lr/+679+qNOdd1Lbm4mTO2UEFis237q2tML0LNnS9Gzt8jhZ2nz769rnHemKNbfl7kbpoF0BuvFeZuMqSb2O6Xioiryvtd29SvWu/Ts7O5ohpqu75tUxUzT5zKUp/vzlOew1EzKyBO+8t58nHxvj/qFM2597q4iyikBw8Gj7p/Xv/u7v8vu///v88R//MQ8++CAXL17k53/+58lms/zSL/1Su59uy2wU4BsBu2K52F6AsZyqbfdd/51MvhoZgelCnVu52qog1jD9upPp2doR2tHu+KbbX9dmIhYr9q6WDtoVoBvvZX/a4FhvsiNqu3uV6l35d3Z9scq1xSrD25jK2ipX5so89cwU37y6yBr3cwxV5scfGuLjj44x0tX+594qxvK/bVFWEQgOHm0XH9/+9rf5yZ/8Sd7//vcDcPz4cf7n//yffP/732/3U22LjVL+jYCdq9pMFUzGuxP0pPQ97R9YmRGo2h41xyNfc5siaSumZ2EYcitXY7pY53hvEtP1N9z+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": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "import seaborn as sns\n", - "\n", - "# draw the graph. This might take ~30 seconds.\n", - "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": 62, - "metadata": { - "id": "5nVy61rEGaM4" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 62, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAh8AAAGeCAYAAAA0WWMxAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjAsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvlHJYcgAAAAlwSFlzAAAPYQAAD2EBqD+naQAArzVJREFUeJzs/XmMnOl1349+3r32qt437rORM+SMJMvWYkmWYtkaymtyE8O5RqDYQBLAAWxHQGwrsA07sK04fxhGcgM7zgWcBNkQ3Fz7l5ufZrxKtmRLsuSRNMMZkjMckk2y96qu/d3f97l/vFU13c3qjey9ng9Ay+yu7nq6OF3n+5zzPecoQgiBRCKRSCQSyQGhHvYBJBKJRCKRDBZSfEgkEolEIjlQpPiQSCQSiURyoEjxIZFIJBKJ5ECR4kMikUgkEsmBIsWHRCKRSCSSA0WKD4lEIpFIJAeKFB8SiUQikUgOFCk+JBKJRCKRHCj6YR9gI3EcMz8/Tz6fR1GUwz6ORCKRSCSSHSCEoNlsMj09japuk9sQu+TP//zPxfd///eLqakpAYjf//3f733O933xsz/7s+Ly5csik8mIqakp8Q/+wT8Qc3NzO/7+9+/fF4D8I//IP/KP/CP/yD/H8M/9+/e3jfW7zny0221eeOEFfuInfoK/83f+zrrP2bbNK6+8wi/+4i/ywgsvUK1W+emf/ml+8Ad/kK9//es7+v75fB6A+/fvUygUdns8iUQikUgkh0Cj0eD06dO9OL4VyuMsllMUhd///d/nh3/4hzd9zNe+9jW+4zu+g9nZWc6cObPt92w0GhSLRer1uhQfEolEIpEcE3YTv/fd81Gv11EUhVKp1PfznufheV7v741GY7+PJJFIJBKJ5BDZ124X13X5uZ/7Of7+3//7m6qgz372sxSLxd6f06dP7+eRJBKJRCKRHDL7Jj6CIOBHfuRHEELw27/925s+7jOf+Qz1er335/79+/t1JIlEIpFIJEeAfSm7dIXH7Owsf/Znf7Zl7ceyLCzL2o9jSCQSiUQiOYLsufjoCo+33nqLz3/+84yMjOz1U0gkEolEIjnG7Fp8tFotbt261fv7nTt3+OY3v8nw8DBTU1P83b/7d3nllVf4P//n/xBFEYuLiwAMDw9jmubenVwikUgkEsmxZNettl/4whf42Mc+9tDHP/WpT/HLv/zLnD9/vu/Xff7zn+ejH/3ott9fttpKJBKJRHL82NdW249+9KNspVceY2yIRCKRSCSSAUAulpNIJBKJRHKgSPEhkUgkEonkQJHiQyKRSCQSyYGy7+PVJZKtEEKw0vRoeSE5S2csb6EoymEfSyKRSCT7iBQfkkNlpenx6oM6USzQVIXnTxUZL6QO+1gSiUQi2Udk2UVyqLS8kCgWTJfSRLGg5YWHfSSJRCKR7DNSfEgOlZylo6kK8zUHTVXIWTIZJ5FIJCcd+U4vOVTG8hbPnyqu83xIJBKJ5GQjxYfkUFEUhfFCivHDPohEIpFIDgxZdpFIJBKJRHKgSPEhkUgkEonkQJHiQyKRSCQSyYEixYdEIpFIJJIDRYoPiUQikUgkB4oUHxKJRCKRSA4UKT4kEolEIpEcKHLOh2RfkAvjJBKJRLIZUnxI9gW5ME4ikUgkmyHLLpJ9QS6Mk0gkEslmSPEh2RfkwjiJRCKRbIaMCJJ9QS6Mk0gkEslmSPEh2RfkwjiJRCKRbIYsu0gkEolEIjlQpPiQSCQSiURyoEjxIZFIJBKJ5ECRno8TiBzwJZFIJJKjjBQfJxA54EsikUgkRxlZdjmByAFfEolEIjnKSPFxApEDviQSiURylJFR6QQiB3xJJBKJ5CgjxccJRA74kkgkEslRRpZdJBKJRCKRHCgy8yGR7AGyvVkikUh2jhQfEskeINubJRKJZOfIsovkSCOEYLnhcnulxXLDRQhx2Efqi2xvlkgkkp0jMx+SI81xySjI9maJRCLZOfIdUnKkWZtRmK85tLzwSHbxyPZmiUQi2TlSfEiONMcloyDbmyUSiWTnHM13comkg8woSCQSyclDig/JkUZmFCQSieTkIbtdJBKJRCKRHChSfEgkEolEIjlQBr7sIidTSiQSiURysAy8+DgucyQkEolEIjkpDHzZRU6mlEgkEonkYBl48XFc5khIJBKJRHJSGPhIK+dISCQSiURysAy8+JBzJCQSiUQiOVgGvuwikUgkEonkYJHiQyKRSCQSyYEixYdEIpFIJJIDRYoPiUQikUgkB4oUHxKJRCKRSA4UKT4kEolEIpEcKFJ8SCQSiUQiOVCk+JBIJBKJRHKgSPEhkUgkEonkQNm1+PiLv/gLfuAHfoDp6WkUReEP/uAP1n1eCMEv/dIvMTU1RTqd5uMf/zhvvfXWXp332CKEYLnhcnulxXLDRQhx2EeSSCQSieRQ2LX4aLfbvPDCC/y7f/fv+n7+X//rf82/+Tf/ht/5nd/hq1/9Ktlslk984hO4rvvYhz3OrDQ9Xn1Q562lFq8+qLPS9A77SBKJRCKRHAq73u1y9epVrl692vdzQgh+67d+i1/4hV/gh37ohwD4z//5PzMxMcEf/MEf8KM/+qOPd9pjTMsLiWLBdCnNfM2h5YVyn4xEIpFIBpI99XzcuXOHxcVFPv7xj/c+ViwWed/73seXv/zlvl/jeR6NRmPdn5OGEAI3iCi3PN6Yr6OpkLMGfqefRCKRSAaUPRUfi4uLAExMTKz7+MTERO9zG/nsZz9LsVjs/Tl9+vReHulIsNL0mKs6GKpKEMVMl9KM5a3DPtaJRnpsJBKJ5Ohy6N0un/nMZ6jX670/9+/fP+wj7TktLyQWcGm6wFg+RcrQUBTlsI91LHhUESE9NhKJRHJ02VPxMTk5CcDS0tK6jy8tLfU+txHLsigUCuv+nDRylo6mKszXHDRVkSWXXfCoImKtxyaKBS0v3OeTSiQSiWSn7Kn4OH/+PJOTk/zpn/5p72ONRoOvfvWrfOADH9jLpzpWjOUtnj9V5KmJHM+fKsqSyy54VBEhBZ9EIpEcXXb9jtxqtbh161bv73fu3OGb3/wmw8PDnDlzhp/5mZ/hV3/1V3nqqac4f/48v/iLv8j09DQ//MM/vJfnPlYoisJ4ISW7Wx6BRxURXcHX8kJyli4Fn0QikRwhdi0+vv71r/Oxj32s9/dPf/rTAHzqU5/iP/7H/8jP/uzP0m63+cf/+B9Tq9X40Ic+xMsvv0wqldq7U0sGhkcVEVLwSSQSydFFEUesDaDRaFAsFqnX6yfS/yGRSCQSyWEgRFK6doKI8fzeJwR2E79lIVwikUgkkhNMHAsabkDDCQnjGFM/9EZXKT4kEolEIjmJhFFM3QlouiHx0SpySPEhkUgkEslJwgsj6k5A24uO7IBFKT5I6mArTW+dqVEOAZNIJBLJccLxI2qOj+NHh32UbZHig3cGWUWxQFMVnj9VZLwgu3MGHSlKJRLJUadrIq07AX4YH/ZxdowUH8iNs5L+SFEqkUiOKnEsaLqJ6Ajj4yM6ukjxgZyGKemPFKUSieSoEUYxDTek4QRHzkS6G2SURU7DlPRHilKJRHJU8MOYmuMfaRPpbpDvpshpmJL+SFEqkUgOG8dPOlds/2Qtx5TiQyLZBClKJRLJYdE1kXrB0e9ceRSk+JBIJBKJ5AgQx4Kml/g5guj4mUh3gxQfEolEIpEcIlEsOpNIA6L4+Ps5doIUHxKJRCKRHAJ+mIw/b3nhiTCR7gYpPiQSiUQiOUDcoDv+/GSZSHeDFB+PwHGffHkUzn8UziCRSCQHSdsLqZ1gE+lukOLjETjuky+PwvmPwhkkEolkvxFC9IaCnXQT6W5QD/sAh40QguWGy+2VFssNd0d1t7WTL6M4mat/nDgK5z8KZ5BIJJL9IooF1bbPvVWbSsuTwmMDA5/5WGl6fOt+jWo7wI8i3nN2iEtThS1LAEdh8uXjlC2Owvl3cgZZmpFIJMeNIIo7nSuDZyLdDQMvPlpeSLUd0PQCVpoeiqIwmrO2LAEchcmXj1O2OArn38kZZGlGIpEcF6SJdHcMvPjIWTp+FLHS9BjNW+iqsu0CsaMw+fJxlp4dhfPv5AxysZtEIjnqtDuTSF1pIt0VAy8+xvIW7zk7hKIo6KrCSM48FgvEsqZGywt45Z5DztLJmtphH2nPOQrlIYlEItmIEMkk0rotTaSPysC/myuKwqWpAqM569gtEBMCEJ3/PYEchfKQRCKRdIliQdMNqDuDM4l0vxh48QFHowyxW9p+RD5l8MxkgfmaQ9s/eSm/4/jvIpFITh5dE2nLDYlP6m3vgJHi45giSxISiUSyv7hBRKMz/lyyt8iIdUyRJQmJRCLZH2w/MZE6JzCjfFSQ4uOYIksSEolEsncIkQw7rEkT6YEgxYdEIpFIBpY4FjTcgIYTEsaDIzqEEIc6tFGKD4lEIpEMHOGaSaSDZCK9U27zR68v8vpCg//rn34ITT0cASLFh0QikUgGBi/sTiKNBmb8ecsL+fyNZV66tsiNxWbv4198a4WPPnM4xXspPiQSiURy4nH8iJrjD4yJVAjBqw/qfO7aIn/x5gpe+HBJ6X+9MifFh2TvOazFbHIhnEQiOQp0TaR1J8DvE3xPIitNjz98fZGXX19kvub2fczFyTw/9v6z/OAL0wd8uncYePHRL1ACJyJ4HtZiNrkQTiKRHCZxLGi6iegYBBOpH8Z8+XaFl64t8vW7q/QbvlpI6Xz82QmuXp7k0lSBU0OZgz/oGgZefPQLlMCxCZ5bZRkOazGbXAgnkUgOgzCKabghDScYCBPp7ZUWL11b5I/fWKLhPjwITQG+/dwQL16e4oNPjGDq6sEfchMGXnz0C5TAsQmeW2UZDmsK6mFOX5UlH4lk8BgkE2nLDfmzm4l59OYa8+hapoopXrw8ySeenTiyF+eBFx+bBcrjMrp8qyzDYU1B3fi8ozmT5YZ7IIJAlnwkksHB8RPRYfsne/x5LATful/jpWuL/MVb5b7+FVNX+chTo1y9PMkLp0uoR/zSdXSj6gGxWYDu97GjeKveKstwWFNQNz7vcsPtCQJVgZmhNClD25fXUJZ8JJKTT9dE6gUnu3Nlpenx8uuLvHxtkYV6f/PoMxN5Xrw8yXdfHCeXOj4h/ficdJ/YLED3+9hh3aq3Ej3HYcfLWkFwfb7BctNjNGfty2soF+5JJCeTOBY0vcTPcZLHn/fMo68t8PXZ6qbm0e95doIXL0/yxFju4A+5B8h35g1sZ+AMo5i0qXO33KKYPpjsx1ai5zCyG7vNAK0VBH4UYWjqvmUmjoMYk0gkO6drIm26AVG/SHxCuL3S4nPXFvmTY2gefRSk+NjAdgbOlhfy6lw9+fuqzdmR7L5nP45aKWG3GaC1guD0cPIz7FdmQi7ck0hOBn6YjD9veeGJNZH2zKOvLXJzaXPz6NXLk3zvETaPPgpSfGxgOwPn2ZEMbT/k3EgWJ4gORAgcVClhpxmN3YqhtYJACMFozpKZCYlE0hc3iKjZJ9dEuhPzqKWrfOTpMV58buJYmEcfBSk+NrCdgfPsSJa6E+IGMbqqHoinYK9LCZuJjJ1mNB5HDMnMhEQi6UfbC6mdYBPpcsPlD19f4uXXNzePXpxMzKN/6+L4iferneyfbgdsDMSjOXPLQH8YnoK9DtibiYydZjSkr0IikewFQojeULCTaCL1w5i/ervcmTxapV/xqGsevXp5kgvH1Dz6KAy8+NgsEG8W6E/Czb3pBlRaHsWMQaXl03QDxgupHWc0TsJrIJFIDo8oFjScgMYJNZG+vdLipdcW+ZPr/c2jqgLvPTfMJy9P8oEnRjC0420efRQGXnwcNTPnQeCFMQ+qDnfKbQxN5UpnpLzMaEgkkv0kiGJq9sk0kbbckD+9scxL1xZ4c6nV9zHTpa55dHLg318HXnx0b/tzNZu2F1JpeUdmgNh+Yekqp4cyFNI6DSfE6rRsyYyGRCLZD9ygO/78ZJlIYyH45v0aL+/APPrJy5NcOVU8kebRR2HgxUf3tj9badNyQyotn7oTcmWmgKIoR2qa6V6RTxkM50yiWDCcM8mnjMM+kkQiOYG0O5NI3RNmIl1quPzRDsyjn7wyyUefOfnm0Udh4F+R7m2/5YWstoNe+eXeqk3dCbft/NjNwK2jMp5dllckEsl+IUQyibRunywT6U7Mo8W0wfd2Jo+eH80e+BmPEwMvPrpsNFvCzjbb7mbg1lFZenbSyitHRdRJJIPMSTWRvr2cTB790y3Mo99xfpgXL0/ygQuDaR59FKT46LAxGyCEoO40tu382I1hdRDNrQfBURF1EskgEkTJJNKme3JMpE034M9uLPO51xZ5a7m/eXSmlObq5Um+59kJmT1+BAZWfPS7LY8XUoyt+fh0KYWlq+RTxqb/ce1m4JZcerY/SFEnkRw8bhDR6Iw/PwnEQvDNezU+d22RL761QhA9LKRSHfPo1SuTPD9TlBnWx2Bgo99mt+XH2Vuy1j/RT9xIr8X+IEWdRHJw2H5IzT45JtLFhssfXlvk5dcXWWp4fR9zaSrP1ctTfOyZMbLy/WVPGNhXsXtbniqmuLHQ5PpCA6C3OXG7W/RGcXF+NLtOBW86vOwEeS2OClLUSST7ixCClpeIjpNgIvXDmL+8VeZz1xZ5Zba/ebSUNvj4s+NcvTwlzaP7wMCKj+5t+cZCk/tVG4EgiATTpdSObtHbZUhkKeDgOGkGWonkqBDHgoYb0HBCwvj4i463lpq8dG2RP72xTFOaRw+VgRUf3dvy9YUGAsGl6QILNRdLV3d0i95OXOxVKUB2ckgkkoMmXGMijY+5ibThBJ3Jo4vc2sY8+r3PTTCak5nTg2BgxUf3tgwQRIKFmoumKuRTxo5u0duJi70qBchODolEclB4YUTdDmj70bHuXImF4JXZKi9dW+RLt8qbmke/65kxXrwszaOHwcCKjy79RMJOsg3biYu9KgXI8o1EItlvbD+ZROr4x9tEuthwefnaIi9fW2S52d88+mzHPPpRaR49VAb+le8nEpYb7rbZhoPyGchODolEsh90TaR1J+i7k+S44IcxX3yrzMvXFnjlXm1T8+j3PDvB1SuTnBuR5tGjwJ5HsiiK+OVf/mX+y3/5LywuLjI9Pc0//If/kF/4hV84Nmmto5RtOKqdHNKLIpEcT+JY0HQT0XGcTaRvLTU7k0eX+84aURV43/kRrl6e5P0XhtGlebTHUXiv3nPx8Ru/8Rv89m//Nv/pP/0nnnvuOb7+9a/z4z/+4xSLRX7qp35qr59uz1gbTN0gQlXYVbZhv4LxUe3kkF4UieR4cRJMpA0n4E+uL/PytUVurfQ3j54aSvPic9I8uhFVUchYGhlTJ2Noh32cvRcff/VXf8UP/dAP8X3f930AnDt3jv/+3/87f/3Xf73XT7WnrA+mMDOUJmVoO842DFowPkrZIYlEsjle2F1nfzxNpLsxj169PMkVaR7toasqGUsja+qkDPVIvS57Lj4++MEP8ru/+7u8+eabPP3003zrW9/iS1/6Er/5m7/Z9/Ge5+F57xiDGo3GXh9pR2wMpilD48JY7pG//qQH48SLAm/M1wljwenhNEKII/Uft0QyyDh+Ijps/3iOP1+su7z8+tbm0eemC1y9PMlHnxkjY0o/HIChqWQtnYypkToCGY7N2PN/rZ//+Z+n0Whw8eJFNE0jiiJ+7dd+jR/7sR/r+/jPfvaz/Mqv/MpeH2PXPK6xc9CMoWN5i+lSmsW6i6lpzFUdRnPWic72SCRHHSEEbT+iZvvH0kS6E/PoUOadtfVnpXkUgJSRZDcylnZsBqPteYT8n//zf/Jf/+t/5b/9t//Gc889xze/+U1+5md+hunpaT71qU899PjPfOYzfPrTn+79vdFocPr06b0+1pYIIRBCUEwnL8eZ4cyWO1r63e6PqjF0v1AUhZShMZZPPVa2Z+PrO5ozKbd8aWSVSHZB10TacI/f+HMhBG8tt5LJo9uYRz95ZZL3nZfmUUVRSBtar6SiqcfvPXLPxcc//+f/nJ//+Z/nR3/0RwG4cuUKs7OzfPazn+0rPizLwrION1CvND1em2v0/BqKovQC3kn3cjyOUXYvsj0bX9/pUor5mnskX2/Z4SM5aoRRTMMNezupjhN1J+BPry/z0rUF3l5p933MqaHO5NFnJxgZcPOopiqkzURspA0N9RgKjrXsufiwbRtVXa9KNU0jPqItXUIIZitt5mo250ayOEG07ga/Uy/HcRUpj3Puvcj2bHx9V5rekfXOHNd/Y8nJww+TzpWWFx4rE2kUC165V+Wl1xb5y7c3MY8aKh99epxPXpnkuenCQAv8o2wYfVz2XHz8wA/8AL/2a7/GmTNneO655/jGN77Bb/7mb/ITP/ETe/1Ue8JK02O2YrPU8FhqeDwxll13g9/p7f64Gk4f59x70Qa88fUdy1vM19wj6Z05rv/GkpODG0TU7ONnIl2oO/zhtSVefn1r8+gnL0/yXQNuHj0uhtHHZc//hf/tv/23/OIv/iI/+ZM/yfLyMtPT0/yTf/JP+KVf+qW9fqo9oXtrf9/5Ee6WW+v8HrDz2/1uShBHKX2/2bkP6owbX9/RnMlozjqS3plBMxVLjg7dSaRecHzGn3tBxJc6a+u/ca/W9zFd8+jVy1OcGckc7AGPEJahkTWTGRymPhh+FkUcsZxdo9GgWCxSr9cpFAr7/nw7GaW+E3YTrPfqOfeCzc692RmPknA6aAb5Z5ccPEIIGm5Iwzk+JtKeefS1ZG39ZubR919IJo8Osnk03REbWVM7Ma/BbuL3wF/d9qpLZTcliL1K3+9FMNzs3JudcZB9D0d12qzkZBHFgoYT0DhGJtLEPLrES9cWNzWPnu6aR5+bZDhrHvAJD59uh0q2M2X0OHao7CUDIz42C9SHEVD2Kn2/n0JgszPut+9BZhckg8pxM5F2zaOfe22Rv9rCPPqxZ8a5enkwzaOqopAxNTJWMtL8uHeo7CUDIz6O0o19r7It+ykENjvjfvsejtK/k0RyELhBd/z58TCRztccXn59kT+8tsRKq7959PJ0gatXpvjo02OkzZNrmuyHrqqkzWQ1x0nrUNlLBkZ8NN2A1ZZPIa2z2gpousGhBbW9yrbspxDY7Iz7PUxNdpRIBoV2x0TqHgMTqRdEfPFWmc+9tsg379f6PmYoY/CJ5yZ58fIkZ4YHyzxqaCoZUyNr6Se6Q2UvGRjx4YUx96s2QTnG0FQun0rMMMc5zX8YU1X3u0y1naDa6t/rOP9bSgYDIQRNL6RuH30TqRCCN5dafO7aAn92Y5m297BIUhX4wIURXhxA86ipq72R5pYuBcduGRjxYekqp4bSFDMGdTvA6rQzHec0/0k0QG4nqLb69zrO/5aSk81xMpHW7YA/ubHES68tcrvc3zx6ZjjDi53Jo4NkHj2OO1SOKgMjPvIpg5GcRRQLRnIW+ZQBrE/zz1VtZittmm6AF8ZYuko+ZezJDXq7W3m/zwNH/ibfPfdevWbbCaqtyjKyZCM5agRRYiJtukfbRBrFgr+ZrfK5awv81a0KYR+BlDY0PnYxWVv/7NRgmEdPwg6Vo8rAiI+dGChbXkjbD7m90uZB1eH0UIbhnLknN+jtbuX9Pg8c+Zt899yVlrfmNTOYLqVJGdqei6atyjJyCJjkqOAGEY1O58pRZifm0SszBV68PDjmUdmhcjAMzLvzTgyUlZZHpe0jhKA+5zOaNVhtsSfm1O1u5f0+DxzKTX433onuuYsZgzvlNoW0TqXls1h3Gcun9lw0bVWWGbTNwpKjh+2H1OyjbSJ1g4gvvlXmpWubm0eHs2Zvbf0gmEc1VUkGflkaaUMbiKzOYTMw4mMz1oqSnKVTd0LuVNqs2j5iBUoZs2dO3Q0bA3jW1La8la+/tSdvEG0vpOUFzNUEuqoe2E1+N96J7rkrLR9DU2k4IWEsMDVtX0TTVmWZk+iBkRx9joOJVAjBzaUmL11b5M+uL9P2HxZHmqrw/gvDncmjIye+xCA7VA6XgRcfa+nenHUViAWnhtM0nLBnTt0NGwP4lZnClrfytbd2N4iYqzpEsUAIGMmanB3JHthNfjfeie65m27AlVNFLF3FC2Pmqo4sfzwCsmPn+BDHgoYbdAT30RQddTvgj68v8fK1rc2jVy9P8j0DYB6VHSpHBxkV1tC9OQMEkaDaTm4yXhgjhNhVENgYwNt+xIWx3KZBfO2t/fZKi1jAzFCG+ZrDSM7a930za9mNd6J37jXnE0Ic2eVwRx3ZsXP0CdeYSOMjaCKNYsHXZ1d56dri1ubRZ8a4euXkm0dlh8rRRIoPHg7SozmTmaE0y00PQ1OZrzmM7lIAPI75cS/Hr3/rfo1qO8CPIt5zdohLO3ijeVzvxH6XP05ydkB27BxdvDCibge0/ehIdq7M1RxevrbIH76+SLnl933MlZkiVztr69MntNQgO1SOB1J80P+2mTI0RnPWuiAwtoug9zgBfC/Hr1fbAU0vYKXpoSjKjkTUUfdOnOTsgOzYOXrYfjKJ1Onjkzhs3CDiL94q8/K1Bb55v973MSNZk+95doKrlyc5fULNo7JD5fgh39nof9vsFwR2E/QeJ4Dv5fh1P4pYaXqM5i10VTkRN+mTnB2QHTtHAyEErc74cz88Wn4OIQQ3FhPz6OdvbG4e/cCFET55ZZJvPzd8Im//skPleCPFB93bJrw+V6PqBCiK4PmZIldmCrT9qBcE7pTbxyrojeUt3nN2CEVR0FWFkZx5Im7SJzk7cNSzTiedOBY03UR0HDUTac32+ePry7z02gJ3K3bfx5wdznD1SmIeHcqcPPOo7FA5OZycd+3HYCyflFfeWmqy1PBpdsxkH35qjAtjud7j9iroHZRnQVEULk0VtjV/HjcPhcwOSPaao2oijWLB1+4m5tEvv93fPJoxNT76zBifvDzFpan8kf7dfRRkh8rJRIoPkiCdMjQyps50SQOSlOvGzMZeBb2D9Czs5CZ93DwUMjsg2Su8sLvO/miZSLvm0ZdfX6SyiXn0+VOJefQjT58886jsUDn5SPHRIWfpZC2dpWbSC/9ELvtQZmOvgt5R8yzs13mOW0ZFMjg4fiI6bP/ojD93g4i/eHOFl64t8q0Hm5tHP/FcMnn01NDJMY+u7VDJGNpAbccdVKT4IAmSQgjODKcppHSK6USI3C23mK20OTOcYbyQOpD9JIfBfp3nuGVUJCeflhdSs/0jYyJdax79sxvL2ANkHpUdKoPNwIiPrbbGzlba3Fu1yVo6mqIQxPDFW2UW6y5ZU+fCWI6PPD3GWN56rJv82g2w06XUug2w+81WWYj98lActQyPZDCJ42T8ecM5OuPPa7bPH7+xxEvXFjc3j45k+OTlST5+gsyjskNF0mVgxMdWW2PnqjZLTY/3nR9mqe7x+nydxYZLGEMhpXZ2rIS9xz/qTf5RMgF7VbrY6rn3y0Nx1DI8Jx1Z5lpPFIuOiTQg6mPUPIzzdM2jf/V2pe+ZMqbG37o4ztXLk1ycPBnmUV1VyVqyQ0WynoGJBlttjT03mmOp6XG30kZTFLKmzmQhxfXFJqaa3EBylv7YN/lH+fq9Kl0cRhZCdqUcLLLMleCHSedKywuPhIn0QdVOJo++sbSpefSFNebRkxCgDU0layUZDtmhIunHwIiPzW7hCoKbC3VqLRcRx5wbzWCmNfJpHUtXeWIsxwunS73A+Tg3+UfJBOyVaHicLMSj3qhlV8rBMuhlLjfodq4cvonUWWMefXUz82jO5MXnJnnxuUlmhtIHfMK9xzI0smbSNWg+wjJOyWAxMOJjs1t4xtK5udRivubgL7cpN30uTRe4cqrImWcSN3nb70wJzZmPdZN/lEzATkTDTsTB42Qh5I36eDCoZa52ZxKpGxzu+HMhBNcXOpNHb25uHv3gEyNcvXz8zaPJiIIkwyE7VCS7ZTDendj8Fh7FAkNTmComt0VNS94gRnJJAO8XdB/1NvkomYCtRENXdKw1zOqq2lccPE4W4iBv1NK38OgMUplLiMREWrcP30RaXWMend3EPHpuJMPVK1N8z6VxSsfYPKoqCmlTS6aMmrrsUJE8MgMjPjZjNJe8EczV2jhBRBSnyVr6nng89oKtREM3I7HWMOsG8Z6f8yBv1DLL8ugMQpkrigVNN6DuHK6JNIoFf32nM3n0dn/zaLZjHn3xmJtHNTURHFlTJ2PKDhXJ3jDw4mMka/L0RI60odIOIp6dzHNpKt8TJUc5jd0VR2sNszOlzJ6f8yBv1EdB8EmOHkFn/HnrkMef31+1efn1Rf7o9SUq7S3Mo1em+MhTo8fWPKqram8lfdo8nj+D5GhztKLpIWAHMTNDWS6M5fi/X1vg1lKbWCgMZwxUVaWYTl6iM8OZI5fG7mYkHD/kwmiWM8NpcimDphsA7FnJYrsb9V6WSgbVtyDpjxtENDqdK4eF40f8ecc8+tpcf/PoaM7kE8fcPNrtUMmY2rEVTZLjw8C/s3eD3Vdvr3JruU0pYzBbtYljwbmxLFGcZD8URTly6caNGQkhBK/NNXZUslgrGLKdm83aDb7AjgXFXpZKBsm3INkc2w+p2YdnIhVC8MZCo7O2fgWnzzl0VeGDTybm0feePZ7mUdmhIjksBl58dIPdnZUmKUNFQdDwQm4uN8inDZ6dLm6a/j9sc6SiKL3g3PJCKi2PMIqZGcpsW7JYKxhaXoAQkE8ZDw1g24mg2MtSySD4FiT9EUJ0xp8fnol0tZ2YR1++tsjsan/z6PnRLFcvT/I9lyYoZowDPuHj0e1QyZg6WVN2qEgOj4EXH91g98EnR7m+2GSp4XJ2OMNUMUMYi176P2tqLDfcdUJjJzf+/RYo/UTETkoWawXDK/ccEPDMZOGhAWw7ERSyVCJ5HOJY0HADGk5IGB+86IhiwVfvVHjptUW+cmd1c/PopXE+eXmKpydyRy4LuhVKd4dKJ8NxHDM0kpOHjBIdLk0V+DvvOcXX7lQQisJoVufsSIbJvMli0+fLb5dZbQdMFVMYutYrDWwXoPe7e2PtGeZqgpGsyUjO2rRk0RVDlZZHywuYq4lOyeZh0bJTQbEfpZLDzipJ9p+wYyJtHpKJ9N5qMnn0j95YYnUT8+i7The5enmKDx8z86jsUJEcdQZGfGwXzFRV5TufHGU4a/KNezV0VcENIhabPl+9vcpKy6XuhLz43CSqKnrfZ7sA3U+gjO25QRPemK8TxoIzwxnOj2b7fr9kCFKDb9yroSmgayojWZN3ny4BD3s+dioo9qNUIltuTy5eGFG3A9p+dODjzx0/4gtvrvDytQVem2v0fUzPPHp5kpnS8TGPru1QSRmqFBySI83AiI/lhsuXbpV7wfRDT44yUVz/xpLUQzVGc1ZPLDyo2gRRzMXJAl++XeHWUpMXzgz1AvJ2AbqfQNlrg+Z0Kc1i3cXUNOaqDqM5q+/3W2l6vDJb5UHVYTRvkbeSYWobX4cuh+m9kC23Jw/bTyaROn0mf+4nuzGPfvLyFN92dujYlCZkh4rkuDIw4uPeqs3bK21KaYOlRpszw5l1QbdfOUJXVU4NZZiruizUXWZKaa6cKvL8qWIvW7FdgB7NmUyXUqw0PcbyFqM5k7sV+51SSdVmttJ+5CxIVzCN5VPbBuqWF2JqWs+vkja0I+vPkD6Sk0HXRFp3AvzwYP0cq22fP+qYR++dIPOo7FCRnAQG8B1963bRMIoRIhk+dnYky0jWYDhr9sTDxck8qrrzX/hyy2e+5hJGMW/MN2h7IVlLR1XoCYW2H7LaDh45C7LTQJ2zdIayyRuspau8+0zpsfwZu/Vl7ObxsuX2eHNYJtIoFnzldoWXO5NH+w1BzVoa331xgquXJ4+FeVR2qEhOIgMjPs4MZ7gwmqXtdQdyZdZ9vpvm77apjqwpXTw7XXzk5+1+37Sp8+pcnbYfMlNKMzOUJmVoVFoelbb/WOWFnQbqsbzFC6dLj+01WbtTZrZik7N0dK3/Tpm17KbctJ8tt9LMun8clol0Z+bREp+8MsmHnjz65lHZoSI56QyM+BgvpPjI02ObBuisqdF0A16Zdchaem/w1mbsNIB1sxJ3yy0Azo1kcYOYlKFxYSxHztKpOyFzNZt2Z1bHbgPiTgN193Fdw+udcvuRgm9vp0zNZqnh8b7zI7hB9JBw2vgaNd3gSCyok2bWvccLu+vsD85E6vgRX7i5zEvXFrk23988OpazePHyBJ94bpLpAzCPCiFYbQfYfkjG1BnOGjv+3ZIdKpJBYmDEx04CdPK7Lmh6AbOVdm+IV783gZ0GsG5WopjWya3aOEGErqq90kj387OVNi03pNLyqTvhvgbExw2+vZ0yI1mWGh53yy1mhh7eKbPxeaZLqSOxoO4gzKyDkl1x/Iia4x+YiVQIwevzjd7aejd4uKRjaArf+cQoV69M8p4zB2seXW0H3FxqEscCVVV4ZiLPSG7zLbayQ0UyqAyM+NiOpM3UYDRn8dU7q1ynScONekHrUW/xvWxD3uLsSPahzEv38y0v8X0cRFbgcYNvb6dMEPHEWFLCOjuSfSibtPF5LF09EgvqDsLMehKyK5sJqMMwke7EPHphLDGPfvzSBMX04ZhHbT8kjgXj+RTLTRfbDxlhvfiQHSoSiRQfPXrlkUobgHOjuXWlhMe9xW+XeTnI7o7Hfa6NHpPRnEm55T9Uxtn4PPmUcWDtu1v9jAdhZj0JrcIb/5u/PFMgbejUneBATKRhFPPVztr6r2xhHv34xQmuXpnkqfHDN49mTB1VVVhuuqiqQsZM/rsz9STbKTtUJJIEKT46rC2PZE0bxw/RNbU3Vv36QoPVls/FqTwLdXfdLT5ragghuL3SeuQU+0F2d+z2ufrdgNfulCm3POaqDrFg3S3/MDtW+gmkjePx9zMTcRJahbsCaqKQ4tZyk7eWWgeysfVexealawv80RtLVO2g72Pec6bEi5cn+fCTo1hHKHswnDV4ZiKP7YcMZ01OD2fIWjqG7FCRSNZx/N4RH4ONQbR7Y98YVNeWR4QQvPqgTqXl8aDqADCcM8mnjF4w3W3XRz8OcqHabp+rXwkB3lk8V255GKrKpenCulv+fvxMO/VSbHzu5YZ7oGWQk9AqbGoqLS9k6UENVVX2tURg+yFfuLnC515b5I2F/ubR8bzFi89N8onLE0xtMhjvMEk6VHTGCimyskNFItmSgRIf/Uon8zWXKBaoCswMpbF0FS+MsXQVIQSzlTZzNZuzwxkEgomixaWpwrrFctt1fewFh2lg7FdCgHcWz9VsHz+KDt1IutufYT+F3nHezuv4SeeKF0acGc6s69zYS4QQXJtLzKNfePPomUd3gtptibV0MoaGesTOJ5EcVQZKfGwMQCtNr/f36/MNlpsemgpvLrUYzhpkLZ04ElTsgKWGxxNjWS5NFR7qmtiu62Mv2E8DoxCC5YbbM/KdGc4wXkj1xM1mJYTux0ZyJtOlZG7JYRpJt+IklEH2EyEEbT9KhGTHRKooCiM58yHD5ONSaXn80RtLvHRtsZdN3MiFsSyfvDzJdx+ieXQzdFVNWmItjbQhW2IlkkdhoN6BNwagsbzFfM1lvubgRxGGpiIQzNUc/CBpIZwppfnAk2PMlpOR7GsD6067PvaC/by5LzdcPndtgTcXW1i6xnPTBb7rmbFedqfh+KQMlSCMMHSVhuOTTxlcmSmsW0Z3EG/C6/8NwQ2iHXltTkIZZD+IY0HTDWm4AUG0fybSrnn0c68t8tU7x8M8uhZDU8mYGllLlx0qEskeMFDio58JcTRn0fJCTg8nQf3GYoMgjKm6AbYXsdL0Waq7zAwlwmLtG2K/gPaob5jblVX2+ua+9vluLTV5c7GJHwrCOO4ZM4F1fpdiyqDuBpwaSjOSS372C2O5Xf8sj8Pa19wNor5G134c5zLIfhBGMQ037LWM7xezlTYvXVvkj7cwj777TIlPXk4mjx4l86ipq8nAL0vD0o/OuSSSk8DAiI/NAmL3BixEklUoWBqOH1Nuujw5miNn6kwWUz2fx1r2MqBtV1bZ65v72ue7XW4RxgJFhaYboqqJ2OlmW4oZgzvlNoYGQRRTzBhEsdg0+7KfJaK1r/ntlRaxgKliihsLTa53jIondaDXXuCHyfjzlhfu2yTS42weTRlaT3DIDhWJZP8YGPGxWUBc+3FVgelSihdOF3l7WWM4YzGcM9f5PLo8yu2+39d0z3Z9oUGl5XFpusBCzX0osO/1zX1tGadqe5wfgVgINE3lI0+N9s6mqQqVlo+hqQRRkn6u2wEjOWvT7Mt+mzvXbiBuuj73Km1mV9uc9XIEUczzp0rHbqDXfuMGETU7Gfu9H3TNo5+7tsCf31zB7TN8zNAUPvTkKC9ePjrmUUVRSBtab8roUTiTRDIIDIz46BcQx7rdLFWbc6M5FmsOy02PkZzJeCHFmeEMZ4YzfWd4PMrtfquW1dWW3zPfbRXY+/EoQmhtGWcka3JqKEMUi97m3m5W6PlTRZpuwJVTRUxNwY8Elq6uazXe6nvvh7lz7QbihhOy1HRQUFAQVDqt0/tZXjlOo9O7k0i9YH/Gn1daHn/4+hL/92sLLNTdvo95YizL1ctTfPzSOIUjYB6VHSonm+P0+znIDIz46BcQV5oe91ZtlpoeS02PvKUxlDVJ6Sq3lh10NXmTmq+5D/kKHuV2v1XL6sWpPMC6Vt6dsp0Q2mxI2FrvxHzNIYwF1xcatL2wZ5wdL6R2nUXYb3Pn2g3ESw2XUsYkbST/nmlD3/dOlqM+Oj2OBU0vpOHsj4k0jGK+fHuVl64t8Nd3VvuaR3OWzndfGueTlyd5aiK/52fYLVpn2qjsUDn5HPXfT0nCwIiPfgHxTrlN1tL5jnNDXJuvkzU1HD/i8zeXWW76rDQ95usOI5kUF6fzXJ9v9HwFWVNDVeD6fAM/ijg9nEYIseWb2lYtqwt1d9MSz3ZsJ4Q2+2Vc652IYkgbGq8+qNNyw8dabrff5s61r2PWSgJKHCtYusq7z5T2vZPlqI5Oj2LRWWe/PybSu5U2L722yJ9c728eVYDzo1k+8dwEP/SumUMfIy47VAaTo/r7KVnPwIiPfgExZ+noqspSw8MLBFZOZ7XtoSnwZGfdfRBH+FHEG3N13lxqUW56rDQ9PvTkCDNDaZabHoamMl9zGM1tPbJ7s4zA42YJsqZG0w14ZbYTjM31b7Tb/TJut9fmqLH2dez+rAfZ8nvUZobsp4m07SXm0ZeuLfDGQrPvY0ZzJldmirzrdImRnMUzE/lDEx6yQ0Vy1H4/Jf0Z6H+VbhC7vtBAQeHiVJ4bC00EsNTwKLc8nhrP8u4zJW4tt3qll2tzDQxNYaqYQlOhmDGotHyabtATH5vVHftlBPYiS6AogNL53w1s98u42V6bo/pLe9hts/1E5GHUmd0gmUTa9vbWRCqE4LW5Oi9dW9zWPPrJK1O863SRmh3u2yTU7ZAdKpK1yJk+x4OjGV0OiG4QAwiiOgt1l6GswVTJQlGSMkUhbTCas7D9iJtLLWw/YrnpcH81ERwPak6nEyRmKGP0jJgHWXdMbv0GT08ku1Xa/npz4Xa/jN3XYeNem8P4pX3UIH6Qwb+f+DnI3TFtL6S2DybScsvjj15PJo/O1fpPHn1yPMfVy5N898X15tH9mIS6GbJDRbIVh305keyMgRUf3WDVdAPcIKKQSkxoZ4YzNN2A+ZpLMWNQt5N09pnhDE+MZblbbjOWT3H5VIm7K8kY9tGcxfWFJvM1B1V9Z9vt2lJH0w0QQmw6wvxxfg43iFhputTtgKGs8VDGYqtfxn5B+zDNWY8q2g7bZHYQ7cUNd+9NpEEU85VtzKP5lM53Xxzn6iGaR2WHikRystgX8TE3N8fP/dzP8dJLL2HbNk8++SS/93u/x3vf+979eLpHohusutM7Tw9lGM6ZKErSTvqg6nQGa6lcOVXkwliKDz81xpnhDLMVG9ePyKUM8mkj8R5YOufH8j2vxMZShxfGfONemdvlxFfxxFiWDz819kgBcq1gcIOIuZqdZF/imJmh9CN0ytSotHzCWPDuMyUuTRV6n9ssk/Coc0622iEDjx7ED9tktl915igWNJyAxh6bSLvm0T9+Y4ma0988+m1nh7h6eZLvfHL0UDwcskNFIjm57Ln4qFarfOd3ficf+9jHeOmllxgbG+Ott95iaGhor5/qsegGq0JapzbnM5o1WG1B0w2wdJXTQxkKaZ2GE2Lpai97MJozyXbadE8PpxnJmtyvOg95JTaWOppuUpsvpU0gmQ76qAFy7S1/peliaCrPTheZrzmkdvAmvVY4VFoe5aZHy49Yabg0HJ92R0zN1xyiOAkCV2YKKIrS+3kSX0Bj13NOvnSrzNsriQC7MJrlI0+vF2CPGsQP22S213Xm/TCRtr2Qz3fMo9c3MY9OFlK8eHmC731ukslDyIDJDhWJZDDY83fo3/iN3+D06dP83u/9Xu9j58+f3+uneWy6wWp2xWah7rDUdMlbOlMli6cn8gznTKJYMJwzyafeqW2XWz7zNZcoFizUPcbyKd57bnidV2I0Z/adZJq1dJaancxHLrsuQO4mk7D2ll+3A4I43lXQXSteWl7Aqu2zUHNRFbizEpAxNTRVXSdq7q3a1J2wJzaKaf2R5py0vJBS2gAU2n0E2KMG8cM2me1VnXmvTaRCCF6dq/PyNubRjzw1xtXLk7zrTAn1gDMMskNFIhk89lx8/O///b/5xCc+wd/7e3+PP//zP2dmZoaf/Mmf5B/9o3/U9/Ge5+F5Xu/vjUb/XRB7TS9YuT5pU8ULBJW2z6sPajw9kd80kLW8kDCKSZs6d8stiul3fBLdwNPPfDiWt/jwU6OcHckA9DbkdkXHbKXNvVWbbKf9d6tMwtpb/lDWYGZod+vs14qXuZpgNGcxX3Oo2QF+LChlLTw/XidqgHViA9h1piFnJQPAlhrvZD5240/ZisMyme2V0XWnJlIhBKvtYF1nSb/nW2l6/PEbW5tHn57I8eJzk3z3pfF1AvsgkB0qEslgs+fi4/bt2/z2b/82n/70p/kX/+Jf8LWvfY2f+qmfwjRNPvWpTz30+M9+9rP8yq/8yl4f4yE2M1bODGXIpwwMLdnt0nRDbiw2uTRV4Pxo9qE39u7CtVfn6snfV23OjmTXCYXNBMpEMc3EhiVaXaHyYLXNnYrNpak8Kuq6tt2NjOZMpkvJXpq149BXmh53yu2H9sZsDIxrxYuuqpwbydDN7L+x0KDW9pguZdaJGiEEdafRExtnhjPryjA7ET1jeYsPPTnKmeH1Auw48zhGVyGSSaR1e+cm0tV2wM2lJnEsUFWFZybyjOSSLpMgivny7QovvbbI1+72N48WUjofvzTB1cuTPDH+8Ebi/WJth0rG0NCl4JBIBpo9Fx9xHPPe976XX//1Xwfg3e9+N9euXeN3fud3+oqPz3zmM3z605/u/b3RaHD69Om9PtamQeLMcIYnx3K8+qCOHURoCizWXIJI9A0kSTtqhrYfcm4ki9NnGNdOBEqXbhZiKGvx1btVvDBiLJfiuZk8S3WnrzlzbelnvuYymksC+GZ7Yzb+zBvFy3DGoOFGhFHMlZkiZ0cyvfHqXfElhOB5RaHpBnhhTMsLyaeMvgJtMxRF6SvAHoWdZhz2uwX3UYyuj2Mitf2QOBaM51MsN11sP6RRDnj52vbm0U9emeSDTxyceVR2qEgkks3Yc/ExNTXFs88+u+5jly5d4n/9r//V9/GWZWFZ+3/73SxIjBdSvO/CCF4UU254hDGMFyxWmj5vzNcptzxMLelWaXth5wankjU17laSLMPGiaI7EShdulmIWttjPJ+MV1c6fojrC82+3TFb7YiZLqWZq9rMVtrYfsRqy+fiVJ6FukvTTQLTbKXNbMUmZ+nM11xGsua6MtNozqTc8tdlUdbORLnzGDf9vRICO8047HcL7m6MrkEUd8afP7qJNGPqqKrCvdU2ry80+M9fmeXWcqvvY7vm0U88N8nEAZlHZYeKRCLZCXsuPr7zO7+TmzdvrvvYm2++ydmzZ/f6qXbFVkHC9iPSusbZ0SxvLDT46u0KXhhzu6LhBzFThRQLDZeImKxpMJIxUFUFVVHoF0O680JmKzZ3O/tjugJlYwAezZm96aK5tNHzfCiKsml3zFY7YrozRRbqDm0vZLWdzBcZyVt4YcydBzVuLCblk/edHwElGVJ2YSy3pWelG7Afp6V1s7beRwlQ252j+zpfX2isE2B73YK7E6OrG0Q0Op0rj4MQgvurbf74jSW+/HYFv0+p5jDMo7qqkrVkh4pEItk5ey4+/tk/+2d88IMf5Nd//df5kR/5Ef76r/+a3/3d3+V3f/d39/qpdsVmQaK72fZOxWa50/HS8AKCSGDoKvN1h+GMTtsLMHSVKBLMVR3OjGZ4z9nhvhNFu/Qbeb52HXzLC3sljm87O7SuY0YIwWzF7tsds9l4724ppe7AStOlmLGIhE/KTAysTTdIAn8kqLQDvnJ7lfeeG3rott50A1ZbPoW0zmoroOH4AL25IqrCI7W0Jq29Pk0vpNz0EEJsuw9nM7bLOGyc4wIwnDP3vAV3K6Or7YfU7GSI3eOw0vT4w9cXefn1ReZr/dfWPzWe45NXJvlbFw/GPGpoam+pn+xQkUgku2XPxce3f/u38/u///t85jOf4V/+y3/J+fPn+a3f+i1+7Md+bK+faldsFiS6A8IuTeXxwoh3nS5RbnrM1VwsPdlc2/ZiFEVhvu5gaD5DaQMhkgCsKsnN9vZKa10pYbOR590be9rUeXWuTttfv0G2ez4hRN/umM1+lpWm1/OBlJse7SCiRNLeO11KM15IJZ0SnbbaM0MZCimtr+nTC2PuV22CcoyhqUwPpbhbcTqZEHbdXdMlZ+mEnfON5S1MTXvkTMR2GYfu63xpOhmYNlG0uDRV6D1uv7wgj2Ii7UcQxXz57Qqfu7bI14+IedQyNHKmTtrUDn1jrUQiOd7syySm7//+7+f7v//79+Nb7zk5S0dTFJpOiB9GvDHXIJdSmSxYFFI6xlSBU8UUq22DtKEwVcqQT+k8MZZjNJ/CDaJ1w7i6ImKzm3lvg2w5qdOfG8niBvFDQXi35sy1ZYia7aOoYBkqT+Syve4SgJShoqoKQSSYLCZZF0VR1gXjlhswM5SilDGp2wFhFK8rcaQMjQtjuw92Y3mLd58pIYTA1LS+o+B3ynattd3XeaHmMpJLhMfaDMteloAg8ds03YCGExLGjy467pTbfO61Bf7k+jL1Tcyj7z2XTB7db/OooiikjCTDITtUJBLJXjIwu1363XS7H49FzP3VFvcqNg3X5/RQliszBTRNJWcm49bn6x6xolBzQoazFudGc4wXUtxeaRHFPOQ92OxmvnaDbG7VxgkidPXxN8iuFTvDWZMrp4qdWQpJSvz2SotKy2OykOLCaI67lTbnRjOM5kyWGy53yy1en2+gKhDFUEjrKCiMdMoi8zWXuZpNuzMV9VGyBYqicGmqwGjO2vdhYDvJjOxFCahrIm25IfEjmkhbXsjnbyzzuWuL3FzsP3l0qpjixecm+d7nJvbVPKoqCmlTS6aMdsytEolEstcMjPjY2PVwZaZApe3zjXs17lfaXJtvstz0iEXMg6qDqas8KRQiAWesDLqmMJXL0HADCunEKAqbew82u5k/ygbZnZQINgbbbsfK2uFlrc7NXFMVspbOmeEM5ZbPqw/q3Fio8/pCkyfHskSx4NRQmicncmRNDSEEbS+kavsIAZWWv65UtBsOahjYTjIjj1MC2omJdKuBYEIIXn1Q53PXFvmLN1fw+kweNXWVjzw1youXJ3nX6f0zj2pqIjiypk7GlB0qEolk/xkY8bGxO+Leqs3NxSYPqg52EOH4EQpgKEkQqtsBY4UUi3WHctMljAUPajZZy6DhhJRbfk9E7Gas90YhsZM5GTtpF90YbLsdK3NVm6Wmx/vOD1OzfbwwImPpPRNs93UZzaeI5xv4UYymqgxlTS6M5VhuuL0dLkt1h1U7oJQxCWLBuZH0I7et7vf8je141BJQ2wupOzszkfYbCBYLsa159JmJPC921tbnUvvzK6qram8lfdqUhlGJRHKwDIz4yJoaTTfglVmHrKUzlNExNY2xvMWdlYCpksVSTWAHMTrJTXB+1Wa8aDFTTLPYcDtGzTRRJGg4PkKIdUPAdhJAdzp3Ym1wLjddyi2XUsZMSgVbTD/t0hUV50ZzLDU97lba6KrKSDbFpel3TLBJ5gYajk/O1FEVhQujmZ5PZK1oe2OuzqtzNQxNw9QVLk3meXKi/5nXZl/6CYz9nr+xHTspAXV/noYbJMJUUwl3MRSsOxBsOGPyxVsr/M+v3efafH1z8+izHfPoI/hpdkK3QyVjarIlViKRHCoDIz6SLoSkhTRGkDFzDGWTlsSnJ3I8M5njb2ar3Fu1iYWglE5S5NPFFE0vZLHu8dZyi5VWsgsmk9Jw/Zg7lYeHgG183rUBudmZarndnIy1wXm+ZnN/NSkFGZrKlc700n4/Y/e53CBCU8HxQy6MZjk7kiFr6cxVnXUlorG8xXQpzULN4dJkActQeHb6HSGwtqwEMcPZZPHeg6pNuKGbY6OgmC6leh04ezkvZK+yJtuVZhbqDl+9vUrbi0Bh3SjznbDS9Hj59UW+eb+G3acd+x3z6BQffGJkX8yj3R0qskNFIpEcJQZGfNyvOqw0fUppg5Wmj+1HvHC6RMsLcfywV3c3NJWWG3BrpY0XxYzkTVbbPlEcUXd8IAZS3FpsYOr6Q0PAxoRgueH2MiIZU2O+5hILegF5JxMx1wbnhbrNcNbgyfE8DSfE6hNEhBBcX2jwymwVU9MoZXRODWceaondeNNPOho0xgvpdd0s3WC+tqyUtTTi2xWqtk8pYz4ktDYKipWmt6nA6OeV2amo2C5r8rjipLvO/tZyi6YbrhtlPsJ68bHR12FqCp9/c4WXtjGPXr08yfc+O7Hn2Z61O1Sypt5bCiiRSCRHiYERH0IIbC8iikTP3NcNyK89qHG73MYLImYrbYSAfNqg4QS8vdRE11WCUFBt+wQRjORAoKKqUOsM4OoOAVtpenzpVpm3V5KMSD6lM5KxeqUOS1cfMoYuN9wtl7/lUwY5K8nEDOfMvkOkVpoe37hX40HV6f1cT0680xK7VUDeamDX2uzAuZEMw1lz3UK7tWz8PmN5i/ma2/f79vPK7LQUs13W5FFLOo6frLO3/cREmjaSbo/lpovaGRu+kdV2wPXFBrdX2nzjXpU3FhoE0cN1la559JNXprgyU6Bmh9h+0nGz2WbanSJ3qEgkkuPGwIiPtKFSbXtU2x5DWYu0ofaC1P2qTauz90TXVHRVwQsjFBSCWGApClXXp5Q1KaQMohgsI1kJrygKpYzRW8R2p9ym5YWU0gag4AUBlbbLK7PJMLOcpfc1hm4MlOsyDh1DYNej0c/U2vJCdFVhtBPELX19++76gJy0BnezIt0R79uZZlVV5dnp/iUf6N9xs5mnol/JY6elmO2mm+62pNPqmEg3rrMfzho8M5Ff162yluWGy//8+gM+f3OZqv3wTA5IynEfenKUjz0zzunhNIqiUGn5m26m3Slyh4pEIjnODIz4mK+7NL2QlGnQ9ELm6y7ZlEkUCy5PF7mx0KDS8jp7WFRW2xGWnqSwz49kGc1bPDmRp9zySBkqacNAoKCpam/mBySBMWfpLDXaCCFI6WoS3NyAQkqn3PJ622mTEept5qptSlmLWtujmF6/yG3txNNu5gJ4qJSQs3RGciYCgR9qmJrK3XILIcRDy+jemK+zWHcZy6d6bcc7DV47MZWuzTJs/Bn6ZXnW/gz9RMVm+3A2E0s7WfYWx4KmG9JwN59EqigKIzlzXanFD2P+6u0KL19b4Gt3q/Szn3bNox+8MIIbxsSx4EEtMTqP5My+m2k3lnP6YWhqMn9D7lCRSCTHnIERH44fEccCXYeaHbJYd3jX6SFUBd5cauKHMaWMiaGrtNyQrCUQioKhqVwYz+GFMZWWz1g+xVjOJBYwM5R56GY9lrf4zidGyKd0Fusuiw2Hhh1S9wIWUFBQGM1ZTBTTrDQ9Zis2t8s2K3dWGc+nyVpJFmVjmWC7UkKSdSgl22y9iLuVNndXbZ4Yc/jwU2PrAnIYJ+2la9uO6064ozLFbkyl231t/5+h//6dfl+3WTZjNGf29tyM5a3eTBaAcM1m2d0MBXt7pcVL1xb5kzeWaLgPz/ZQgBdOl/jBF6Z6k0fvr9rMVuyHREZ3M+1W5ZwuskNFIpGcRAZGfIzkLCIhuFNuo+sqNTsJIDNDaV6fr1NImahpaPkRKTNmJJflzEiW4ZzFVDFFIW2uW/r22lxjU4+EqqooKDScgPmqQyjg3qrNVD7FYiNZZDdRTPe+36WpPLW2x0hGY7Xl8Ve3VpgZStpdu1mSbuZiqpTi+nyD6wsN4OEMiO0nM0uKaRNFoWeEPT+a7QX208Np5qoOc1W70xkT4gYxl6YLLNS23vy6G1Ppdl/bb6T8Zvt3ul83V7OZrbS3NJOWW35PEM3XXEZzFsWMQd0JaHvRjtfZt9yQP72xzEvXFnhzqf/a+q3Mo5uJjO3KOVZnMm3G1GWHikQiOZEMjPiYKqa4PF1kte2Ts3SKaYO2H5EyNKaKaXKWzmzFJhIxGSNNxlRpuiEzpTSFdNLZ0e1kma20iUXM0BqvR5duKeXmUoO6G+AEMbYfkdZ1Tg1lMI13gknO0tE1laYXEQm4u+pSd3yGcyajuTYXRrN85Omxdbtirs83eFB1Ej9KVO9lAdZuca20fWIBGUvrGWHXBvbuKPHZSpu2nwiP7ubXkZy1ZefJ2gyKqiTeg5WmS90Oth3UtZNyyHZf1/ZCWm7IajvYNNOyVqzcKbe4U27veIx7LATfvF/j5WuL/MVbZfw+k0ctXeXDOzCPbiYyNpZzujtUMqZO1pQ7VCQSyclnYMRHPmUwXrSoOQGRgKz1TqDseiXyKY2a47NUbxEJheGMzvsuDPfS9itNjy++VeZ2+Z3ZHudGc+tu3t1SylzVZbXtc2Y4g6YqqIrCeN7qpdBvr7TImhpXZgroKhALDE3hlftVhtIGpbSZBNoNu2KuLzRQUHhmKseNhWYvA9KdH3JpuoAQgrSZlFX6ba3tCpGWlwTxqWIKBWXd5tfNSh1rSyNuEDFXszE0lSCOmRlKM5a3NhUuu50G22Xt11VaHpWWv2WmJWtqeGHEqw9qCGCquL2fZanh8kevL/Hy64ss1DeZPDqZ55OXJ/nYxfHefztbmUf7eUbW/htkOjtUMrIlViKRDBgDIz6EEIgY0kYyvfTSVK4X/LpeifurDqstr2cSDCKdt5ea3BjLcWmqQMsLaXvhQ7M9NnZs5Cyd918Y4Su3y+iqynQpxfmxHFPFFF4YP7QF99JUgXLLZ7HmoAiFxYaDGwouTxce2hUjhKDc8vjiWyustpIOiyASTBUtmm7A4qxNLODCWLaXldnMTNqd+rpYT8yQFyfz2w4BW5tBub3SIo6ToWRr54Ns1sHzqHtd1n5dztKpO2Hf7EkUCxpOgBNETBXTFNMhGUMDIbi/aj+0XyUxj5b53GuL/M1sf/NoMW3w8UvjXL082XeT727Mo3KHyu457DH8EolkfxgY8XG/6lBu+0wWs9QcHyeIex0n0N3Z4REKQdsN8EJBxtS5X3X5m7urjHbKEVlLZ6nZyXx0Shpr6ZZSAJ4az+MGIV4guLPSYjhrYunqQ1twM4aaTF9t+6AIJgspdE2jkE7KH0KIdW+4QoAXxERxjKWrzFVtojgCkg6OB1WHlhtwb9Xhw0+NMlFMb/q6KAqgwMb38+7Y9Tfm64Sx4PRwuneObkCotDxaXsBcTazbzPs400u3o1/2pDsUrOWFvX/PbsahX2aiZvuJefR6f/OoqsC3nxvm6uVJPvDECMYWZZDtzKNrd6ikDFUGzl1y2GP4JRLJ/jAw4iPZzBoQRTFu+I7psPvmdqfS5m7ZZrHu0e4EpCCK8MOIuZrDbKXNt50d4sNPjXJ2JNl7cmb4nZX0TTfAC2NMLekAsXSVkZzJ7ZXEHDlXc1Hv1XjX6SItL+CVe04iZkyNe6s2K00fQ1dxQ8FoPk3VDlise7ymNni+c/OHzqyPlM6TE3k+f32JL9xcZqqUxvZDhrMWo3mL1+YbFNMmt8ttznRmS3TPZ+kq+ZTBWN7qzA0xeHrinV0vXcbyidH2zcUmsRC8PldnJGv2unRefVAnjGKEgJGsyZnhDEIIbq+0eqPdd+vt2AlrsyCOH7HU8HpDwbr/zmsnjrb9gDgW5CydP7uxzP/7i7e5W7H7fu/pUtc8OrlpSWjj9x/q4+uQHSp7x34KWYlEcngMjPjImBp+GLPS9CimDTKdwV3dN7dTpRS6qmBoCoWUTiBiCqlkJkjNCbi3anN2JMtEMb0uk9AtMVRaHg+qDqeHMgx35lDkUwY3F5usND1G8xa6qtD2QoQARJLBWIula6iKwmLNwTJ1zo3mcIPoobHkbS/k7ZUWipp8n4uTedwgJowFVdtDVRRMTaHhhdxYbHK/6hBEMXNVd935tptsavsRLT+ilDa5U7E5t6ZLJ4pFr9V4JJekwrs3VFVJuog2jnbfC4QQvaFg/cygazfJokCt7fMnN5Z5Y77Rdymcpat819NjXL08yfOnittmJvptqh3JmUwbadmhsg88qklZIpEcbQbmN7nlBjS9EBELml7Ym2iaNTVaXsBC3SEmCUalYorFmkvNDihkDC5O5PDDiL+6tdJbP15KG+RSRq/8UEjrBOWYQlonikWvvfU9Z4dQFAVdTcyHiqKQTxk8M/lOtuH0UJrRrMlqy+PiRI6LkzmaXkzb9VlseDh+wHzNYbJgkU8ZnB5K03JDnpnIc2OxSc0OmC6lmRlK03IDsqZOywvQAoW6k3yPM8Npgujh8+3MALo+aPcLCBtvqClD6+uReFTiWNBwAxpOSBj3HwoGiQdjtZUsAfzirTK1TSaPXprKc/XyJB99ZnxXAW2tx6Nm+1i6ypnhzEMdKtKrsDc8qklZIpEcbQZGfNxbdZit2Kgkq+HurTq8H4jjmLmqzd2VFqaqMJo1cIKIIIywFUFkw5feXsHQkoDbcpP9LnnLoJQxuDCapelFFFwdP4q5tdJkqpjcgvutbRdCUHfWzwgRQlDMGGha8vfnT5WoOiFfv7vKW8tNNFWhZge8cKrEUNakkE68J6au8uR4jjPDmZ65VAhBLmXw6oMaKT3kyYkcX7tbpdzyMDSVhhMylDVwg4g75TZZU+sIsIcnp54ZzvDEWDYRKpkMbhDx5zeXGc2ZXJ7OYwfxuoCw2Q11u0C81eeDzlCw1jZDwfww5i9vlfnf35rn1Qf1Tc2j3/vsBC9enuT8aHbX/w0pSjIgruEk3pLRvMVkMdW3NVZ6FfaGRzUpSySSo83AiA8niDotqDotP8Tp7PF4bb7BNx/Uk+FcXsBozkQJBYamMdYpJ9TbIflUkr1o2BF2EOIFEau2x3QpRSGlM5Y10BQFVVlfTum+eY6tCbBdT0jXe3Gn3F7nvbhfdbi36vDmQpOFusvl6SLLDR8vjLhdbjORN8mlDEZz1kMdLStNj/mamww5c0OW6x4XRrOcGU6TSxlYuoobRLwxX6ftR8RCkLcM8injoSA5Xkjx4afGaHlJd8lX3q4QxgJDU7l6ZXLdnpetbqjbBeJ+ny90Fvt1RdFmvL3c4nPXFvnTLcyj33F+mBcvT/KBC+vNo13/RtsP8EOBqStkTWNdR4yqKD3DaLJDJflZt7uJS6+CRCKRbM7AiI/JYorxgoWuqGRSGhMFi+WGy51yi2orMXsKoGoHxICiwv2aw1Da5OxoholCijfm66y0HaJYIQgFigJvLTU5N5rn7EiGtGUmUzirNvdW7aSNteERhIl3otb2MXWNkZzJ86dKvdZZN4gotzxqts9IzqRmB9wut7EMjYYb8tZSC1NXaHsRiqp0vCAxIzlr0wFbl6YLvZ+7O7ujG1C/dqfC7bJNKW1wp9xmppTqlYHWBsm1t877qzZhLHhmssDNxQYrTW/d8251Q90uEO92KFjTDfjT68u8dG2Rt5b7Tx6dKaUT8+hzE4zm+n+vrn+j3g5YaDhMFlKUsibPTRU4NZwhZ/XvUNnJTVx6FSQSiWRzBuYd8fmZIjeXmizXPcaLFjOlNK8+qNN2IrwwouEFKEDe0kjrOmpKAQHPzeSZLKQTE+FUgVLGpG4H1J1EsFw+VURFxfZDIgFztcRP0fZDvvkg4Fv3qqR0jXLLYyRn9YaAdUeEu0HEg1UbQ1XxwpCUoVFuurS9kMm8yYXRDNOFFKdHsuRTGi0vwgmida2ta+kGvYWamzzfVGGLdL+CpWtomrppkOyWRLwwwg9jbizUMXVtV7X37QJx1tRwg4i/fKuM23kNRjv+mG52ouUF3F5p85e3ynzxVrnv2vqUrvJdz4zx4uVJnp/Z3jza9W/kUhpxTTBRtEjpOsWM8djeAulVkEgkks0ZGPGhKAo50yDICHKmge2FVJoeupbU8aeKFmEElbZDuR0RRRFnRrOcHc5RSJt4YcgLZ4bILDSYr3s8MZ7FCWL8IMaPYgrppGwxkjUZzhjcLrep2z5NN2RqPMVKy8PUYbbcJmWoOGEyCKvS8jFUlUvTBa7PN5it2Ghq4p+IgEuTedp+xL1Vm6GMwbefG8INRUcUJC2+3fLNZlNEN3oqTg+luTCape2FPDdd4NnpPGlT7xsk15ZETo+kGc6YPDWR5+Jkfsev/WaBeO1QsLSp4UWJqFpuugxlTEZyJjcXW/x/XnnAK7NVas7W5tGPPTNOdhcZhlLGoNpO/lsYyVnEMaRNbU+yFNKrIJFIJJszMOKjO2SslDYpt31mV11uLjW5vdKkavuoSjLn4VQpy6khBZRkA2rN8XlqMs9iXVC3A/Jpk7QTcno4w1AmmengBYKLU3kW6km2wQ0i5qouC41k4uV83SVn6WQsA8+P0BWVlYbHhdEcmgrllssrs8n01OGc2fNSpA2NB6ttvnm/RiljcWulnWQRNJWFukOlGTBdSpE2Nd5zdohLU4W+QW/jxNHL03menS70tr5enMyjqv27Na4vNKi0PC5NF1AVlacmcrvuYtl4pmQomL9uKJilqwxlTMbzKeZqNn92Y4m/vlvllU0mj5bSBt/zCObRlNGZMGpp6KrCVDHddwbKXiI7XyQSiWQ9AyM+ukPGwjCi6gQULRVDVxjOmlTaPvcqNnYQUUobZCwNU9dQUWh6EV+9s0re0hjOWjx/oUQhZZAyVaaKKdwg4rW5Bss3bdKmTimtUXMCLB3ee6bETDHNZMHkzEiOIIxYbvhYhspX71T40zeWMHUFVVVImyppMwmICzU32ZcSCSptn6odMpZP0fYFD1ZtQhSiOOZOpU0kIjKm0evE6JZY1ga8SssjjGNmSpmeobXuhOu2vm4szSw3XL50q8xC3WG1HSAQjOZSj5UVcPyIuhP0HQpWs31ul1v8n9fmee1Bo2cIXosCvPtMiR961wzvvzC85eTRd763T6Xtk9JVnp7IJ3ts1gT+8UJq37tQZOeLRCKRrGdgxEfG1HD9iDdWarT9CF2BSAhuLjWYr/soIiZWoGH7PDNZQFMS/8ez0yVqdrLITFUVFuoumqawavvcr9o8WE12qQSdaZ/LLZe6HWJqKiutgMmCxbvODHNxMpnJ8cZ8gzsVm5WmQ7UdkEvpWLrGs9NFLE1FUxUsQ0UhmcdxZabEzaUWCzUnmeUxnGZ21SGMkjHwi3X49vNZdFVZZ+RcG/CaboCivDNxFNi2E+Peqs3bK22KqeQcaUPj+VPFXWcFthsKNlux+f9+Y46/uVtlodF/odt43uK7nh7j45cmeHI8u23WQO0sbWt7IXcrbe6Uk4mm5Zbf2xJ8kAxa54vM9Egkku0YGPHR9qLOLThps52v2YxkU2iKgq4I3BCqbZ9CKpmBEQsFiHhzucWF0SwvnC6hKEmAv7Xc5O3lFi034K2lFufHcmRNnTuVpEOlbvs8PVGg3HKJhGCuZrPa9rlbbrPU8FhquChK0lmTNlQW2j5/cWORy6eGURRQVYVYJN6UKI45PZQhY6qcHs7y7FQB2495c6nJZCmNoSbG2JGcuS4rsTbgzVVFsuuks5+m36yRzVAUlYypkTbemQUymjMpt/wtg0scJ3tm6k7w0FCwWAhema3y0rVFvrSJeVRVEtHx3HSBH3h+iiunSpsGsOTnCYgFjOUsTg+nUVWV2ystbD+imDJoeSGz5TazI5kdnX8vGbTOF5npkUgk23Gy3wXXUHN8lpsufhSjqVCzI8YKCpPFNJBsYo2EIG2oLDZcdFXlfU+MoCGSOR55C1VVGQcqLQ8niKm6AV4Uc3ulRdbSQEkyLE1Xoe0FlLIWV2ZKLNZdFmp13FCAmrwhu36M3dlNIhSFCJVK26PphhTTJuWWx3vOlJgspni3MsTFqTw3F1pU2gEzpTSqAmdHcyzUkm2txbRBHMcs1ZOpqW4QoXayHbqm9uaBdG+kU0WLthf2Oko2Lq87M5zpmVLHchaNjtDS1GR3zXzN7Rtcws5QsGafoWCLDZeXry3y8rVFlje06nYZy5lkzaSbZtX2CaOYhbrHzFDQW1XfLdV4YcRoziJtqKy2A6JYULMD0qbGeCEpEeUsndsrNZabPuN5i3urNllL3/T8+8Ggdb4MWqZHIpHsnoERH6zZyBrFUMzofOTpMVYaHq/N1RjOWoRRCKj4UYwbxrz2oMaF8RxNL6Tc8nsB6sxwhomCieMHPH+qQLnpM5QxcPwYRMzFqQJPj+do+0lbbBgLcpZJLg1vzLt4QUjT8UkbKpoqyKYM3nduiFUnGaNuBxEtN6Tc8nhupkgYw82FFverNoIkYOZSBq4f4QYx1baDFwjultuoqkLOMtDUh/errL2RtrwAIZJb+WzF5uxIZt3AsvFCio88PdbzjFTaPlPFFDcWmpRbLipqz2Tb8kKKYeLnaHsRcRz3lq/pqsobC3Vefn1pU/NoMW3wbWdKTBYsFuout1ZazNeS9uMnJ/JkTK23ql5XVRw/ZKXpJSIucCh2RsZvDHZjeYsPPTmKrircr9pcni7idvb7HGRwHLTOl0HL9Egkkt0zMO8KsegEbUsjFvChJ0f4vitTlFs+z58uIYRgse7w/3t1gWYjROusmb84nieMBNcXGkAS0MYLKb7rmXEK6RpV26OUtnh6Is837tfIpXSmi2ne1SnT3Fu1URWoqQFV28P1Q2IBQlEopA0MLVnD3vIjJgspHD+m0vK4OJlnOGNh6SrPnypyfaFBLGImiilmV1pMldKUMgZ3yi3maw6LDYe6EzCSNfnI0+O4YUzK0Dg/mmWl6XGn3E6Mp1HMzFCGV+45IGAsn+LVuTptL1met3ZUezdg5iyduhNyY6HJ/apNIWPQsBN/RjaVCIO5qtN7rVfbAZ+/uczfzFZ59UG9r3lUVeB950c6k0eHWai7fP3uKmEskmm0gKkppHUVy9CYKqaYLiVi6vZKNwOTiAfoP9pdURQmimk+8MQo2Qd1vFCgqwqaqrDSdKnbAUNZ48gHx+PmoRi0TI9EItk9R/tddw+pO0HiP4hidE1N5nJoWi+bcW/VpmoHCEBRBKt2iO2HvDZfZzibpPuDSPRS9N05F28tNam0fGbLTRpOwKmhNFEsaPsR+ZTR6yppB2GSFUCQNpOOGBXB2aEspazFeN7i/RdGuDTl8417NUxNo5TR8cIYxQsZy1ss1V2+cGMFL4yIEAjSyQj1lseDVZsgFoznLSLg+ZkSOUvvm+2YrzlkTY2mG/L1uxXafkAhneUb92q8PldjLJ9kPZ6dLna6aEymSynKLZdCxuDbz5Z49X4dXYPxnIXjRVRaPlEs+Ma9Kv/Xtxa4t9p/bf2poc7k0WcnGM6arLYDFuouXhiTNnSCCExdZTRv8eRojnedKfHkeH5dwN14sz4znOn5cfoFu7XBMGmDtpNuojhmZih95IPjcfNQDFqmRyKR7J6BER/lpofjx2iqguPHlDueg5WmxxffKnO73Ga+6tB0AgxVJ458IlXl7kqLsdwIF6fy3Fho8sZ8nXLLo+n4vD7fRFEEi02X5ZpDuRXyda/KzFCKmaE0D6oOlZbHRMFipenj+SFNN8ILYsIoRlEUnCjmVEojjEFV1d6sjuWGS9ML+Zu7q5i6xnDOAAX8KPE5zFZsKi2fuu1TtX1MXSWnKUzkU+RNnTPDmd7emJ7xtCYYySbGU8cPeWO+QdsL0TyVm4tNHlQdMqbGfN0DRWEsn7Shlls+8zUXhMJK3eXLb6+STxucHs4SC8FL1xb5+t1Vri82iTZZW/+xZ8a5enmSyzOFnoiotPw16+mTabKXpnK4YcxI1uTsSJbxQuqhW35XDHXnlKz14/RjbTC8vdIiEsnY+buVNu1tdsccBaSHQiKRnDQGRnyINf8XRO//a3khLTdAQ0FFEMURi3WXphsyk84lWQsv5PM3lnl7pcVkIYVlqISxYLbiMF1I8dZKExVBytJIGQphJLhTbmJpBg+qNnfLbequzzMTecptj3LL48xwhjgWGGpiem25AbOVNnEcc32hwULd4fZKm4ypcW40GeqVMlRKGZN8yqDc8kjlVN53YYRV22Op7uHHUHMCrpwqcnYkaUldmyXQVbUX0G+vtCikTZ6ZLHBjoUnd8SikdFRFJWVCFMW9IFd3gl6JotgyGc6a5FI6//tbc3zutUUqbb/vaz6et3jPmRL/z28/w6mRTM8oavshGVNPPCEKlPIpFuqJcfa954a37GpZaXrMVtrMVmxyHeNovzklG7+m5YVkTQ3HD7mz0mKx4ZExk4Fj3dfkqCI9FBKJ5KQxMO9io1kTVQEviDB1FVNTuL3SwvFDanbAX9+t0PZD4ijGCWLCGB5U24xmTEQsuLXcpOEkI9kVVeGJ0SxxJGh6QWfGh6BScyllLDRNwfUFz5/P8aBqs9KwUVSVt5YaDGVMsqaOqiikTI17lRafv7GIisLdSpuLkzmuL7Zw/YiFustT4znKTY+0ofHEWJbVts9q22eqaCUekSDiVCnDeC7Jtqy2k2mtd8sthBCb1t97O2DqLsM5k+dm8ui6yltLLQxNZbKYxtSSUed1J8AJI2pVn7vVNi+/schrc/W+r3PG1Hj36RKFlM77L4wkJt+OllhtB7y13ERXVTJmyPnRTFJSmKsBSelrKyHQLT/M1WyWGh7vOz+C44e9PTn9/BAb552AIBKCIIy4eGYIS1ePfCZBeigkEslJY2DER8tPTIyaphJGglvLbc4ttWi6AX4ckbU0VAWcIETXVDKKStsLaQUhFdsjpetoWYWv3K5ALKi2fSYLKeJYRwMqTjKiu5jRmClmSJsaNxdbrLY9hKICggdVl/G8RcrQaLshD6o283WHuh2QMXVmVx3ulluAgqKq1NoetpfmqfEc7zpdRAiB40dYusp43mKikOL1+QaGphHFCqqqEsRwu2yz1PQ5P+Lw3ExhXcdLNzBvDGijOZPRnMW9aRs3iCimDbwwWbq30nT54psrfPFWGdvvbx59/lSRD1wYZbJo0XRCFhsuLTeimDUopAwKaQMviBjJWp0SkE3bCzuGW7XXibKVEOiWH86NZFlqeNwtt8haOm0/pNKZ27Gxa2dtyeKVWQcUuDJTwvZjarbPzFDmyGcSpIdCIpGcNI72u+4eEkYxuqJiGipNL8APo15ASuvJnIzZio0QgjhOJoCWsiZjeQs/FKhGzFzNwQ9jRrIGyw2HlK4yXkjKIF4skj0vfkzKUDg7kuHGQgM3iPHDKNnFYvu4YUwQge35eCH4UUzDDQljQcpQWW37jORSmCoMZU1KGZ3zYznaXsjf3KtSd0LGCxagUrUDml4iFCrtNitNhzAUpHQFTVFYqjv4UcxozuoZFcfyFssNt2cI7XpDANKWzmQxTdCZ1fH735jjpWuLvL3S7vuarjWPjuSsXlml7Qc8GxcopnXGCylODSWG0DgWzNXcxLfgBizUHNp+yGo7YKnh9YagbaRbOqm0PFpeQCySLNCZ4QwAlbZP2tCTrh0/pO6EPVPm2pJF1tJRFHCCiCfGspwqpQli0fPx9NtxI5FIJJK9Z2DEh1AU2kFIzUnMjWpnjXzW0qk7PnNVlyiOKaZ0cpZG3fZQVQXXC1EUNemoQNByQ1RFIYxDIgH3Vl2cIMT1IyxDI5PSGS+kWWq4PKi5LDUdoliQs3SWmy5LNRcviomiCE3TMXUFEAgBKUNnOGMyVUwRA5dmimRNnbsd0+hiw2UobbLS9LB0BUXRqNk+rh/R8gIsTWGh6RJGSUfNRN4CNREJThD1JpR2DbYA50czvHBqCMtQ8cOYV+5Veem1Rf7y7U3W1hsqH336YfMogKlrPDlukbE0LF176GvXZltuLTV5e6VNKW0SxR4pQ+2Jo42tpUIIXptrEHZG2I/mrHVD0+pO2MkYwbmRLG4Qr5v10X3OrJmcqe1HnU4gl5evLRFEcW9PTHepn0QikUj2j4ERH0MpnTNDmU57p88z4zmemsiRMVT+8PUAQ4OJQoaK7UIMp4bzVNoeQ2mTJ8dzTBZTTJVStL2Ym0t1FDUZVOZ4EYaiUiya2F7IE6MZCmmdaw8aqCRzNBqOj6ooqIrSGyCWNlRs10dRFNKmwVhO5/xIlotTBaZLaSq2z1DGJIwEpqYxMZxiqeHgBTGWngwhWao7hGHMvbqDbmjMjKQRCkwULNp+hCLA7izGe2IsS9bUErNmuQ2CZPjWqo2Cwrce1PjD15c2nTz63HSBT16e5LueGSNjvvOfjWVoZE2NjKlj6uoa4eA8VOpZWz6otDwUBVodz0za0HqP3biFd+0QsRsLzXWln664KKZ1cqs2ThChq+q6WR+blSyuLzQIophnJgvcXGywssnPLpFIJJK9ZWDEx8xwloylUW575CydZ2dKXBjLsdxwiUl8Cy0/IKOr6JrGs1MF3lxuMV1M4YUxq22fsbzFqZEM96ptghgerDqM5kxOD6eZKWVZaXmcG8lTt5OFZm8vt0jpKilLQ1WS0e0tL0QBhEj+ZAwN01CZLqb59vMjFNIGuZSOqqqcHcmQtXTmqg6OHzKaS+GFEYaqcWOhgSKgmDGpOwGKgOtzDdKmzlguxXBHXEwW09wttzg9lKbc8vjy22XuVNpUOyWgctNndpOZHEMZg088N8mLz01yZiTT+3jK0MhaOllTQ9+wWXanMynODGcYy1m8udTC1DXqTsBK02O8kHqotRSSIWLdIWcCsW7mynghxVg+yYbsxpQ5lrcwNJWbiw0MTd2xkfO4Df2SSCSSo8bAiI+hjMFw1kRBYShrMJQxEEJwt5wsiHt6ssjdcotSNslgzNVdEIIYQcP1CcIAXVOYKaYYzaWYKiabbadLKZ6dKqBrGmdGMpwZTnN7pY2pKXhhRBDHuGHSYYNCMjysaLDUcAnjZOS7oqgIoOmFZC2dtKlza6mJ7YdcnMgxXUqRMjRGcsnOl7oT0nB8LEOnvGojOj/fYt1FU6GU1simTO6W2yzWHdKGynzN5fpCnesLTe5VbR5Uk+ffiKrA+y+McPXyJO87P9wTF5ahkTN1stbDgmMtO51JMV5IcXmmiKoonBvNYXtBr2vFDSI0lYeGiF1faCAQXJousFBz133vfhmO7URCd1Bcd15I9+/bcdyGfkkkEslRY2DER7nlY6galyZTVNoB5ZbPStPj2lydr9xexfEiRvMm33a6wHIz5P5qi9JQimrbp+FFGKrK7KrLWN5MfBZBTCmjM5y2uDxTZDSfwg0iHqzafOt+jbeW2zhBiO1HGLpO2gBT09EVge2HhJEgFIlZMmtqmFqW2ytNZistNEWl0k5KMm+vtHj+VIkPPzVGztJ57UGdVx9Uma+5uEHUK3fcXvEI4mR5W70dcGEiR97UqbkhigJvLrV45X6NtvdwtwokpZofemGa731ukuGsiRCCthchgoixnMVU8eFhX/3IWTqqAtfnG/hRxOnh9ENL6yARC2dHstSdRGy0/Qh71Wa1HaAqD++l6X59EAkWai6qAm4QcXultWn2Ybnh8sW3yrQ7ou7DT40yUUz3Pq+q6iN5POTQL4lEInk8BkZ8tPyI2dU2by0LTF2h5ScGzOWmlxhAhWCl5fHmso0XRlTskFJaoe4mQkE1FKpNH0NNzJxLTRcvilDVJu8+P8zZkSx/fnOFW8tNZqttaraLomgEUYgfBQShxpkRnZGswYOqg6qA0ekAUVW4W26TTRsoQDalM55NIVBIaSoLdYfrCw1GcyZemIxoF0DVSUaaq4qCpkAQCxpOSM32uFNpMzWc5fZKm6WGS58kB7qq8OR4jlOlFM9NF/jQU+NMldLkTJ22F3T2wfhcixu863SR0ZzVM2tuVmoYy1vMDKVZbnoYHVPvxiFg3YxE0w2YLqWw9KTLp9L2ewE9ZWhcGMs99L3XjkmfrzlEMZtmH+6t2twuJ6bWpWabsyOZdeLjUZFDvyQSieTxGJh3zayedGqkDAEoZPUkiEQi8Q+M5pP5FA3Hp5Sx8COXuZpDywuo2SGhEBgKCJGMaDd1jZypEYYxby81sTSVt5ZbeKHA1FTyaYsojrB9haxpoKjQdnxsBdpeQCTAjwSWQZINCULGi2liIAxibD8EVWHVgUDAfNXmz64vstL08aOYasvF9iJcPxnTPpzVCUOIEPihYKnpcnvV7ftaDGcMnpsu8MR4FjcQTOQthjMWpYzBTCkJzpW2R6XlJxt9mx4Nx2csnyKfMrYsNSiKQsrQGM1Zm2YG+mUkuntw+gX0jeWT86PZzth4ts8+CEHLDai2kzH0/bIwu0UO/XoY6YORSCS7YWDEx3zD537VIQgjDF1jru7x3CnBU2M55lZtQhGTtVQ0VaXcdhEiThbKiRjPC8hnLBw/YrnpE0YxdhDRdFSemcwTxYLZSpsgivCCiLYXMZY3SRsaQSjQVBUnCGj4EWEkaAcCtbM1FwGqquEGMTcWmxiawlPjOc6NZDg/lsPxQ+brLt+4t8qX3l7FDwIEKqoSE8egq2BoKi03pumFNDcpq6QNlclCiqfGs5wbzfKdT4zghYJr83WylkbGUqjZPssNl7F8Mm8jjAXljh/CCULaXsgzk4VNg/3aeRxNN2CuKtA19aHMQL+MxHvPDW8a0Pt5LHZS3jkznGGsYPHWUgtTV2k4Yc/U+jjIoV8PI30wEolkNwyM+Gg5yf6RXMqg5UXcXmryWilDPmXwzGSBO+Umrojxg5CaE7La9Ci3fMIwIhBqz4ugqQpDGYuFmgOKoNx0mV1tc2WmhK4qRCJmKGNQypiMZQ0Kpo5QBN+8X6PWDkBJulxQIGuq6EryNWlLx1RVdE3h3HCGc6N5zo9luTZX563lFq/P16l2JqE6XshkwSQSES1f4Ll+37KKQrJAbSht8MR4hiiC6WKaJ8fyjOQsri80MTWVuhOgKQq3lpp8fbbKE2NZnp8p9qaqmppGMa0DW5caugEojGMgEVjFdDKno3/G4Z1DbxXQ+3kszo9mNy3vrL2FzxTTqALOjeVx/FD6M/aJQfTByGyPRPLoDIz40DQVN4iStlTgTsVmZLHB5ekiThBSa4dkUjqrdtJ1EQlw/Ih82iCjC0xD5+JEnrurDgu1Nqqmkrd0QiFYqrt84IJGKWOSMTQ0XeX+qkPdCVht+9hewGo7wAsFWmf2VhRDGAsiBEMpEx2VfFrn9FAWOxAs1G1KWZ2m66OrKiqJr8P1wqQM0wz6DgGDpPPl0mQeVUlKLC0vwtRUsmmDyVIaUHh7pc2dik0pbTJfTwahWYbGzcUm9yo2TTfkQ0+O8r3PTfYd0NWv1NANQDOlDG/M11lueggU6k6D5zviApKMxBNjSVvsE7l3JpVuRj+PxVblnbW38JYXkk0ZuEHUNwsj2RsG0Qcjsz0SyaNz8t8hOuQsLTFlRhGKUHlQcygtNVmuu9yv2Sy3fZS2R932CWLBUMak6YbYboCS0snrGlOlNFHnFl9tezS9CMtQqbQDPn9zBU1Lbj2u3+lCMVRuLrrUbB87iPEFaCGogK5D1tQIo5hCSsdQFXKmThiFVG0PgWC17bHU8Li/2sYOIvwIunoj3iA8FMDSFdKmypmhFIoS86DqsdLyyJoG50czTA1lKTeTaaKlbFc8CAw92dJbq7vkUzpjOYuWF9L2Iy6M5XZ8g10bgMI4yZj0uwmPF1J8+KmxdaJmq66V7ZbjbQx4LS8kjGLSps5i3WaqmOaJ8Sz5lCH9GfvEIPpgBjHbI5HsFQMjPvwo8UboioodRFRaPoqi4ked0eaawkLTx/NjFGC17RPHMWbK5MxQGkPXuF+1qTkBQRTiBBG2FxDHOkEYU217xEKh6QaEsaCQMihmNFw/xPVjohgMBUwdcqaOH0Y4foShKjS8gFTH9/GgFhMJaLgRbhBStQNqTrhpliOlq6R1hVzaQEUgUDBUhXLDo+76DKsmURyhIMhbOmlD491nSgxnDJpuUoa4Ml1kopDi2lydpYZPGMfkLH1Xt9duaaVbZsmYKnfLba7PNxjKGuu+19oSy8Zppt3bY7+U9sZb5VaipOWFvNrZvJtLGeRThryV7iOD6IMZxGyPRLJXDMxvy2QhRSGVBNyMpZHteCeG0xaQbIv1ghARxxiGhqlDSjfRFMimDBwv5O3lFrGAphfR8kNQVYI4xgsFy81khLofx+QMFS8MURWdlKkTOyERSdZCF0lHSiAEiqoSxIJ6O8DV48QrISASgsW6R9DPyAGYmoKhJWUYRFIu6QZcXVNwQ0HNjQhDwartE0WCrGXx3nND627/3exDd6vt0xP5vgvn1gqBjJHMICm3/N5gLlVVWWl6vDbXWLe63tQ1gjhmupSIibXZDUiEx1duV7i/anN5poTb2T+zsXSyWUp7s4CXTDvN0PZDzo1ke3ttdhIYZR1fslMGMdsjkewVAyM+nj9V5MmJPOWmSySUXonCsnQadkS57dN2k3X1dSfE0FRGsip+DHfLNiCo2T5+lLSy+qFAU0E1FFRVQVMVbD/EDWKKqTRVx8cN2vhhjKUqGJogCKGYNtBUUIRC1jKoOT5tL8L2QxKb5ubkLY3xvImpCubrAYoiiBVI6wpPjWfJGhpjhRTLDYev3vZRhI4TRmiaQtPx8MKYC2uC6cbAPVFM952DsVYIzFVtHtQcTE3F0BRWO7M5Ki2PMIqZGcr0Vte/58ww8zUH2496wqQrJAC+dKvMq3M1lhs+y02PcyNZRnImOUun4fistnwKaZ3VVkDTDXacuVg/wCxet+tlO2QdX7JTBjHbI5HsFQMjPoQQaIogbapomsZQKln3fq/cRlGS9fVNJyCMY2IBfhjT8iKGcybDORMvCHEjHdf2CaJk/LeuAkIhFskI9XzKIGMKarZLww6xdQ1dVVA1BSUGK6Vj6Rq6qlJMw0ozER6h2Fp0aMB4wex10ay2fLzQRZB8XZRSGcqYvPfcMHUnxI8EU0NZHD+k6vhMFFL4QuEb92oPDfza7LXq3v67y+jmqjbnRnNU2x62H3Ll/CivzK7y9TsVLk2XaHkBQtDbFNz0Al65t0rGSDYE3686PDmew9TV3nbdlhcyXcxQsAzaXoAXRVTaPnUnJGWo3K/aBOVk4+zlU4Vd/Xs/6q1U1vElEolk/xkY8fGlW6tcm2/S8gW27+KHJpauEaHgRzFNN0BVQVNVYhEnu1bcEMvQyJsRVcdnteXiheBGHdOoItDUCF1X8IKYMAwYy5m4fjLEwwsjQgXiGDQ1CeqOH4CiUHUUVu1w0/OqQLzm/6+3fVRVSWaM+BG6Boam4YURuiK4W27TdHxUVSdjqgznDDK6xVJDxzR0hrNJCWknwXTt7b/pBjS9gJWmz1LTw9JUMqbOzcUGADnLZLqUZq4mGMmajOQsHD/k9blkS+zt5RY128cJY26ttPmOc0N829lhIDHc3l5JskPDWYOhtMlMKZMYVqOYU0NpihmDuh1g6Zvvk+nHo95KZR1fIpFI9p+BeWettpObfBTFhGFMw/F55d4quqIQxjG6ppCxDPwgJBaJWIiipIPEDnxqdoDtJ9NGIREGoQBVgCKSTa/VtocfJuZQL4SQ5AVWVYgjcMOktLKJlQNDhbGciROEeKHACZIx6kJNvlfD9rDyacJYkDZ1bC9CoODFgns1Fz8U6HqIisJoPsmELDY9FmoudSfgzHBmR8F07e3/lVkHFXjf+WHuVto8M54liGGuk+EwO4FaV1XOjmQZL6S4vdICFFKmloxR9wK+4/woc1Wb4azZy0Jcmiqw0vKIYkHG0LD9kFfurZKzdE4N5QljiGLBSM4inzIe9z+BHSHr+BKJRLL/DIz4yFgarh/RcEJUBTJW0t4qBLTdEFVRyFsqkW6wavsEEWga2F5A21WJomRo1tr6SAyggOsLWr5HDDhhIhi6FsWw98D+KCSiYyJvMl3KMl0weW2+TqXt4YeJwInj5CwxCrYXEouYnGUiOiompatoKuRSKl4I06UUFzoljmJK5+yFYaq2z9mRzKbBVAjBcsPl3qpN1fZpuiFzVUHW0lEUcIOYmVKGQsZivuYylLHQ1GS8+doFcJBkD/woYqXpMT2U5nY5Zq5qM5ZP8dREvuc5SZs6F0bzTJfSvD5fY7Xlk1VVhICRrMlYPnXgIkDW8SUSiWT/GRjxkdZVihmDKI5x/BglFgxlTW4tNWn5MQqCWIAiYrwoGQJmqMkW1VAEtHzoN7g8iNZ/XGz4334oJHNH8pZGLqXjBskQMCcIuVuNyKdMhKIShA6hEEQd8WJqCqqi4IQxvh2gq2oifkLBdN7E0HUMXaGUNdFVhUrL517NJlhq8uRYjoypcXulhRfGWPo7Jsy2H+EGEdce1Hh9oYkfRkyVUrz//AjvPlPqPSZn6TTdYJ0nYrMFcO85O4SiKIlAKabQNZXJYorhjEEcx5RbPpWWR8sLmKslP+NoLsWl6WR8ux3EXBjLSBEgkUgkJ5CBER81N9mEiqKgaoCqsdRwWWp4+EEIqElnhJ4ID0uHMAQniBEiyTyoAkTcyWZ06L9JpT+aAuM5k5mhNK4fsWr7VFo+KUNFNxVGcxblpkPDTXaQBHHyNSlTBSHQVRUhYnKmQSgEiqIylNYZypp81zNjeH7EqhOgK1BueRRTOkNpk8W6S7nl8ZW3KzhBRKUVMD2UIoxigjimmDKwg4h628f2IsI45s5Km7PDWc6N5h5qN93OE6EoCpemCox2hpW5QcRc1SEWcG2+yVTb58Zik6abmFRPD6U5M5xhrursaLHcXrW/yrZaiUQiORwGRnxoiKR8oUBK1xjL6ozmUlxfbOHHEESJyVQJk6xFEL6TvdB0hTgUGBqkLY2WHxHGSUlkJyhA3lI4PZTmyswQC/U25VZAGEUIoSDimIyhsep43Kt5tN2QSHRKNypkdY0nxrKMZC0WGzZeEOGG0PIDUobG6aEMxbTJfcem3g7ImyZLDYeVhodlaEwUUiw1HBbrHqM5izvlFi0/pNJ0qTo+lyYLNL2QKIyouhGmCnYY8+W3y8zXHT7y1BjPTiftsd1BYpCIhjiO+dqdCpDMBhkvpFAUZV354vZKMh+lmy25tdzi7ZU2pbRBzQkeEis7WSy32SAyRVF2LCr6bdft12p8EpHCSyKRHCb7Lj7+1b/6V3zmM5/hp3/6p/mt3/qt/X66TQmFgqIqiEhBILBMg7SukTVVwkglimIikqyGgN7MDdPQaDpRYvwUkDESw+h2wmOtPUQADU9QcxNDZdOLWG37aKpCxtQwNZ1YKMyuNGl7ggDIGgphJBjOGpwaypI2NFAE50ayrNrJXIwo1iikdKaKKYYyBvdXFZwg4nalSbXlk0/p2PWQhYZOFCXj2UtpAyeImK200RSFcjvgxmITN4iZKFogBLaf7ERZaflUnRARw1g+ac999UGdajvAjyK8MGax7nC7nAwmuzCa5SNPjz3UyruxgySlq7S9kCgSuGHUWzq3m8Vy3UFk37pf653nPWeHEhPrDmd19NuuOyjiQ84zkUgkh8m+io+vfe1r/Pt//+95/vnn9/NpdsRozuTscBpVVai1fJ4ZzzIznOHGcoNKO+g9risYuh5R14uISNpdgxhW7J0VWvppk1o7oKEEKEIQRRBGAiEiTK3blquQSavUnQg3EKQMldPDGZ4az3Kv6tByQxpxjKooZHQNXVHIGDpBFOMGMaeGUuiawqv3qzhBzFBGpenGNFyXS9NFluouAsGlqTw120dVVFbbLn4YoSgKOVNjOGNRSOu8Pl/HD5IZGw03GfKlKArVdrf11qPc8tA1hVLaABTaXv+tsRs7SJYbDpqiUHd8MqZO1tK3vIlvtcNl7XkURellT3Y3q2OHKawThJxnIpFIDpN9Ex+tVosf+7Ef4z/8h//Ar/7qr+7X0+yYpycLXJwqUm65pHSNQiZFzQk5M5xmvuqgqhG2J3qDu7qZC7ejQrZoWNkxdiDQVdDVJPuiqqAiODeSYyJvMF+1ieKYlAqWoXBqKM3FyRxhJFBQEMB83cMJIrxQEMUx8zWXmudj6skSt+limrYb8vZKCy+O0dRkr42uwJmRDO86M8ST4znemG/w9kqLcjuNIgSFjImhqWTMZIFete0zX7MJnWRr70Ld5anxXK+LZTRvIWJBKOJkcZ4fM1EwcYN3MhmbkTI0npnM92Z4pAxty5v4Vjtc1p5HV5XeY3Yyq2O323VPEnKeiUQiOUz27R3nn/7Tf8r3fd/38fGPf/xIiI+Lk3k+9swYX3pzmbIaoCoxD2ouhbSJaWi4YUxKT+ZzROzPXVgAKUPFC2IUBYopHV1TCcKQm8s+kUj2vxiawjOTBb7j/CiOH+JHUS9QmJqKZaq03YgoVnH9ZIdLue1xcSrPcDYpnSw3E8Fgd7pU2l7E82fyvP/CSM+X4QYRlqax0nQZzacYyeoM51PkTI1SSuftZQs3EsRRMsTsqfFcr4tFVxWGs0ZnwJjD2ytthtIG8zXnoSmqG4XFdCnFSM5aN8Njq5v4Vjtc1p6nO5p9p7M61m7XHbSZHnKeiUQiOUz2RXz8j//xP3jllVf42te+tu1jPc/D87ze3xuNxn4ciUo7YKnhUXUj7lZs3lpq4YUREwWLjKERhQHVcHfdKztBV96Z+WHqgEi8I5qazOfwY8H9mkvTC4hFMvVT1ZTET9FwCKLOsrlYkDVVTF2j5Qa9QWVDWZNC2iSKkyCd7DOJKGUSYTBftbl8qoSlqTw7XWQsb7HS9Fhpeli6zt+6VOLmYouJosVY3mK+5uBHoGkqxYyJ4oaMDlsYmkrbj7g4mQcSQdFdLJc2dYRQNk3hbxQWlq72DXy7vYlvZlTd6ayOQZ7pMcg/u0QiOXz2XHzcv3+fn/7pn+aP//iPSaW2N7B99rOf5Vd+5Vf2+hgP0fJCmo5PGMWEYYQXRBi6QiwEFTtgtR3vebZDAdKGStbUCUUMIqbuJGIijMAJIwytM8CMZEOuG8VcGMoyWUgjYoGuKuiail1zO2ZIgakpWGaM5wcMZ1MMZ3Umixa2F+KHcHY0x1LLJ2OqnB3NU0pbDOdMzo5kKbd8Xn1Qp9LyeFB1ABjOmVyaKnREAr1x6U+O51hp+UmWI2fgBhF/M1vl3qpN1tKZr7mM5qwtU/hCCNwgotzyqNk+Izmzt95+beDb7U18o0fk/Gh2z7s1HqUjRHaRSCQSyfYoQog9jbl/8Ad/wN/+238bTdN6H4uixNCoqiqe5637XL/Mx+nTp6nX6xQKu1smthXLDZf/9pVZPndtgZWmixfGKEpym98vFJLMh66CqatkLT3pclFU/DhGBXKWihsmy+wURSFraJwZSaNrGpCIo6ypUXVDzg6n8fwY01CIhYJKzGQpw1NjeVQVwlhwf9VGU1RsL+DpyTzPThdIGRp+JLB0ldW2T6Wzifb6fIPJYopLU4VeRuTVB3XCOKbthZweSpNLGVi6ihtEvDHf4F7FpuEFfOyZcbxQ8NREjvOj2U0D7nLD7duR8rgBebnh7nu3xmbPsZXAOIhzSSQSyVGk0WhQLBZ3FL/3PPPx3d/93bz22mvrPvbjP/7jXLx4kZ/7uZ9bJzwALMvCsva/3jyaM2k4PosNj6YbdbIc+yc8IPF4GFq3zKIwmjOoOwF2EKPRaeftzPNQFYWUoZJLJf4MQwddVXHDZIlcydJYWLVxIzA1gRcpXJzIkTZ1IhEjYpVLUwXm6y53VlqU0ib3qw6XT5UopM11i+IUBRZqLiM5i0tThYeMnbOVNi03ZLUd0HAjnj9VZLXtc6dioysKy02fa/N1Lk4WyVl6L4U/1gnKd8rtXlBuecmunO7k0pSh7Ukm4HG7NXaSodiqxXczgSG7SCQSiWR79lx85PN5Ll++vO5j2WyWkZGRhz5+UPhhzA/+v77EjcVm38+ryubL3h4XL0wyH6CwVPcIOrtfQhLRUXMTIaKoAkOLafsx1ZaLZVnkLZXxfIqxnMVK28cJIgKhMJ6zWLUDlhsOc3WX6UKKQkan7gbU2h6GqnJhLMs37lX50lsrvOt0iTBOdrPMVQUjuWT7bMZQWWm6XF9o9Pwb44Vkn8pqO2CqmOLGQpPrCw28ThdLNqUznrc4PZTh+VPFbYeBPWpXxVpxkDUTwdod8T6W37rUsxN2MudiqxbfzQSG7CKRSCSS7RmId0ZTV5NAukZ8KAqcHUozU0px7UGVur8/zx0BSpwsZnNFUl7ReKejptfWG4MTCMI4QFUUam0PP9QYz1tMFS2EIiikdG4utJit2qQMnSASNNwAQ4VVW8ULI0azaeaqdf7o+hJ+EJPSdXQ12WszX3PQNZUzwxkUReEb91b5ws0VhBBkTIP/x7fN8NxMqRdAbyw0uV+1EQi0zsZdO4iYLFo8Of7w2PV+Qfn8aPaRuirWioNutiZnGT2h8LjdGjvJUGzV4ruZwJBdJBKJRLI9ByI+vvCFLxzE02zJ3/22U3z+5gqFlM50McWpUporMwW+fq+KZRoofrBvo6YU9f/f3r3GRnqehf//PudnzuOZsb32eu095LTJJts2J9K0hR/tryiKKvpHKgUFKSW83IiECEQLQgGhNi0SCNRWoQWUvoCoVEBaqFRKaCH55y9C06RpkzbNaZM92Ls+j+f8HO//i8ee2rverHd37Nm1r4/kF+t4Pfezu5n78nVf93WBpqlu59T1eoZoJEcwfqQwSAIm09BpBjELrYCOH7PQ9MmnTQwN2n7EbD3CXr52G6gAXVfcOlHCDyOmai0GMykGcw6GlvS0KC8Xhyql+OGJKv/10xl+eKLGNcMZFlshb8w0uGF3sbuBvnKqljQlWz4yyacsppc6eIHihWOL3dsm79QM7GJvVawODl441gYNrhnO/yxQyLvn/L4bOVLZSIbina74SoAhhBAXb0dkPgDGBlL8P+8aYbLapuNHlLI2accgDGOCsPc3XVYL4yT4MEk6pa5kO1aCkIgkG5K2dUxdx48idJI5NKau0/ZCLF0niCNM3SRtQdY1mat7eH4EpqKcd4lijeeOLXLVYI7hostszeN0zWM4b5NxTPaW08w1kqFux+ZahJGiHUUcW2xRSttJC3d+tulCMtX3VLWTZE9SJtVmiB8FTFY76Mera3p6vNOmfL5jlHcKDjKOiaax4aOMjRypXEoA8U4BlbQtF0KI89sxwUe1FZBxTEaLKV49VWO+0aGStTF0DbXJNyEVEEWgG0mvD7U8GyaOwQZiDdKWRs41SdkG9Y5GJ1BEscILQ6ptDdMwcE0Tx9RJWQbtICJl6Zi6hqXrDGZt9gykMQ2N3UWXYtrmuN1iruExkLaZqibXaqeqHeYbHm/PJ8Perh7MEMYxB0fy3Lg7z/RSm+MLyayWZBBevhskKKV49XT9rI6i52sGBms35YaXTLPNudaGgoP1gpV3spEjlc3qcyEFp0IIcX47JvgYzDloaMzWPUoZl73lHK6lE8YKYnBN6ISb9/oRoMdgmRq+Ut20h6aDpSWb4UAm6cURxorp5Z/4XVNnsppcDR4ppsnYOqAxXfeoeyEjOZdSzqKUsRktpjF0jYYfgRbiR4pyxu0em8zWPcI4Zjjv8FbKxLV1rhvJkbYN3jNRQtd1/t/X5zg61wTgwGCG9189yP7BLJBkL9brKLoRa45RjrdBwbW78hsODlZnToB37J/Rz6JPKTgVQlzOLpdeRDvmnfG6XTl+6dAu/vuVaZa8EMsE0JKrnzpo0dpJtJshUKCHyd1aTQfi5EjGMnXKWYdrhnIM5Rx+OFmjEyoMU2EZGpap044Us/UOumaTsi0GUjYtPyKIIjJWilsnSly9K898w+v28Vhsecw12jz1WjLI7dDuAo1OwI/mWmhojORT7CmlGcjYlDM2DS+k6YUUUzaQTLY9M7NxcCRPOWN3syNKqfPOcoG1m3KSRdn4MQpc2HFGP2sypB5ECHE5u1yOhndM8KHrOndeVeHqoSzHF1ostnxeP12n6JrkXYuOn9RZbG7nD/BWrriQ1H+kzCQbkrYNsq6JZRg4JkxUMnQ8n6xroGkO4GHoGteP5JlrBNQ6AbFKqkdcy2T3QIq0pfP92Savz9Y5sdCimEpuxEzXOkmAk7EBDQO4aleeth8y2/BRaCy1a4wWXTKOyXR9OfORzZwVGGia1m3jHsWKpXaNm1bViKx2Zp3HyhHOhR6jwIUdZ/Szdbi0LRdCXM4ul6PhHRN8QLIxDBdSDBdSHJ1tcGy2xVzLp9ryCKLeTK7dqJXrtmgaWdfk0GiBMFa8MVOn4yssPcY0LPwwZqraJogUBdcin7LoBEm30qGcy/sOVNhdStHyI7718mn+960FOkHEfN3n5/aXKaQsUrYFKGYaSQATKcUPjlexdMVQIYVjuhxbaJF3Dd53VZmJcjLddbyUZjDnnJWmq3eCDf3jXS/CXjnCuVBynCGEEJfucnkv3bHv4FnHJIgj5hsekdLQDIXapLTHSk9XS0uai60c7RQdg8GcTSWXohUELC5FtPyIKI5p+klB6VAumbo7WrQJQsWppQ4TpRwHRwu8cqpOJWdTySZTaheaPmnbZKSQou2HOJaOrlvdGo6cYzCQtsk6Fv97dI6BlM3UYouTiy0yjkXGNtlbyXLrvnI34HhrrkkniJhcbCc9Span0m7kH+9KhL26WRm8c73GuchxhhBCXLrL5b10xwYfgzmHq4ZzVLIuS+2QxZa/JjDoJR1I2ckUW8swSDsGQQS7Cg6FlE055+CaBoNZjROLTRabAUN5F9cyuGY4w3R9hoWWz56BDKWMg2vr5FMmE+UUxbTNaNGllE6KTt+YbdL0Q3YXUlw1lKWSdbqZjLRt8Mqp2vL8FihlHbwowtJ0btlXpu3/rMZjddZirpF0TV0pXD3XVNozrdesLIjURZ0xynGGEEJcusvlvXTHBB/rVfgeHity4tpBDF3j9Zk6s3UffxPOXgwd0o5J3rUZyNhkXQPbSIpM867NRDnF6VqHU9UOjmWyp2QyNpAhXi7k3FV0aXoRrm0wNpDiht1Fml5I04twTIOpaodyxuauQ7vYXUzR8kPKWQfH1NE0jVv2ltA0DaUULT/i9FKHwZxDx48opi0qWYfppQ5+FDEepFEq6Sq60PDJp0xaXohr6d1Mx+qptO9UOb1es7JT1Y5cPxVCiB1uxwQf56rwvevQLmKlWGp7LDQ2p8d6J4bFVogXJHmVtq9zeGyAkYJLyjFRaFSbEQNph/GSwY1jRXblHabrPicWmhwaLXL1UJZjCy32VrIcHMnz1lyThWbAaDHF5GKL4wstylmHd40PoJTipckab862MPR291k1TWOinGGpHTDf8Aljxbv2FAB48cQSlpEEGJWsgxfGnFhsEczFWIbGwdEyo8XUWZmOd6qcXq9ZmdRrCCGE2DG7wLoVvnmXN+daPP3aHCcX2nQ28aqLF0GsIsKlNoW0w55SGscyQINi2saxdA7vKaJpGrsH0mQdk2MLHQzNoNb2mK557C6mmShn0DRtTdFQvRMwVW3R9mN0HfZXMiiS73NmQWiSjSiuyVS8NdekknXW/Nk4ps7YQIpC2mKplQyZW69Y9FJmpAghhNiZdkzwkbEN6p2AF44lzbtWrnv+9FSNU7UOhq6jNvGi7crslhiNThDy8lSVw2MD3c3dMnRq7ZDScuOulU392pEs1bZHre0zkLaI4xilFIM5hxt35zm+0GK61uanp+rEJMFAvRNQybpM1zprnhXWP+9bCWQmqy2aXsh8wyPjmJSyFguNgDBWeGHyusCaY5aMbVz0jBQhhBA7044JPpRS1L2kjiFWScOuph+haxooqHWCTXttjeTGi64nRadpy2ChGTDX6DCUT4a97R5IMZyzCWKotX1O1zxm6x2OLTR5c7bBUjPkx1N1TlZb3H3jKMOFFADHF1q8PZdMui1nHLKuSaQUXhSRNpKZKOezkpk4Nt+k0QmZb/hUWwEp2ySIPGzDYHIxOY4B1hyz3Lg7f96sxuXSUa+XzvVM2/FZhRCi13ZM8HFisc1s3aeYsnh7oUnbjzgwlCPrmOwpp5istjbldR0dbANs06SYMhjIpii4JinbZKrq0fAWObS7QDnrEMQ/m71yYrFF0bWZWWpxfK5FK4gwjWQs3Y27iwwXUhxfaPHmbJOMbWPqGn4UMWg7FFybgbTNSCHF2/NNji+0ujUf61nJTDS8sFtHMlVtE8WKwZy75kgFWHPM0vQj9g9m3zGrsbouRNdg90AK1zIuaXPu9yZ/rlqXy6V7oBBCXM52TPDxMxpBGBOrZAONoogB16KQstC9gGaPa04tA3Ipi8Gsy3glQyltstQOqbZCimmTVhiRT5lEserOXlEk11vHBlKYDYNWENIOFVoY0Q7ss14j65ocGMxy1WCGg6OF7pXa/31rAYCM3WKinDnnJriykc83PBpewGRVYeo6gzmHqWrnrCOVC21Q0/BCwjgmZRm8NFnljdk6+ypZTF3f0Oa8XqDR703+XLUul0v3QCGEuJztmOBjvJRmfyVD0wu5ejnjMbnY4vWZBscX2zS8iHaPA4+UDoWUTdY1qeQc2n5IZiDFnoEML5yo0gpCYi/i5GKbfZUsgzmHV07VeOVUjaV2yCun6mRsnfFShroX0vJDDgxmGS/9rAPpyjPdNFbk/VdXGC6kuldqm17E3kp2Tf+O9axs5GEUoxSUlwfcVbI2layzZtNXSjFaTH7CH8w5VLJnB0NnyjomTS/kRyeXWGz62KbG9SMFOkG8oc15vUCj35v8uboEXi7dA4UQ4nK2Y94Zh/IuH7hmcM2I9uMLLeqdgKYXAKqn7dUdHQayNq5toGsa842AXMqgE4SYhkPaNtCUzmInJI4iRosu1w5naXohjXbAe8ZLLDY9Rgou+wazTC8l11QP7U42Xq2W9OpYeabV9RY/u1Ib0lk+rkmGua1/VLGyka/cjilnnW4W4cxC0dm6x1S1QxQrpqodKqu+9lwGcw7jpTSNTsi1wzlePV3j7fkmu4vpDWdOzgw0+r3Jn+sGj9zsEUKI89sxwcdqmqYxmHNo+hHD+RRoGn6oejLVVlv+yDoGhg4Z28Q2dGYbPk0fdhVSvD3fZr4Z4FoG842AyarHD45XgSSbsTK0bayU4cbd+W6A0PaTbMjR2SYZx+xmOtb7iX+9TfBcRxUXspFfTMZhdTAURjH7B7NMlJNrwxvZnNdbX783+XPd4JGbPUIIcX47JvhYb+PN2AaoGFNPbrxcSuBhkPx+AzDN5FbLrnwquX0Swa6ChmsaXDWY4ehsgyCMyNgarqWhaXBioYVSiv97/fBZm6qmaQwqxZM/Ps0LxxYoL1+jnSinu7dezrSyCQ6umtEy3/AIo/is/h8XspGfGQhkbIOZWue8hZ/rvcZGC0TP9XtlkxdCiCvTjgk+1vuJPWMbVDshrmlQylosNgOiOBn+dqEikoyHroFlaBQyNsN5C0NPrrv6oYVtKF6erDHT8PCCpKdIyjKIlvt22IbRvT2yOmhYOTJ5c7bBfCvAjyFj6xta1+qgq+EFKMVZGY4L2cjPDASUUhsq/LyUYEECDSGE2F52TPCxXuq+4YWkLZNi2qbWDun4EV4UE4dcVP2HAjwFttJYaoccne9gahpoOkM5k9GBLKeqHTK2QccP8ZZnqziGhoqhmDa7AcGZmZpCyqSUcTi4K8fpWoddBbdbeLpmDWfUddQ7QTfoOrkYY2gajqVvuFj0TGcGAkdnG3K7QwghxAXZMcHHuY4WhvIucRQz1/QIY4UCTA38SziDSdtJP475WtJK3Y9DBtImKI2MbbLY8plvBgwv3yTZM5Ah5RiMldLddXXH0RddfjK5xFQ1ptEJyNgmN4zkuXlvicGcw0ytQ70T4IUxjqnjhfFyj47kSuxo0e0GXU0vIumppnWH0a3Uk1xsr4x+F34KIYS48uyYnWK91H1yW6TC88fmME5pFNM2Cy0fpV1aAUjHj3FsCGNoh0kOZSBjM1RwsU2NU0stXFPHMjWUgolKmoG0g2sZ3c1/ZVN/ZarGa9NJdgFNsavgcvPeCgdH8t3syELD58Rii7GBFEEUYxk6148WmKq2cUy9G3TNNzzmm343S3FsvsnxhTZNL1xTwHoh+l34KYQQ4sqzY4KP9WialtwWybo4poFr6UQNhXcJd241wLU0XNuk3vJZanaoZFOMFVwGUhauqRMpxbUjipmah2UktRsNL2C+4XU38NXj6OfqHqaho2ngWHo3SFnJjuRTJsFcTCFtUWuFBHG8nIkAL4zRVs1hWWqH3SxFtRVwdK5JMWUzXW++YwHrO/0ZSj2GEEKIC7Gjgw9IaiQqOZsYODHfuqjAY+V6rQ4MZC1GCy7HF9qEaFimST5l4VoGDS/EtXRswyCfsdhbzrK3ksE2NI4vtJlv+Cy1w27R5krh5mzd4+hcE4AD2cxZDa0WGslguqVWQCljd9uXd4KIycU2sWLdOSxvzzVW/hQu9Y9RCCGE2LAdH3zM1j1q7YCMpTN7EaNBdCBtQjFjY+ka+ZRFO4jwwhDLNBguOMSa4tXpOqWcw/+5Zghd09lVcDk4kmcw53B0tsHbc20AFho+9U7QDTwGcw7vv7rCRPlnXU3PbGhV7wQcGsvjmDo51+rWbhydbRArzjmHRSnFgcGkSPRANrNuAet6+j1XRQghxJVtxwcfDS+k4cWkbBNjg/unTnIbRie5WqvrGq5lMJx3STsGC3WfrGvR9CNmah7FtE3GsZip+bw8tcR1uwocHMl3AwwvjDmx2CKYS+o1Do3lu6+1cjS03nFI98jjHB1Gz1cMOpR3ef/VZ3dIPZ9+z1URQghxZdvxwUfWMSmkLWxTp5S2mGkEBOscvTgGWDr4Ed3/rgMo0DRFa3l+ymDO4WinSRgpMraJricNx0ppE8cy2DOQ5qaxwpqN3jF1xgZSFNIWS60Ax9xYD4/V1stGnK8Y9GLrNfo9V+VyI5kgIYS4MDs++Fg51piudbANsMwOUwsdVs+Yc3QwNFjuC4apJZkPDdB0UOjoukHLj5hv+lSyDo6h0w5ibFMj71poWlJz8XP7y2dlCXKuRTnrEMWKctYh51oX/BznykZsRjGoXK9dSzJBQghxYXb2rsHKnBeXG0YL2EYy46XW9qm1Y2LA1iHraJimRRQGBEqjnLZYaofJT7c6RJHCNXXKGZtSxibvmHz/+CILLR/X1Mm7FhPlLB+4ZnDdo41eXFfdymyEXK9dSzJBQghxYXZ88AHQ9CPyKZt9lSzPvDGLqeukHTCJqeRShLGi4Ue4joMWRliWQckwSdsGoVJ4QYhrGbi2yUDapuBa2IZGOeNQTJsMZByG8uef/qqUYq7hUe8EawpHNyJjG9Q7AS8ca5NZvlZ7IS7k6ECu164lmSAhhLgw8i5JsnnoGjz/9iIzNZ+2HxIBXgSq5RPHMZ1AYWoBGcfC0nUWOj6GBsMFF9twKGUcDo8XOVXtMFVtYxkGtgleqMi55jkDD6UUr5yq8cKxRdp+xFI7YLyUoZS1Lzh9ry3f+b2YcgM5Orh4kgkSQogLI8EHyeaxeyCFpilSjoEfxbS95NjFayWFHqYOKoZWEC0PhlNoeoRe9xgrpUk5Jg0/ZqHpE6MxUnCptyMqeYtfftdurtuVW/e1Z+sePzhe5eRiG12HejsknzKXB8GF3QFz58tINP2IrGNxzXC+e632QsjRwcWTTJAQQlwYCT6WNToBjp1cl52ve92rtCsXX1ScZBSiOKbRjglj6AQhnmty9XCWnG3S8QPKGYesa3Bioc3+QYtb95UZKbjMNfx1A4eGF2Isdy59a7aBqSfNwso5h4xtdLMitmEwkLE4vKfIUN4965gkYxuXlPqXowMhhBBbRXYYkuzDSyeXOLnYJghjUpaBF0WoVY0/bQNSto5jGTQ6EVqchCW6pnF0rokfKcZLaRabPoXAJOuajBZdXp+u8+ZMnaxr8b6rzp6dknVMTEOn2kqOdEaKLvsG0+ytZFFKdbMiqwfODXH2McmZ3UsvNPUvRwdCCCG2igQfQL0TMFv3QGlEMViGRtbWaHoKw4C0AWnXIu8apCyTMGqjoWOYGnnXwNI1IqXww5hjCy0Gsxa6pvP2XINaJ+TwniJH51qYusYdByprMiCDOYeJcpqmH7K3nKEdRFRyyRXZo7MNTF2jknOYrXs4pt7NSJx5THJm99ILJUcHQgghtooEHyQdRmfqHnMNj2o7IIyTNummEWGbBhoKXTcoZVwqWYe0Y1Ft+TS9iIGMy95KGpTG5FIHL1DU2xGzzTamBvPLTcNsy+DEYovMyaVuMefK0QkkGZB2EGHq+prZLeWsDUDKMnj3eLGbkdguxyTSoEsIIXaeK3PH6jHH1Ll2JEsrCKlP+cRKUfdCbN1IpsmicEydjGPSDmPGSylunhjgtdMNdhddRgopJpfazNTb5FydmXqHpU5IJWuDrjHX9Lh2OM+h0QJeqKh3AoDlkfYt0raBUlDO2EyUMwzmHJRSKKUopCwKKYvxUpqhvLsmY7Idjknklo0QQuw8Enyw3GE045BLWaRsg3onxNENlKbR8UM0NBpehGsZGLpOFMYsdUJcW+fweIm2H+K2OnhhzMnFFnEMYRyz2Ao4UE4zXs4wOpCiE8aYuo4Xxrx1conJxRbTdY/b95XQNZ1y1ulmRF45VeMHx6uYukY5a6Np2pqMwHY5JpFbNkIIsfNI8MFK3UWGRicgjhQdf57RgQzTSy2iGAopi5OLLeptD9M0GcraLDR8iimb548tEEYxS+2AxWZAy1egYoYLLjoag7kUVw9l2TeYxTF1NE2j0QkIo5i9lSzTdY+355vsLqa7RyezdY8Xji1ycrFN5YxC0+1muxwfCSGE2Dh5pyfJIkyUMxybb6HrGrmURcsPcSyDpU7IYquDbhgU0jZhrNH0Q1K2ybW78pxYbNH2Q47Pt1jyAkoZk4VWiGPq7C5lKKZNXMvi9JKHrkPWsWh4Qfcmzf5KholyunvcAkmgYRsGg8uFpinL6G7K261GYrscHwkhhNg4CT6Wrdw6aXQC9lUy/GSyRr3jJzNcDBOdENc2Gc6lqLY8YqWYqbeBmAOVLEpB/VQAWjL75cBgngNDGUoZh4OjeV44tgAaXDOcZ7KqKGdsylln3QAi65gMZJLhco6p8+7xIpWszUyt060TyTgmpq5f8TUS2+X4SAghxMbt+OBjdSYh45iMldJMLrYZr6SptU1OLnaoZG0ans5gxmZfJc3xeUUYa7SDCA2N6UYHLwwZK2UopUzuuKrC7ftKBDFMLraZqibzVjQNpqptTF1nopw5Z9AwmHM4vKe4JhuwUpi5uk6kE8Tb9jhGCCHE9rXjg4/Vty10DXYPpCikLOIpxWzNwzI1Zho+A2mT0YE0+yoZbCO5BaOUotr2abYDHMvi0O4ssVJcuyvPVcN5ppfanFxs0QkirtuVpZJ1aAXxeY8X1ssGrBRmnqtORAghhLhS7Pid68zbFq5lcHAkD4CmIO+avHhikV2FNLV2wGzDoxWETM60sUydnGOSTpn4NY9qy8dYDkpm6x7/35vzvDnbBCCIFAdHNFp+xHzDQym15urs+awUZrb9sFsnMl5Ko5Ti6GxjW9R/CCGE2Bl2fPBxrtsWmeW255apk3MtvChkZqbDG9MNXFvDNExGCjYjxRSVjM3r000Wmj67CikyjknDC2l4IcWUBWicXmozW+9Q95KBb/srGd5/dSW5/bKB4tH1CjOlR4YQQogr0Y4PPs61qU9V21iGTj5lMpR3ObXUIQZOLrYoZxwKGZ0g1Gh0QuYbHgXX5PrRArmUibt8OyXrmEzXksxHzjWJ4rgbjDS9kOMLLZba4YaCh3c6ipEeGUIIIa4kOz74WNnUV0bXvzXXZL7hEUaK60cLTFat5eyIznyjw3TNxLVNau2ArKMzqlLM1Dwsw2Cx5VPK2uRci8Gcw/uuqjBeSgOQXp5Qe3SuBSSZD+CSggfpkSGEEOJKJLvVstVHGCt9OFZuptw8USLjWLx2uka15SfHMYZiOO8yUnTohBH7KxnmGz6mrqGWm3gMF1IMF1IopZipdRgvpcm7FsW0xUQ5CT6W2rWLDh6kR4YQQogrkQQfy+qdIDk+SVsEUcz+SoZKziXrmFSyNoM5l7GCg9JgbqlD04/R0fjp6TqGrlPvhMw3fZSmCN9QvO+qCsOFFJAENi9N1gij5GrsQCZpl17J2pcUPEiPDCGEEFciCT6WJXNZ2hydbRJEMaW0zd5KtlsEOpR3OTbfxNQNBgspGnNNxkoZHENjpJii6QW8PlvHasNsw2PPQKobfKzUZqRskx9NLtH0Q5baITfuzsvtFCGEEDuO3u8FXC4cU2fPQJp9gxkipZhaavOjk0vdkfer2bpOGClOV9ukHZPdAynqnZDZus9M3Wem5lNtBd2vX6nNeHuuAcDecoYoVhxfaPGjk0u8Pt0452sJIYQQ241kPpblXItS1mZyMWldvrecYbrm8ZOpJeYaHrah0QkisrbOqaUOrqVjmzr1TsArp2rUOiEayRXdfEqjmLa633ulNqOQMskutGgHEaausdj0OVXrsLecoR1E1DtJwLJd5rYIIYQQ65HgY9mZAcLpWofXTjd4a66BHypGii5L7QADjWrLJ2WZVHIOLT/CMHQO7S4w2/DIOxYTlUy3oBRW3ajJOYyX0hxfaLHY9Dmx0GKu6TNd8zgwmMELY96Svh1CCCG2OQk+zlDK2GQck9dO14iUIo41JpfalDIWYaQYzNsstByyrsGx+RaupZNxTdpBxE1jRcZLayfUrqZpGpqmsdQOOVXrMN/0uW5XnmrLZ7yUxjF16dshhBBi25PgY9mZ3ULTjpl0OdU0dA1m6h5KwXxTp5Ay0TUdRchg3iXnWFSyTjfoeKejku6MluVjnWrLZ/dAupspkb4dQgghtjvZ3Zad2S10IG1xYDBDreVTzli0Oj6FtEspY1DOZjhVbRNEFtcMZemEMeWss6Ejku6MliDiwGDmrEyJ9O0QQgix3UnwsWwlKJhcbCW9ONImB0fynFho8tJUjXonRjdDFtom7aDN6ZrHTD0ZMnfTWHHDWYr1GoOtzpRI3w4hhBDbXc+v2j7yyCPceuut5HI5hoaG+OhHP8qrr77a65fpuUrWZrTo4oURdS9gvukzVe3QCWLSlkHGMXh9usbR2TphFDFacLl6MEvesRgvpTecpVgpPt0/mL2gqbZCCCHEdtHz4OOpp57iyJEjPPvsszz55JMEQcCHP/xhms1mr1+qp+YaPpOLbU4utHn1VJ3Zhs/kYhMviAmimDdmGzQ6IdVmQM2LWGoHhEp1b7ZIECGEEEJsTM+PXf793/99za+/8pWvMDQ0xPPPP88HPvCBXr9czzS8kMVmQBDHnFpq89Z8i5GCzY2jBfYMuJystnHzDn4YE0cR79pbYiBtX1DWQwghhBBbUPOxtLQEQKlUWve/e56H5/2ss2etVtvsJa2hlqfZzjc85psdWn7EYM7h+EKLrGMx3woopWxsQ2euHpBPmWQdh6uGcuwfzG7pWoUQQojtYFPbq8dxzIMPPsidd97JoUOH1v2aRx55hEKh0P3Ys2fPZi7pLCtXbOcaHkEUo1CkbINy1mEg7QAa5azF4T1Frh/JMZxzKWetS74GuzLp9uhsg5lapzsJVwghhNjuNjXzceTIEV5++WWeeeaZc37Npz71KR566KHur2u12pYGIN2hb5bBXNPDREMHhnMOjqmxq5Di6uEckQLT0DA0jX2D2W4r9Atpgb6SZWl4IZ0gYnKxTayQbqZCCCF2lE0LPu6//36++c1v8vTTTzM2NnbOr3McB8fpX81ExjZoeAHfO7rEyYU2E6U0xxfaDOcddE3j4EiecsYGGuQciyhWTNc6tPz4goOG1Y3M5hoelq5zcDQv3UyFEELsKD0/dlFKcf/99/PEE0/w3e9+l3379vX6JXpOKVAoFBrVdkC1HWIaBg0/ouVHtIKYnGvxnokShq7R9CNGiymiWNHwwg2/zupGZqau4UeRdDPdYnLcJYQQ/dfzHe/IkSM8/vjjfOMb3yCXy3H69GkACoUCqVSq1y93yZp+RM61+MA1Q0SvzlJvexTTFgMpi2j5a1YakE1V22QcE03jooKG1d+nnLUZKbi0/ORVlFIopeTK7iY7s42+HHcJIcTW63nw8eijjwLwC7/wC2s+/9hjj/GJT3yi1y93yVYCgk4Yc9NYgaxjMLnYpumHmIZO2jaoZO1uV9KMbQBJ0LK6Bfrqeo71OpfC2d1NlVK8NFkjihVL7Ro3LTcgE5vnzDb6ctwlhBBbr+fBx5WWxj4zIKhkbX56us4LxxaxDYOpaofBnHvetucb+Yl6pbvpyvc5OtuQjXCLrc4+yXGXEEL0x45/5z0zIABwLYPBnHtBQcHF/EQtG+HWW2+2jhBCiK0lu906LiYouJjfIxvh1lsv2BRCCLG1JPhYx8UEBRfze2QjFEIIsRNJ8LGOiwkKJJAQQgghNmZT26sLIYQQQpxJgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWkuBDCCGEEFtKgg8hhBBCbCkJPoQQQgixpST4EEIIIcSWuuwGyymlAKjVan1eiRBCCCE2amXfXtnH38llF3zU63UA9uzZ0+eVCCGEEOJC1et1CoXCO36NpjYSomyhOI6Zmpoil8uhaVpPv3etVmPPnj2cOHGCfD7f0+99OZDnu7LJ8135tvszyvNd2Tb7+ZRS1Ot1RkdH0fV3ruq47DIfuq4zNja2qa+Rz+e35T+sFfJ8VzZ5vivfdn9Geb4r22Y+3/kyHiuk4FQIIYQQW0qCDyGEEEJsqR0VfDiOw8MPP4zjOP1eyqaQ57uyyfNd+bb7M8rzXdkup+e77ApOhRBCCLG97ajMhxBCCCH6T4IPIYQQQmwpCT6EEEIIsaUk+BBCCCHEltoxwccXv/hF9u7di+u63H777Xzve9/r95J65umnn+YjH/kIo6OjaJrG17/+9X4vqaceeeQRbr31VnK5HENDQ3z0ox/l1Vdf7feyeubRRx/lpptu6jb+ueOOO/jWt77V72Vtms9+9rNomsaDDz7Y76X0xB//8R+jadqaj+uuu67fy+qpyclJfuM3foNyuUwqleLGG2/k+9//fr+X1TN79+496+9Q0zSOHDnS76VdsiiK+KM/+iP27dtHKpXiwIED/Omf/umG5q9sph0RfPzjP/4jDz30EA8//DAvvPAChw8f5pd+6ZeYmZnp99J6otlscvjwYb74xS/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": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" + "cells": [ + { + "cell_type": "code", + "execution_count": 16, + "metadata": { + "id": "9GIt_orUtNvA" + }, + "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": "h7AT6h2ItNvD" + }, + "source": [ + "## Use BigQuery DataFrames to visualize COVID-19 data\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", + "
" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "n-MFJQxLtNvE" + }, + "source": [ + "## Overview\n", + "\n", + "The goal of this notebook is to demonstrate creating line graphs from a ~20 million-row BigQuery dataset using BigQuery DataFrames. We will first create a plain line graph using matplotlip, then we will downsample and download our data to create a graph with a line of best fit using seaborn.\n", + "\n", + "If you're like me, during 2020 (and/or later years) you often found yourself looking at charts like [these](https://health.google.com/covid-19/open-data/explorer/statistics) visualizing COVID-19 cases over time. For our first graph, we're going to recreate one of those charts by filtering, summing, and then graphing COVID-19 data from the United States. BigQuery DataFrame's default integration with matplotlib will get us a satisfying result for this first graph.\n", + "\n", + "For our second graph, though, we want to use a scatterplot with a line of best fit, something that matplotlib will not do for us automatically. So, we'll demonstrate how to downsample our data and use seaborn to make our plot. Our second graph will be of symptom-related search trends against new cases of COVID-19, so we'll see if searches for things like \"cough\" and \"fever\" are more common in the places and times where more new cases of COVID-19 occur." + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "ffqBzbNztNvF" + }, + "source": [ + "### Dataset\n", + "\n", + "This notebook uses the [BigQuery COVID-19 Open Data](https://pantheon.corp.google.com/marketplace/product/bigquery-public-datasets/covid19-open-data). In this dataset, each row represents a new observation of the COVID-19 situation in a particular time and place. We will use the \"new_confirmed\" column, which contains the number of new COVID-19 cases at each observation, along with the \"search_trends_cough\", \"search_trends_fever\", and \"search_trends_bruise\" columns, which are [Google Trends](https://trends.google.com/trends/) data for searches related to cough, fever, and bruises. In the first section of the notebook, we will also use the \"country_code\" and \"date\" columns to compile one data point per day for a particular country." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Nf__tMR-tNvF" + }, + "source": [ + "### Costs\n", + "\n", + "This tutorial uses billable components of Google Cloud:\n", + "\n", + "* BigQuery (compute)\n", + "\n", + "Learn about [BigQuery compute pricing](https://cloud.google.com/bigquery/pricing#analysis_pricing_models),\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": "7_rsbkCktNvG" + }, + "source": [ + "## Before you begin\n", + "\n", + "### 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 free 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, you need to install the [Cloud SDK](https://cloud.google.com/sdk)." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "XZKC6iMFxmMG" + }, + "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": 17, + "metadata": { + "id": "4aooKMmnxrWF" + }, + "outputs": [], + "source": [ + "PROJECT_ID = \"\" # @param {type:\"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "pv5A8Tm-yC1U" + }, + "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": 18, + "metadata": { + "id": "bk03Rt_HyGx-" + }, + "outputs": [], + "source": [ + "REGION = \"US\" # @param {type: \"string\"}" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "B9RWxD1btNvK" + }, + "source": [ + "Now we are ready to use BigQuery DataFrames!" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "wJ0gXezj2w1t" + }, + "source": [ + "## Visualization #1: Cases over time in the US" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "xckgWno6ouHY" + }, + "source": [ + "### Set up project and filter data" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "-uiY0hh4tNvK" + }, + "source": [ + "First, let's do project setup. We use options to tell BigQuery DataFrames what project and what region to use for our cloud computing." + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "metadata": { + "id": "R7STCS8xB5d2" + }, + "outputs": [], + "source": [ + "import bigframes.pandas as bpd\n", + "\n", + "# 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", + "# Improves performance by avoiding generating total row ordering\n", + "bpd.options.bigquery.ordering_mode = \"partial\"" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "v6FGschEowht" + }, + "source": [ + "Next, we read the data from a publicly available BigQuery dataset. This will take ~1 minute." + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "metadata": { + "id": "zDSwoBo1CU3G" + }, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/tbergeron/src/bigframes/venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py:3579: UserWarning: Reading cached table from 2025-03-20 20:22:07.633084+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", + " exec(code_obj, self.user_global_ns, self.user_ns)\n" + ] + } + ], + "source": [ + "all_data = bpd.read_gbq(\"bigquery-public-data.covid19_open_data.covid19_open_data\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "9qV2y3iHp13y" + }, + "source": [ + "Using pandas syntax, we will select from our all_data input dataframe only those rows where the country_code is US. This is called row filtering." + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "metadata": { + "id": "UjMT_qhjf8Fu" + }, + "outputs": [], + "source": [ + "usa_data = all_data[all_data[\"country_code\"] == \"US\"]" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "IYCUayWkwq8c" + }, + "source": [ + "We're only concerned with the date and the total number of confirmed cases for now, so select just those two columns as well." + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": { + "id": "IaoUf57ZwrJ8" + }, + "outputs": [], + "source": [ + "usa_data = usa_data[[\"date\", \"new_confirmed\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "94oqNRnDvGkr" + }, + "source": [ + "### Sum data" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "TNCQWZW83U0b" + }, + "source": [ + "`usa_data.groupby(\"date\")` will give us a groupby object that lets us perform operations on groups of rows with the same date. We call sum on that object to get the sum for each day. This process might be familiar to pandas users." + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "metadata": { + "id": "tYDoaKgJChiq" + }, + "outputs": [], + "source": [ + "# numeric_only = True because we don't want to sum dates\n", + "new_cases_usa = usa_data.groupby(\"date\").sum(numeric_only = True)" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "3jcwFPgK5BLh" + }, + "source": [ + "### Line graph" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "8GvJAgnH5Nzi" + }, + "source": [ + "BigQuery DataFrames implements some plotting methods with the matplotlib backend. Use `DataFrame.plot.line()` to draw a simple line graph." + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "metadata": { + "id": "gFbCgfFC2gHw" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job e8946d0f-20f1-49ae-9af5-5136f45e792d is DONE. 372.9 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 24, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "new_cases_usa.plot.line(\n", + " rot=45,\n", + " ylabel=\"New Cases\",\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "sM5-HFDx70RG" + }, + "source": [ + "## Visualization #2: Symptom-related searches compared to new cases" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "se1b6Vf4XB9_" + }, + "source": [ + "### Filter data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Wl2o-NYMoygb" + }, + "source": [ + "We're curious if searches for symptoms like \"cough\" and \"fever\" went up in the same times and places that new COVID-19 cases occured, compared to non-symptoms like \"bruise.\" Let's plot searches vs. new cases to see if it looks like there's a correlation." + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "olfnCzyg8jYi" + }, + "source": [ + "First, we select the new cases column and the search trends we're interested in." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "metadata": { + "id": "LqqHzjty8jk0" + }, + "outputs": [], + "source": [ + "symptom_data = all_data[[\"new_confirmed\", \"search_trends_cough\", \"search_trends_fever\", \"search_trends_bruise\"]]" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "b3DlJX-k9SPk" + }, + "source": [ + "Not all rows have data for all of these columns, so let's select only the rows that do." + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "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", + "\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." + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "metadata": { + "id": "x95ZgBkyDMP4" + }, + "outputs": [], + "source": [ + "bpd.options.sampling.enable_downsampling = True # enable downsampling\n", + "bpd.options.sampling.max_download_size = 5 # download only 5 mb of data" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "C6sCXkrQPJC_" + }, + "source": [ + "Download the data and note the message letting us know that downsampling is being used." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "metadata": { + "id": "V0OK02D7PJSL" + }, + "outputs": [ + { + "data": { + "text/html": [ + "Query job 5b76ac5f-2de7-49a6-88e8-0ba5ea3df68f is DONE. 129.5 MB processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "local_symptom_data = symptom_data.to_pandas(sampling_method=\"uniform\")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "T9Hub_EAXWvY" + }, + "source": [ + "### Graph with lines of best fit" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": { + "id": "hoQ9TPgUPJnN" + }, + "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." + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "metadata": { + "id": "EG7qM3R18bOb" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 29, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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)" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "metadata": { + "id": "5nVy61rEGaM4" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 30, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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691Mdrsd//nPfzB69Gjk5OR0+RHxwAMPoLW1NZnjJeqTg774EnvjPY+IiFIvoa3Zc+fOjatL9v33389EYUovQkvueURElHIJBTPxEoI7Qii9eINxVgCO8zwiIkq9fg1miNKNLxRfgB3veURElHoMZmhQifcFz18MIqLMwfdsGlSUOF/x8Z5HRESpx7dsGlTMRimp5xERUer1azBz8sknw2plJVVKH23++BJ74z2PiIhSL6Fg5osvvsDmzZujn7/66qtYsGABbr/9dgSDwejxN998E6WlpX0fJVGSCCm+GZd4zyMiotRLKJi55pprsGPHDgDArl27cOGFF8Jms+Gll17CLbfcktQBEiWTLMW3Syne84iIKPUSCmZ27NiB4447DgDw0ksvYc6cOXjuuefwzDPP4B//+Ecyx0eUVKoaX5AS73lERJR6CQUzQghoml4h9d///jfOOussAMDw4cPR1NSUvNERJZkrGF+QEu95RESUegkFM9OnT8evfvUr/PWvf8WaNWtw9tlnAwB2796N4uLipA6QiIiIqCcJBTPLli3DF198geuuuw533HEHxo4dCwD4+9//jtmzZyd1gEREREQ9OXK3yC5Mnjw5ZjdTxIMPPghFUfo8KCIiIqJ4JRTMdMdisSTz4YiIiIiOKO5gJi8vD1KctTdaWloSHhARERFRb8QdzCxbtiz6383NzfjVr36FM844A7NmzQIArFu3Du+88w7uuuuupA+SKFkkAPHsU2LJPCKizCEJIXq9B/X888/Ht7/9bVx33XUxx//whz/g3//+N1555ZVkja/PXC4XcnJy4HQ64XA4Uj0cSrGxt76BcBznGQBUP3B2fw+HiIi60Zv7d0K7md555x3Mnz//sOPz58/Hv//970QekuioiCeQ6c15RESUegkFMwUFBXj11VcPO/7qq6+ioKCgz4MiIiIiildCu5mWLFmCK6+8EqtXr8aMGTMAAJ988gnefvttPPnkk0kdIBEREVFPEgpmLrvsMlRWVuL3v/89/vnPfwIAKisr8eGHH0aDGyIiIqKjIeE6MzNmzMCzzz6bzLEQERER9VrCwYymaaiurkZDQ0O06WTEnDlz+jwwIiIiongkFMx8/PHHuPjii7Fnzx503tktSRJUVU3K4IiIiIiOJKFg5ic/+QmmT5+ON954A6WlpXFXBiYiIiJKtoSCmZ07d+Lvf/97tFs2ERERUaokVGdmxowZqK6uTvZYiIiIiHotoZmZ66+/HjfffDPq6uowadIkGI3GmK9Pnjw5KYMjIiIiOpKEgpnzzz8fAPDjH/84ekySJAghmABMRERER1VCwczu3buTPQ4iIiKihCQUzIwcOTLZ4yAiIiJKSEIJwADw17/+FSeeeCLKysqwZ88eAMCyZcu6bEBJRERE1F8SCmaWL1+OxYsX46yzzkJra2s0RyY3NxfLli1L5viIiIiIepRQMPPoo4/iySefxB133AFFUaLHp0+fjs2bNydtcERERERHklAws3v3bkydOvWw42azGW1tbX0eFFE60DRx5JOIiCjlEgpmRo8ejY0bNx52/O2330ZlZWXcj7N06VJ861vfQnZ2NoqKirBgwQJs37495hy/349FixahoKAAdrsd559/Purr6xMZNlGvbDngSvUQiIgoDgkFM4sXL8aiRYvwwgsvQAiBTz/9FPfddx9uu+023HLLLXE/zpo1a7Bo0SJ8/PHHeO+99xAKhXD66afHzO7cdNNNeP311/HSSy9hzZo1OHDgAM4777xEhk3UKy3eYKqHQEREcZBE57bXcXr22Wdx7733oqamBgBQVlaGJUuW4Iorrkh4MI2NjSgqKsKaNWswZ84cOJ1ODBkyBM899xy+973vAQC2bduGyspKrFu3DjNnzjziY7pcLuTk5MDpdMLhcCQ8NhoYRt36Rtznvn7dSZg0LKcfR0NERN3pzf2713VmwuEwnnvuOZxxxhm45JJL4PV64fF4UFRUlPCAI5xOJwAgPz8fALB+/XqEQiHMmzcvek5FRQVGjBgRdzBDlKiJZQx+iYgyQa+DGYPBgJ/85CeoqqoCANhsNthstj4PRNM03HjjjTjxxBNx7LHHAgDq6upgMpmQm5sbc25xcTHq6uq6fJxAIIBAIBD93OVi3gMlRpalVA+BiIjikFDOzAknnIANGzYkdSCLFi3CV199hZUrV/bpcZYuXYqcnJzox/Dhw5M0QiIiIkpHCbUz+OlPf4qbb74Z+/fvx7Rp05CVlRXz9d52zb7uuuvwr3/9C++//z6GDRsWPV5SUoJgMIjW1taY2Zn6+nqUlJR0+Vi33XYbFi9eHP3c5XIxoCEiIhrAEgpmLrzwQgDADTfcED2WSNdsIQSuv/56vPzyy1i9ejVGjx4d8/Vp06bBaDRi1apV0U7d27dvx969ezFr1qwuH9NsNsNsNifytIhiaJrgUhMRUQZIadfsRYsW4bnnnsOrr76K7OzsaB5MTk4OrFYrcnJycMUVV2Dx4sXIz8+Hw+HA9ddfj1mzZjH5l/rdlgMu7mYiIsoACQUze/bswezZs2EwxH57OBzG2rVr4+6qvXz5cgDAKaecEnN8xYoVuOyyywAADz/8MGRZxvnnn49AIIAzzjgDjz/+eCLDJuoV1pkhIsoMCdWZURQFtbW1h23Hbm5uRlFRUdzLTEcD68xQR6wzQ0SUGXpz/05oN1MkN6az5ubmw5KBiTJVZUl2qodARERx6NUyU6SNgCRJuOyyy2ISbVVVxZdffonZs2cnd4REKVJV5+bMDBFRBuhVMJOTo7+xCyGQnZ0Nq9Ua/ZrJZMLMmTNx1VVXJXeERCnS7Akc+SQiIkq5XgUzK1asAACMGjUKP//5z4+4pPTRRx9h+vTp3CpNGam5LTYBWNMEthxwocUbRL7NhIllDm7dJiJKAwntZrrnnnviOu/MM8/Exo0bMWbMmER+DFFKufyh6H+vrW7C8jU1qGnwIKQKGBUJ5UV2XDu3HLPHFqZwlERElFACcLwSbMhNlFbWVjfh9pc3o6rWhSyzAUXZZmSZDaiqdeP2lzdjbXVTqodIRDSo9WswQ5TJHBYjNE1g+ZoaeAJhlDgssBgVyLIEi1FBicMMT0DF8jU10DQG7kREqcJghqgb+XYTthxwoabBgzyb6bByBJIkIddmRE2DB1sOsDs7EVGqMJgh6kZhlhkt3iBCqoBJ6fpXxazICGmC1YKJiFKoX4OZrgrrEWWKiWUO5NtMMCoSgqrW5TkBVYNRlpBvMx3l0RERUQQTgIm6IcsSJpY5UF5kx0Fv6LDXsxACrd4QyovsmFjGVhlERKnSr8GM2+3mtmzKWJomIMsSrp1bDrtZQZ0rAF9IhaYJ+EIq6lwB2M0Krp1bznozREQplFAwU19fjx/+8IcoKyuDwWCAoigxH0QDQSSpd/bYQty/cBIqS7PhDYTR4AnAGwijsjQb9y+cxDozREQpllDRvMsuuwx79+7FXXfdhdLSUubG0IDUMal39thCzBxTwArARERpKKFg5sMPP8QHH3yA4447LsnDIUofnZN6ZVli40kiojSU0DLT8OHDmdxLAx6TeomIMkNCwcyyZctw66234uuvv07ycIjSx8e7mlM9BCIiikPcy0x5eXkxuTFtbW0oLy+HzWaD0WiMObelpSV5IyRKkeVrajBzTAHzYoiI0lzcwcyyZcv6cRhE6SfSpoB5MkRE6S3uYObSSy/tz3EQpZ1gWGObAiKiDJBQzsybb76Jd95557Dj7777Lt56660+D4ooHYQ1jW0KiIgyQELBzK233gpVVQ87rmkabr311j4PiigdmA0ydzQREWWAhIKZnTt34phjjjnseEVFBaqrq/s8KKJ0MLbIzuRfIqIMkFAwk5OTg127dh12vLq6GllZWX0eFFE6mFDCWRkiokyQUDBz7rnn4sYbb0RNTU30WHV1NW6++WZ897vfTdrgiFJpe6071UMgIqI4JBTM/OY3v0FWVhYqKiowevRojB49GpWVlSgoKMBDDz2U7DESpURVnROaxkrXRETpLqHeTDk5OVi7di3ee+89bNq0CVarFZMnT8acOXOSPT6ilHH5wqwzQ0SUAXodzIRCIVitVmzcuBGnn346Tj/99P4YF1HKaQKsM0NElAF6vcxkNBoxYsSILrdmEw0o0uGds4mIKP0klDNzxx134Pbbb2cPJhrQjLLEOjNERBkgoZyZP/zhD6iurkZZWRlGjhx52HbsL774IimDI0qlYfkW1pkhIsoACQUzCxYsSPIwiNLPt0YWpHoIREQUh4SCmXvuuSfZ4yBKO6MLrKkeAhERxSGhnBmiweDdqsZUD4GIiOKQ0MyMqqp4+OGH8eKLL2Lv3r0IBmO3rzIxmAYCpz+U6iEQEVEcEpqZWbJkCX73u9/hggsugNPpxOLFi3HeeedBlmXce++9SR4iUWo4zAnF+kREdJQlFMw8++yzePLJJ3HzzTfDYDDgoosuwlNPPYW7774bH3/8cbLHSJQSx7HyLxFRRkgomKmrq8OkSZMAAHa7HU6nEwDwX//1X3jjjTeSNzqiFKp1+VM9BCIiikNCwcywYcNQW1sLACgvL8e7774LAPjss89gNpuTNzqiFFq7q4WNJomIOhFCwBdUcbAtiAOtPrSmQduXhIKZhQsXYtWqVQCA66+/HnfddRfGjRuHH/3oR/jxj3+c1AESpYrTF8bmb5ypHgYRUUoJIeAP6cFLrdOHr5u9qHX6cNAbhD+kQqTB33wJZTg+8MAD0f++4IILMGLECKxbtw7jxo3DOeeck7TBEaXa+r0HMWV4bqqHQUR0VPlDKvwhFb6QikBIg5YOEUsPkrJdY9asWZg1a1YyHooordS3Mm+GiAa+QFiFP6jB1x7EpHvw0lnCRfP++te/4sQTT0RZWRn27NkDAFi2bBleffXVpA2OKNVKHMwBI6KBJxBW4fSFUO/yY09zG7456ENzWwDeYDjjAhkgwWBm+fLlWLx4Mc466yy0trZCVVUAQG5uLpYtW5bM8RGllMNmSvUQiIj6LBjW4PSF0NAxePEE0BYIQx0AGx0SCmYeffRRPPnkk7jjjjugKEr0+PTp07F58+akDY4o1fLtDGaIKPOEVA0uvx687G32Yv9BL5o9AXgGSPDSWUI5M7t378bUqVMPO242m9HW1tbnQRGli1yrMdVDICI6opCqRRN2/UENYU1L9ZCOqoSCmdGjR2Pjxo0YOXJkzPG3334blZWVSRkYUTrY1diGqSPyUj0MIqIYYTWSrKsHMSF1cAUvnSUUzCxevBiLFi2C3++HEAKffvopnn/+eSxduhRPPfVUssdIlDLfHPSmeghERFA1AV9IhS+oMnjpQkLBzJVXXgmr1Yo777wTXq8XF198MYYOHYpHHnkEF154YbLHSJQydU5uzSaio0/VRHTZyBdk8HIkCQUzPp8PCxcuxCWXXAKv14uvvvoKH330EYYNG5bs8RGlGN9AiKj/aZqAP6wHLr6QimCY7z29kVAwc+655+K8887DT37yEwSDQXz3u9+F0WhEU1MTfve73+Haa69N9jiJUmLNzhasrW7C7LGFqR4KEQ0gHYMXf1hDIKSmeki91uwJoKrWja+b2wBIuPucY1I2loSCmS+++AIPP/wwAODvf/87iouLsWHDBvzjH//A3XffzWCGBoy2QBi3v7wZ9y+cxICGiBKm9zfSk3YjMy8ig4rT+YIqdtS7UVXnxrZaF6pq3Wj0BKJftxoV3H5WBQxKwrV4+yShYMbr9SI7OxsA8O677+K8886DLMuYOXNmtBow0UAwNNeCencQy9fUYOaYAsiylOohEVEGiAQv0f5GGRS8qJrA101t0cBlW50++9JTeRpfSMWOeg+OKXMcvYF2kFAwM3bsWLzyyitYuHAh3nnnHdx0000AgIaGBjgcqXkiRP3BH9aQazOipsGDLQdcmDQsJ9VDIqI0JIRAIKy1LxvpW6YzIXgRQqDBrS8XbavTZ1x21rvhjzNnZ4jdjMnDcvCt0fnIz0pdkdGEgpm7774bF198MW666Sacdtpp0SaT7777bpfF9IgylTeoIt9mglMTaPEGUz0cIkojmdZZGgA8/jC21emzLZEA5qA3FNf32kwKJpRko6IkG5UlDlSUZqPQbkaezYS8FAYyQILBzPe+9z2cdNJJqK2txZQpU6LHTzvtNCxcuDBpgyNKNV9QRcCswShLyGefJqJBLdM6S4dUDbsa21BVGwleXNh30BfX9yqyhDGFWago1QOXytJsDM+3QZbSc6k9oWAGAEpKSlBSUhJz7IQTTujzgIjSiappaPWGUFmajYkpWgsmotQIhA9V2PWH1LTuaSSEwIFWf3SpaFudCzsbPAip8Y25NMeCipJsVJQ6UFmSjXFFdpiNypG/MU0kHMwQDQa+kIYCu4Jr55Yz+ZdogAuGtfYlI33pKJ2DF6c3hG31kcBFT9R1+cNxfW+2xaAHLiXZqCx1oKIkG7kZPvPMYIaoB2FN4MJvDee2bKIBKBTpbxTUZ2DStTljIKSiutETDVyqal2ojbM6uVGRMLbIjooSR3vwko2huVZIabpclCgGM0RH8NSHuzF5WC4DGqIMlwmdpTUhsK/F2z7b4kZVnQs1jW1xzxINz7OiovRQ4FI+xA5jimq/HE0MZoiOwOkNsc4MUQbKhM7SzZ5AdLZlW50b2+vcaAvGVw04z2bUZ1xKs1FZko0JJdnIthj7ecTpicEM0RGENIGtB5ysM0OU5tK9s3TnKrrb6txocAeO/I0AzAYZ44v15aLK0mxUlDhQ7DAPuOWiRDGYIYqD0xdCU1t8bzpEdHSkc2dpVRP4urkturNoW+2Rq+hGSABGFWZFl4oqShwYXZgFJY1mho2KDLNBhskgw2ZKfSiR+hEQZYCwBny6uwXfnlCU6qEQDVrp2llaCIFGd+BQ36I6N3bUu+EPxTe+Qrspuquoon25KB0CBECvN2MyyDApeuASCWLSbUYoPa4WUQb4x+f7cWJ5AXKsJrR4g8i3mTCxzME8GqJ+kq6dpT2BMHbU6cm5epKuGy1t8VUItxr1KrqRGZeKkmwMyTb384jj03G2JRLApKpxZG8xmCGKU6s/iOue3wCLQY4m6JXmWHHXWZU4ecKQFI+OKPOlY2fpkKphd1PHKrpu7G3xxvW9sgSMGWJHZXsxuoqSbIzIt6V8uUiWpEMBS3vQko6zLb3BYIYoTsGwQDAcggQg8vbq9rtx6TOf4vRjinHRjJGcrSHqhY7NGdOhs7QQAgec/uhS0bZaN3Y2uBOuoju2yA5LiqvoZvJsS28wmCHqJQE9QS/y35oA3t5Sj/d3NiLXakJ5kR3Xzi1nXRqiTiLBS7TWS4o7Syejim6k4eKEkmzkpbCKblezLSZFHjR/WDGYIUoSX1BDWY6Cqlo3bn95M+5fOIkBDQ16kb5GkVovqWrOGAxr2NngjukWfaA1/iq65UPs0STdVFfRNSpyTFJuJDF3MGMwQ5SArt6OBQB/WEOJw4w6V4CF9mhQSofO0poQ2N/i65Cg27squsPyrO07ixzRKromw9EPFiKzLUZFhtk4+GZbeoPBDFESeYMqDLIEq1FGTYOHhfZowEuHztItbcFogu62Whe21bvRFohv51Ou1dheQbd9uag4Gw7r0a+iy9mWvklpMPP+++/jwQcfxPr161FbW4uXX34ZCxYsiH5dCIF77rkHTz75JFpbW3HiiSdi+fLlGDduXOoGTdQDpy8Ely8EQECSJHxY3dhtMKNpAlsOuLjNmzJKqjtL+0J6Fd3IjMu22t5V0R1XdGi5qKI0GyUOy1FdLpKk2LotZgNnW5IhpcFMW1sbpkyZgh//+Mc477zzDvv6b37zG/z+97/Hn//8Z4wePRp33XUXzjjjDGzduhUWiyUFIybqmUGWIEmAqukVQP+ybg+mdNGkcm11E5avqUFNgwchVehr8kwcpjSUys7SqiawJ1pFVw9evm6Kv4ruyAJbhzwXB0YV2I7qTh5jh0JzHQMYSr6UBjNnnnkmzjzzzC6/JoTAsmXLcOedd+Lcc88FAPzlL39BcXExXnnlFVx44YVHc6hEcVE1DRD6DieTQUYgpGL5mhpMH5GHN76qwzetXngDKt7YXIu2YBh5NhNMioygqjFxmNJCpDnj0e4sHamiG9N0sRdVdAvsJlRG+xYd3Sq6nG1JvbTNmdm9ezfq6uowb9686LGcnBzMmDED69at6zaYCQQCCAQOTTm6XK5+HytRRMfq6v6wBgFg/dctmHbfv+ENhhEpVyEBKMo2R2tQWGQFJQ6ZicN01KWqs3TnKrrb6txozoAqugZZPnwLNGdbUi5tg5m6ujoAQHFxcczx4uLi6Ne6snTpUixZsqRfx0YU0bGAXlcC0ehGg0EGjBIQ0idvUO8OQJKk6JuwJEnItRmZOEz9KhWdpcOqhl1NsU0X97Z4e/zdiZAlYEyhvT1JVy9IdzSq6HY122JU5JRX76WupW0wk6jbbrsNixcvjn7ucrkwfPjwFI6IBrKughmpwxc6fi2sHSq2F9HoCaDQboomIJoVGU5NoMUb31+oREdytDtLCyFQ6/THLBftbPDE3RSyxGGJ6RY9rrj/q+hytiXzpW0wU1JSAgCor69HaWlp9Hh9fT2OO+64br/PbDbDbE6Ppl008HV+e+4ukIkQnc5VNYFWXyhaOTSgajDKEnKtRmze7+ROJ+q1o91Z2uUL6Vui6w5V0nX6QnF9r91siO4qqixxYEJJNvKz+q+KriRJMCr6jItZUaLBC2dbMl/aBjOjR49GSUkJVq1aFQ1eXC4XPvnkE1x77bWpHRxRNwQAqZtApqtzAUT/UhZCoNUbQmmOGQ++sw27Gtu404mOKBK8RBo09mdn6WBYQ3WDJyZw+abVF9f3GhUp2nQxssNoaJ4Vcj9ti+4822JUJJiUzG6mSN1LaTDj8XhQXV0d/Xz37t3YuHEj8vPzMWLECNx444341a9+hXHjxkW3ZpeVlcXUoiFKNz0FMl0tSymyBF9IRas3BIMMNLgDqHX6udOJunS0OktrQmD/QV9M08WaRg/CaVZFl7MtBKQ4mPn888/x7W9/O/p5JNfl0ksvxTPPPINbbrkFbW1tuPrqq9Ha2oqTTjoJb7/9NmvMUFpTZAmqJroMXLrKrwmFBbwijIqSbDh9QdQ6/TGFvLjTaXA7Wp2lM6GKriJLh1XJ5WwLAYAkUtmy9ChwuVzIycmB0+mEw+FI9XAoRSLVds/5w4epHkqMbIsBPz2lHCeNHQJNCFz7t/XIMhu6THj0hVR4A2E88cPp3Ok0gB2NztL+kIqd9R5URZeLXKh3xVdF1xStonsoeEl2FV3OthDQu/t32ubMECVLx2q7R5tRkSCEQFc5mEXZJmhCwtqaZlwzpxwfVDchpAqYuqlQyp1OA1d/dpZWNYG9Ld7orEtVrQu7e1FFd0SBLVpBt6IkG2MKs5JaRZezLZQMDGZoQFtb3YTbX94MTyAc3TF0NIVUfbnJKOtLTKoGKBIwNN8Kh8UEX0iN1pXJtRohIHDQG4TNZIDFqN8wIiXkw5qAQQLye3ge7PeUGfqzs3SjO9ChEJ0L2+s88MWZFFyQZTpsuSjLnJzbhCRJMMiSXh23Q9ByNNsL0MDFYIYGLE0TWL6mBp5A+Kg3k+tIQC+Up8gSsswyhmRbYG+/QURmWz6sbsJH1U1w+8MIqhoMshSdUlc1ASH0hEyH1Qinr+uZGfZ7Sl/91Vm6LRDG9o5NF+vcaPb0poquXa+g2x7AJKuKLmdb6GhjMEMD1pYDLtQ0eJBnM6XFm6gEgUK7ORrIAHpdGU3T8Jd1XyOkaii0m9Hg8iOsCoTaex8oMgAByJK+ZHXnK18dtqup8wwUd0GlVjCs6dulg8nrLB2potuxGN3e5t5X0Y0sGSWriq5RkTnbQinHYIYGrBZvsMcclKNN04AmTxB2iwES9MDkYFsQqtBrzURmj0wGCXtbfNEboKYBNpOCIocFNpOMb1r9uO/NKty/cBImDdUTgbuagcqUXVADYWks2Z2lO1bRjdR06U0V3WKHOdqzqLI0G+OKs2HtYxVdRZYOdX9uD1rMBs62UHpgMEMDVr7NBKMiIahqsMj9Ww49LhLgD4XhDaiQZQmt3hBMBhnBsBYze6RIMmQJkBUJkVSKkhwLNAHsafbBHwqjqjaEK/78GSpLHThjYkm3M1Dp3u/paC+NJStwSnZn6b5U0c0yK9FaLsmqosvZFso0DGZowJpY5kB5kR1VtW6UOFL7RiwDMBtkBMIamtuCMCkyih1mTB+Zhzc318XMHoU1DUIABkUCBBDWBDyBMA62haAJEa2YalJkVNW6saPODX97QNSVrnZBpcNsyNFeGutL4BRWNfjba730tTljMKyhptFzqOlinRv7D8ZXRdcg62OuKG7vXVTqwLA+VNGVI80UOdtCGY7BDA1Ysizh2rnluP3lzdjb4kNI7b8y70diMsoocZjR6A7CapThD6moa/XhDZcfbn8YJoMc/WvaIMuQJERnZSQJcPnC0ISAoX22RoKAzWRAvlHG/oM++EMqAqoKq3z4r3Sk31NkF1Q6JAp3l5zdX0tjvQ2cIp2l/X1ozqgJgR11Huxq8qDJHcRBbxDb6t2oaYi/iu7QXGt7w0U9z6UvVXQjsy0dl4qMnG2hAYLBDA1os8cW4pIZI/Db93YgEOrfhns9MSky9h70QdWAtpAKRdK3qOZYjdCEQK3TB6MiwW4xQEBAkSUEwxokCTApCkKqCkWWozM1VqMMi1H/C7rAbsK+Fh+aPEEMy1Vi/qqO9HuqLM3GxDJH2iQK95ScneylsXgCp8dXV2PS0BwEVH3HUaLNGQ96g9hW68Z/tjfg090tcPvDcSXoAkCO1YjK9u3QlaX6clFOAlV0u5ptMSkyZFlKixk5ov7AYIYGNE0TeH9nE+xmA/KsEmrjrHKabC5/GIBehMykSAAk+MMaQp4g8rNMaHQHsO+gFyZFRkjVoGntHbkFYDQBwTAgoBffUyQJQ7I73JQNCqwmBWaDfmPOtRlhVmQEVA2t3hDsZgXXzi0HkD6JwkdKzu5tgcCebtJdBU5CHNrunmVWsKPOjY+qmzG+xB73c+hYRTeyNbq3VXQjMy6VCVbRjc6yxDHbkg4zckT9hcEMDWgdb2T6sk1qgpmOhJAgSXoejapp8PjDyLMa0ewNwS80yJIERQaMsoSwpsETUPVu3BpgNcbWqQH0ZaQsk4Kffnss3tlSh5oGD5yagFGWUFmaHb1Zbd7vRE2DB7k2Y3THjUGWYTHJRz1R+EjJ2Z2XxnpypJt0izeIYFhDjkVCWNWgCcS0BzDKElxCwOnvPnCKVNHt2HRxV5Mnriq6gB7Amo0KVFXDiIIsLLtgCkyG+JPSI7MtRkWG2Rg72xKPdJmRI+ovDGZoQOs4A5AOOY0CQLBT/oU3qCLQXqG1ONsMi1HRgwyjDNHeuTgs9OBkaK4VsnzoL++Oy0gXnzACF58wotsZihZvEG1BFU5fCEFVTzKWJMBsUDAk2wybUTlq7RI6J2f3tDTWk+5u0lsPuHDrP7/E7WdVwqQokCXAG1Jh7iLfJKgKGCUJOZZDgVNfqujK7dc0y6TAYpRhNijRei7+sIYWTwBfN/m6nQXqzWxLPLpaZovMTGWZFLR6Q3h8dXXabt0nigeDGRrQOs4ApGtLVQEg3D42py8Mq8kAq0n/q12SJAxxWHCwLQCTQUG9O9jtMlLkRtTdrMq+Fi88gTAgBAyKDEnWk4z9IRXfHPRhSLY57tmQvuqYnN3T0lhPN9eON+nibDMg6TkhsiQhP8uIJk8QT36wGw8snIThBVnY1ehBod0ECR0CJwi4fEEUOSz4fE8L/vbJHmyrc6Epziq6FqOMCcV6gm5FqQPBsIrlq2tQaDd3ucPIpEhwt88CyZIEY4egxWzo3WxLvDovs3kCYTS6/e3dt/VzPt19EM99uhc/mDkyqT+b6GhhMEMDWscZgCxT+u/cCLQHFkPzrDEtD2RZxg9mjsR7W+uxr8ULTQhYjUrMMlJPNE3g7a9qIUt6Lo6etaMvd0kKEApraHD7MWN0wRFnQ5Jl9thC3L9wUnSJqKulse4IIbBhbyt21rlhNxsQ0teOol+XICHbYsS+5jbUNLbh4hOG43fv7UCjOwCL0QBV0+Btr86rCaDV58GO+p4bkcoSMLowK6YY3ciCrJgqujvqPO15TwJmw6HjkqRf61BYwKzIGF+UjVGFWX24evHrODvpCYTxzUEfVCFgkPUxaRAIhTU8+n87MaYwKyOWm5jITJ0xmKEBreMMQGucRchSyWDQZxca3QFkmRVIkKItD97bWo96p14ZWJaAIocF18wZE9fNZ8sBF3Y1tqEoW98eHtIEDLIe1ERCAE0A848tOao3hdljCzFzTEFcN6bOnaWrG90IqBrssgFdbRkyykCrquH9nQ3tuTQynP4wWn3huMbWsYpuRWk2xsdRRXdcsR0jC7NQ09AGm8MMWdbngSJLO03+MCpLszFleG5cY0iGyOxkIKyi0e2H2r5kGVnakwSgyAKBsJbWlaIjmMhMXWEwQwNeZAbg8dU1+LC6KdXD6VFY1bdl+0Nh+IMaLEYZDa4AgqqK/Qe9yLOZkJ+l54XsP+jrsk9TVyJ/nRdlm2EyKDHLDJIEPU9HkTE833aUnukhsix1uTR2pM7SORYTjLIUnQVRNaEHO2E92InMujz36b4jjkECYLcYMGN0PuaOH4LKUscRq+gaZPnwLdAGGTfNG4/bX96MRs+hJUF/WI17+SzZIrOTm/c7EQhr7TMy7bu6IKBqAhajAYV2U9pWio5gIjN1h8EMDQqRGYAxt7+Z6qH0SBOA1t5g8qA3AEBCUFVhUiTkWIwItef+WNqL8MW7nbpj7pDdbECWKStmR5OAgC+o9ilfpq9T/73pLB0MawhrKsxGBbVOn96ZXI0vKUqRJMiyvkNIvxb6bIs7EEZVrQtnHlsSE8hIUmwH6Ejhue6aNPZl+aw/RGYnb3phI5x+AaMMiPaijGp7jtGQbDPMigKnFu5VAvjRXO452oUWKbMwmCFKUwe9IYwuzEJI1RBWBfYe9HbYgaRv0Y53O3VXu4f0JGMFQgjUuQJx7R7qzoc7G/HQuzuwt7kNmtC3kI8t7vnmHW9n6ciOLn1LtF7+v6bRE3fwUpZrQWV776LxxdlYsfZrfN3UdlgysNkoo8kTwguf78Pc8UWwmJRoANNbvVk+Oxpmjy3E9aeNwy//tRWqpkFTD83IDcnWO7n7QmqvEsCPtNyT7EDnaBZapMzDYIYGjS0HXKkewhHp+RWAUZFgNRoQ1gTc/jBkuUObAwC+kIZvDvpQmmNBKI7t1MnYPdSdJ9+vwW/f29FesViCDCAYlrFpnzNm6j/aWfoIzRlbvcFo36JI00VPIL48Fwn6tSuwm3H2pBKcPbksWmW5ur4NWw448XVTG7LNBsiSfj1lSYpe9wK7hP0tPtQ6/X2+IXa3fJYqF58wAm9/VYevDjiRYzHAqCh6jaH2Du7xbocHjrzcc8mMEXh/Z1PceS3xBD7JLrRIAwuDGRo0MuFNLjLXUJZrhSJL2NPsBdC+NBJJ2ISe3BrSBBrcAeRaDdG/pnu6KcweW4hfLTj20AwKAKtB7tPyx4c7G6OtIowGCTIkCOhF70KqimYh8MiqnSjLtR6W8wLoSb3VDR5Utc+4VNW5Uef0x/WzjYqEcUX6rqIJJdmwGgwwGoBcqxlji7Oi12vTvlY8/9le7GnywhdS4faHEQxrKFLkmOKDwMC+IcqyhJ+eUt4ehKjItSkQGuBXe5fPc6Tlnn0HvfjtezuQZVKQn2U+Yl5LvAm9kaVSlz8ERZYOFXxsn13rTaFFSo502lXGYIYGjUx4k4vMFCiSDE3TkzONiqx3y4aIvnFLktS+A0VFcc6hvks93RTWVjfhifd3od7l17cyA8i2mnD1SbE7ouJ9g9I0gYfe1WdkjIoeyAB6sKVIQFjTCwTuatAr5pYXZelVdNuXi6rq3NjVGH8V3ZH5NlSUZqOifclodGFWTDE5SZJgVPT8FrOiwGSQsf7rFjyyamd0BiHLbIA3GI7W1um4BR4Y+DfEZOTz9LTcAwkIhgWCYQ1Dc6ywtO/+6i6vpTcJvU5fEN6QCpcvBAl6cBYp+Bgp/teXpVLqnXTbVcZghgaNdHqTMykS7GYFB72xjQgloL2ZpBZtdphvN6HFE2zf6YRoR21VFZAAnDGxGB/vaj7itP+zn+zFQW8QwbBASFUhBLCz3o0r/voZThlfhNljCyFLwLtb6rGrMfYN6po5Y5BjNcUEOF/ud2Jvc5s+nSTF7o6WJAmypNcvcQqBB9/dhlqnH95gfFV0FVmCSdETk7NMCm76znjMLj/0BtnVTiKjIsXcXDVN4IkPdsVWvoW+c8cXDEMVGhrdfmSZsqJbpwfDDbGv+Tw9Lff4g/qMnCRJUDvNxHXOa5lY5og7offjXc2485WvoGn6br/ILJ8vGMa+FhVZZgV5NhPOmFiCD6qbUj5LMNCl464yBjM0KERmG9JFSBVo7SKQ0WcpBDz+MMKagEGWYTcpsOZZ0egOIBBWITQ9oDEZFNhMCmaXF+LBd7aj1RtCjtWgJwnLHW8Kfvx+VTXCmopQWEAAMLTP9qiqQDAs8O7Wery3tR4CgCIDJQ4rirJNCKoaNu1z4sq/fA6bUWnvGyVhRIENxw3PQ1gTeuE1TQ+sNOgJux1nW4KqQE1jW7fXwmKUYZD1Bpt5NiOsRiW6fVhAoMkTwuubanH2pDJYjPqMS3c7iTrqagZBgr5z55uDGlRNgz+kF8+TZSllW6dToS/5PD311Qpr+m47WdIDzs46LuPFm9C7+RtnNOgZkW9DW1CNlhaQJD2wUTUBu9mAx/9TnRazBANZuu4qYzBDA17H6dBU05tH6gFF59WVjp83tQVhMcgYmmdBqy+MEocZtgIrnN4wQqq+rOMLqTimzIEv97fis69b9KaVgXBMvyUAcPvDCIQPJdvKkr4lN9xpfSfymaoB9S4/jIqe/+ILhhDS9NmgYXlWhMKant9S60IwHGnc2PkZdK2rKrrBkMC9r38Fa5YJVpMSLTInSXqAV2CXsK/Fi/0Hfb26AXc3g2A3GzA0z4oGlx++kIrmtiCyTPFXUx7seuqrpUj6a8ao6L3FOuu4jBdvQu/Gva0xQU/n0gKeQBgtbUHsb2/JkQ6zBANZuu4qYzBDA1rn6dBUkoAea6d0psgSvEEVigzsbfG1b9HWorMfJoOMEXlW/OH/qhFsv0nIshTtt7SvRU8e7vwzNYEuk3E7CmsCdU5f+3/rOTDBsF6oLxjW4ghbDpk81IFZ5YXRKro2kyEmt+WTr5uhCT3I6OovuUSTcnuaQbCbDVByLXB6w1h06lhMG5HHZYk49bQzzukPwWSQYegiQOm8jLflgOuIndMNkp7A3eoLtc9GSu270PTSAkLIaHQHIASQYzUcMUfnSNIpoTVdpeuuMgYzNGB1Nx2aKl3NxnSmSHoQo2oChXYzPIEwzAYZ/lAYIVVEZysMigSDLOEfG76BIknRZZmO/ZYCIX02RlEkhNtrskTybeLhDx86MVLSpeMMz5EYFeCqk8bgh7NHxXSA1juYH/q3KHVYYTLIPd7UEknKPXJnbr21wA9OGIGqOjdzLXqh+0RiB+aMK8Szn+w9YgmAI/371LZ6EQgLvLLpG2hC7y5f5/RjSLYlOuvoD2kIhDUoMmBUYl87vZ0lSLeE1nTV0x8JQOqS6BnMJIgRfPrrPB0q0rVtdgeKrE/TQwLagmF4/CE0tyf6dmhbCE0DfKoKVejJxCZFhj+swShH+gAdCpxEey+nTr0Y+8zcXkxu5ph8NHmCaHD5EVQ1yJKEYXk2/Pz08Zg7oeiIj1NZko0ihwW7Gz0otJthbe9JBRz+13xvxFNbZ864Qlz+5894A0tA50TiXKsRANDqC+HKk8fg7a9qsauxrdsdUz39+9S2etEW1ANnoyJBqPrSrCqAOpe+dX9IthkhVYMqBGxGvWZOZ/HOEqRjQmu6OvIfCalJomcwkwBG8Jmh83SoPxT/rEKqBDtUtW3yHHoDlqX22ZH23BSjJEV3jARVgWyLnkAbUrX2N5fYqCXfZkRTW98abcoSkG0xwGpUkG01IKzqu0l+dtp4TBmWg6217l4H95HfpX0tbXAHwnAHwjAbFBQ5zDAqcp+TcjvOIFTXu9EU1iADGFGQhbOOLcazn+zlDawPIonEa6ub8NC722PeE8cMseOn3x6L4fm2bl8TXc3wGCQg0D4raI4uK+k74yKv6ga3H1lmGU5fCLIkIdcWW805Ip5ZgnRNaE1X/VmAsy8YzPQSI/jM0Xk6tLuKs5mgc+X+UPvuoQiXPwxFkhAUh0+/KLKEFm/vAxlF0pelOq4sefxheIN64TmDImHysFwcNzw3od0xHX+X8rPMsJuNaHQH4A+r2N/iRa7NhGPKHH3+I2H22EJoQq+Js6/FC00I1Lv8eHzNLmiawIh8G29gfdDde+K2Ojf2H/Ti/oWTenxtdJ7h2bS3FctW7dBrF0n6HyKKJAEGWc8ZE/osY7MnhGOHOuD0hVDrDEAIkdAsQbomtKazdOs/BjCY6RVG8Jml83RoV1tFM1nHkCWkCoS6ycgJdo6EevH4ZkWG2h4EKrIUXa7SE5MlzBlXmNBrvavfJYtRQbbFAF9QRaMniOH5Nqy49FswJNAbqaO11U2485Wv2oMm/Wbr8ofg8umVZNuCakzhPN7A4pes98SOwfDm/a0Q0GcCO1IkCbJB1nfiqQL/NaUM//vdidEaS4nOEqRrQmu6S7f+YwPr3b2f9SaCp9SLTIfazQrqXAGIXu3BGZg6vmqtRhld7J6FWQGGZJtgNykIa4DNKMPW3llabZ+lsZkU2EwGvL+zCVovdmhFdPe7JEkSbGYDihxmNLj8qKpz9/qxO+p8s7UYFciyXitHr+sj0Oj2H5ZPZVbkuHpeDXaRf0erUYEnEIYvqEZ/zxJ9Txyaa4MMdFkZOrKUJEvAtBF5kGUpOktQWZoNbyCMBk8A3oCe3B3PTHnHGdyuDPSq0H0RCULnjh+CScNyUvpHPGdmeoERfObpPB06WMkSYDHI8EZ2OEl6vRuTUYGsCj3XBvoNaFi+DTaTAd5AGLub26DICsYU2hAICYQ1Te+JY9QTjhOdvThav0vdBU0GWY6+8QbCevE8q+nQzgzewOLzYXUjGj2B9mBQiqlxZDcbEvp3PGdyKZb8awuc3hBkSYsuNQGAJvQO8jk2I86ZXBo93pdZgnRNaKXe4cxMLzCCz0yzxxbiz5efgOU/mJbqoRxVEtoTKIFo/Q0JgEGG3hRSkjpsbZWifY4idWnU9hyEkKoiEBKwmhRkW4x6YTtJ6tPsxdH6XeouaLKYZJgNCjRNQLS3j4iI3MDKi+y8gfVgbXUT/rJuD1RNf50YlMhrSu975QmEE/p3NBhkLDqlHIosIRjWA2hNaO0tPvR2BotOKT9s+THRWYLOM7i+kApNE/CFVNS5AoOmKnSmYzDTC5EI/qA3dNi0NN8A05ssH+o6PVgItAcmEuALqvCG9N0gYQ3RwndK+yVRNQENeh2bSG6RQZYhA4fd7CN6e6PSNIHN+51Ys6MRmhAYM6T/f5e6C5oibQ0i5fDDmuANrBciy3fBsAqrUdGXhISeMK7XSdJQ2+pDqzeY0L/jVXPK8Yv5E5BjM0LTBEKq/u+TYzPiF/Mn4Ko55Ul9Pn1dqqLU4zJTL6TrljSKz2Bc/uu4E0lpbwYZ2Q0SCmswtEczWnvjSqtJiZahtxj1Sq6BsKrvJulADziCGJZnQ1NbAJv3O3uc1u+qnEGB3QRFRr/+LvW0hJBlUpBl1meZVFVDgycQ3ZFxzZwxyLYYsWZHY8oTG9NRZPkuP8uMsCbwzUG9QrXWob6RGtag+cMJJ4lfNaccl88ejde/rMU3rV4MzbXhnMmlfU4I706iS1WsOZYeGMz0UjpuSaP4DPblP0kCFEmvRxMpWRPqsNNJkiQMybYA0GdyQqoGWQJMBhlOfwiSLEUDjkijv30tbbjlpS97rLXU3dbdWmcAigyU5pjR7An2y+/Skf4AybOZ8KsFx8Z0BHf6gnji/V29riM1mG5qHZfvLEYJ+Vkm1Lv8h6XYGyQJz36yFxPLchL69zQYZCw8fmhyBh2H3pYYYM2x9CGJTCiL2gculws5OTlwOp1wOJK3/DOY3rgGCk0TGHP7m6keRspI0AOTyF/QnZkUCYXZZji9IQTCemVVWZIwIt8Ku9mAZk8QofblmLZgGCZFRrHDEg1ODrbPqHScltc0gUtXfIqqWtdhLSWEEKhzBVBRYsf/nFGBVl+o336XYm467UFTVzed7gKvrp5bt48/CG5qm/c7cc1fP0eW2QCzUcbXTV74gmEoigQIvdu5EAIj87Pg9OvLNX++/IQB9R6Z6GuF4teb+zdnZhKUSJEwSq2B9EaaCIGeeysFVYEDrX5IABQZsBkV5NpMcPv1hMiffnsshuVasWzVTuxraUNpjvWIdUXiKWewq7ENsiRh7vgh/fbc41lCSLRmSjyFNNOpHkcydFy+y7EYEAirMCgyZEkPZEIaYDUqerK4LA24mj2sOZZ+GMzQoLG2uinVQ8gIkgTkWE3Itek7lyD0vJZ3ttTh56dPQIPLj/wsc1zVUtOpnMGR/gBJpBJsPDe1pW9VIceqB20DZdam4/JdkycATROQFT33KqwJKO1LlpFdbwOtZAWrBqcf7maiQUHTBJa+VZXqYWQETQAtbUHsafaipqENTZ4ArEYZNQ0ebNjXesTgpON27UwqZxBP4NV5K/qRbmpmg4yttW589Y0TNpNe4RjQl2lu++eXGR1gR/IHRw+xA9CTzTUhYDXKGJpnjVZVTqd/42RJ5LVC/YszM5TROucuVZZko6ru8IaHm79xYnsfq8kOJpFt3WFNhS+kQm7fcru70QOjIsHl11sBKO1T6Pq5ej4NBKIdlDOpIFnnXl6ddb4pa5rAF3sOoi2owmJUICBimh0K6Du+NCFgMiioc+lJ06J9C3NbMIylb1Xh1UUnZexSxOyxhfj7qHx874l12NXYhiF2U7QOEZB+/8bJ0tvXCvU/BjOUsTonXWpCgyrae7hIUnQ6/8JvDccHO5oS7lE0WHW8Wlr7nu7XNn2DkCrgDapAh224Hc81yhIefGcbfnrKWMweW5gx5Qx6E3hFXntVtS64/SG0BUKwGA3RyrcA4A9qCIRVSAAOtgWhATDIepVcASCsatha68Zzn+7FD2aOTMlzTgaDQcYtZ0zA7S9vhtMfjtn1lm7/xsmSSUH6YMHdTJSROiddBsMaDjh9CKkCigRkW40Iqxq8QbXLnTvUeyZFQkgV0GvqSdFKwZ0pMpBlMiI/yxjd0dHVbqIxQ+yYf2wJhufb0iYp9tDrSu0y8Lp/4SQAiL72cq1G1Dr98IdUAIAiH1picfmC2NPig9zefdwox970NKFXtD2mzIHXr8vc2ZmIeHeMDRTxvFYG4vM+mnpz/2YwQxknst136wEn8mwmeIMqGtwBhBm19CuDDGgaosFMV9db0Rs8wWKQYTEqOKbMEd2S23FJcF+LF29/VZuWSbE93ZRnjik4bKu5JxDGNwd9UIUGCL11RGmuBU2eIFy+kN5Con2nT0eaEFA1DXk2M/502bcGRKLoYCtZMdgCuKONW7NpwBFC4IDTj417W/Hvqnp8sqsZIVWguS0U1/fLUtddeCl+YU2vVaPv7u76YqoCMEpAUNWQl2WK2dER2U20troJT32wq8etzKm8EfS0jXvzfudhCb92swFD86xodPvhD2nwhVQ4vWEcW5aD/Qe92NPihX69YvNpVE3AbNDzLQZKouhgK1nRlwaXlFwMZigtOX0hbN7vxMZ9B7FxnxOb9rei0R2I+/slAA6LATk2EywGGQecfngC4f4b8CARTzyoNx7Uc5cCmhZzo86U+hzd3ZS728ViNxuQZcqCN6iiuS2IRaeOxY9mjsRzn+7FPa9tQVgVMCj6dRFCv0ayJCHXZoIQgomiGWywBXDpisEMpVwwrGFbnQsb97Vi475WbNrXiprGtri/39xe1VaW9N01QggIAEOyLbCaFPhCanTXDSVGbx4YG8pI6Dq40QAYJL3rducdHZlen6OnXSySJEGWJWSZFEwbkQdZlnDxCSPwwmd7sa3ODVXTAOgJwBajgkK7CZ6AykRRoiRgMENHlRACe5q92LS/FRv2tmLjvoPYesDdbR2SzobnWzFlWC62HnChuS2IobkWyJKEr5vb4AtpAAS09rwFi0mO7iwYXWjDxn3O/n1yA5gs6UFKx+Clp1kaRZbhC6o4pswRc6NOpyJ6iejtLhZZlnDbmZW47eXNcPpCsBkVWIwKZBlo9YYH5E4folRgMEP9qqUtiE37WrFh70Fs2NuKL79xwumLL88lx2rE5GE5mDoiD1OH52LysBwU2M0ADu0kaHAHkWszoiDLjANOH4JhAUXWOzL7Q4d2Ftw8bwJ+uOLT/nyqA5YEtCe2ygiG9e3vR2KQZWRbDIfdqDO9PseRGld2FZzMHluIpR2a07oDYTanJUoyBjOUNP6Qii3fOPH53oPYuLcVX+534ptWX1zfa1QkVJQ4cNyIXEwbkYcpw3MxqsB22FJEROfu5SFNwGExROvMeIMqjLIWvWFkW4yQoc8uUPeyTApybEY0uAIQQkCWJRRk6bVTLCYZHn8Ye1u80ETHdNbD68xMHZETrTPT0UCoz9H5tRdPt28mihL1LwYzlBBNE6hu8GD9noN6nsv+Vuxs8HRbe6SzEfm29lmXXEwbmY/K0uzozo54dXWD6K4C8JodjbAYAO8gzgHumOMiAVAUCcXZZpxYXohCuxmb9rdid1MbgmE9/0hIwNBcK7Itxuhj2M0G2M0G+MMarAYZ2VYjjIqEQEiD2x+Gzazg+lPH4eITRnR5o05kZiMdJRKcMFGUqP8wmKG4fHPQh/WRGZdvWlF1wIW2oBrX9+ZajZg4NAfHDcvB1JF5+NbIfOTYjEf+xjh0dYPo6oaRbzPBajbCG45viSuTRO6fkThSgn5dIonQRlnGeccPxUUnjIAmBDbtd0ISwHEjcjFpaE70Bty5DsyTH+yCJ6B3Q+4YcORnmXDJjBF4f2cTaho88IT1paHjRuTGtWySyMxGOmJwQpQ+WDSPYqia3k9m075WbNjXis3fOLH1gAsNcW6LNhtkjC/OxrFDHThueC6mjczDmMIsyHJqe5pqmsB3//AhvjrgSuk4umNW9DwfT1CFJAQkSUKO1YSyXAs+292CcDe/pYokwWKUkWMzoi0QRiCswWaUoUGCDGBEQRZ+fvp4nDRuSK/HdKSCYH0tkDbYCqwRUe+wAnAHDGa6Fwxr8AbD2FbnxsZ9rfiqPXD5urktrgJzEoBRhVmoKMnGpGE5OH54Ho4d6kCW2dBtrksq/e3jPbjzla9SPYwoCfqsSkGWEcsuPL7bZYsPdzbioXe3Y1eTF5omYDZIyLWaoAoRrZ1jUmSUF9lxzZwxyLGakhYgMOAgolRhMNMBgxn9hhRUNfhDKvYd9GHTvlZs3u9EVZ0LO+rc8IfjS4sttJtQWepAZakDk4fmYMrwXBTazbAY5bQMXjrTNIExt7951H6eDCDfbkIgpKF8SBYWHj8UL362D9+0+qAJwGSQUVGS3WWibGddBRUAGGgQ0YDFdgaDWDCsIahqCIY1tHiC2HxAn3HZVuvWE2Pb4qvfYTUqmFCSjcrSbFSUODBleC5G5NtgNSowG+SMvGn2Zcx2swyTIsPlD0PV9EDFapJRYDMCMiBJMuqdfmhCwG4xwmExQpYlOH1hFNhNuGV+BWaPLcQPZ45KKADpLj+DORtERAxmMlZktiUQ1g4tF9W6sbXWhW11LlTVurG3xRvXY8kSMKbQrgcupQ5UlGRjXHE2skwKrCYFFoOSkcFLslw+eyTu+q+JAHqeCemYY+IJql0mtTJplIgo+RjMZIBQ+0xLZNYlEFKxp8XbPtviwrZaN3Y2uBGKp5oZgBKHJRq4VJZkY2yRHQ6rEdb26qRW4+AOXjrKtSg47/jh0evRUyDCWiJERKnBYCaNdJ5tCaoaQmENB71BbGsPWqrq3NhW64LLH1/BFLvZgIr25aLKUgcmlGQjz2aCUZH1WZf24IW9i7omyXKvSutz5oWI6OhjMJMinWdbgmEtemxngxvb6tyoqnVjW50LB1r9cT2mUZFQPsSOyvalosrSbAzNtUKSJBgVWQ9cTAosBhmGbnrjUCyrUUnb0vpERKRjMNPPIrMtwY7BS1iDJgQ0IbC/xRddKqqqc6GmsS3uKrrD8qzRwKWiJBvlQ+wwGfQgxajIMBtlWNtnXhi8JKa8yJ7WpfWJiIjBTFJ1N9sS0dIWRFWtC9val4q21bvRFoi/im5FaTYqSxyoKNWDl45l5g2yDItJjua9GBm89JkByIjS+kREgx2DmT4IhFW4/eGY2ZYIX0jFjno3ttVGlozir6JrMsgYX3RouaiiNBslDktMLRdFlvTApX23UWRGhpLnhu8cuf4LERGlHoOZPvCHNLh8IaiawJ7mtvYcF3256Oum+KvojiywoaLE0V7TJRujC7MOWxZSZAmWDruNGLz0v29PKEn1EIiIKA4MZhLgCYTxwY5GfLK7BRv2HsT2ejf8ofiq6BbYTXpybnvwMr44G1nmw/8ZZEmK1nixmORed5SmvmOuDBFRZmAwk4CDbUFc++wXRzxPr6JrR0V7nktliQNDss1dnitLUnTWxWzUdx5RajFXhogoMzCYScCwPCsKskxo7tAaIFJFVw9a9IJ0I/Jt3dZvkdq7HUcSds2GzOhvRERElG4YzCRAkiScPrEYzW1BjCnMQmWJA+OK7T3OpkSCF4tBr/XC4IWIiCg5GMwkaOl5k+H0hdDs6XqHkiRJMBsOzbxkSmdpIiKiTMNgJonM7TkvVgYvRERER01G7O997LHHMGrUKFgsFsyYMQOffvppqocEQG8fkGM1oiTHglEFWRiaa0V+lglWk8JAJk3dfNqwpJ5HRESpl/bBzAsvvIDFixfjnnvuwRdffIEpU6bgjDPOQENDQ6qHBpvJgAK7GTaTgTtfMsS1356U1POIiCj10j6Y+d3vfoerrroKl19+OY455hj88Y9/hM1mw5/+9KdUD40ykMEg446zKno8546zKmBgUUIiooyR1u/YwWAQ69evx7x586LHZFnGvHnzsG7dui6/JxAIwOVyxXwQdXTVnHLccVYFOs+lSdADmavmlKdiWERElKC0TgBuamqCqqooLi6OOV5cXIxt27Z1+T1Lly7FkiVLjsbwKINdNaccl88ejde/rMU3rV4MzbXhnMmlnJEhIspAaR3MJOK2227D4sWLo5+7XC4MHz48hSOidGUwyFh4/NBUD4OIiPoorYOZwsJCKIqC+vr6mOP19fUoKem6CaDZbIbZ3HXLACIiIhp40npO3WQyYdq0aVi1alX0mKZpWLVqFWbNmpXCkREREVG6SOuZGQBYvHgxLr30UkyfPh0nnHACli1bhra2Nlx++eWpHhoRERGlgbQPZi644AI0Njbi7rvvRl1dHY477ji8/fbbhyUFExER0eAkCSFEqgfRn1wuF3JycuB0OuFwOFI9HCIiIopDb+7faZ0zQ0RERHQkDGaIiIgoozGYISIioozGYIaIiIgyGoMZIiIiymgMZoiIiCijpX2dmb6K7Dxn92wiIqLMEblvx1NBZsAHM263GwDYbJKIiCgDud1u5OTk9HjOgC+ap2kaDhw4gOzsbEiSlOrhpFyki/i+fftYRBC8Hp3xehyO1yQWr0csXo9YybweQgi43W6UlZVBlnvOihnwMzOyLGPYsGGpHkbacTgc/MXrgNcjFq/H4XhNYvF6xOL1iJWs63GkGZkIJgATERFRRmMwQ0RERBmNwcwgYzabcc8998BsNqd6KGmB1yMWr8fheE1i8XrE4vWIlarrMeATgImIiGhg48wMERERZTQGM0RERJTRGMwQERFRRmMwM8C0tLTgkksugcPhQG5uLq644gp4PJ4ez7/++usxYcIEWK1WjBgxAjfccAOcTmfMeZIkHfaxcuXK/n46CXnssccwatQoWCwWzJgxA59++mmP57/00kuoqKiAxWLBpEmT8Oabb8Z8XQiBu+++G6WlpbBarZg3bx527tzZn08hqXpzPZ588kmcfPLJyMvLQ15eHubNm3fY+Zdddtlhr4X58+f399NImt5cj2eeeeaw52qxWGLOGUyvj1NOOaXL94Kzzz47ek4mvz7ef/99nHPOOSgrK4MkSXjllVeO+D2rV6/G8ccfD7PZjLFjx+KZZ5457Jzevielk95ek3/+85/4zne+gyFDhsDhcGDWrFl45513Ys659957D3uNVFRU9G2gggaU+fPniylTpoiPP/5YfPDBB2Ls2LHioosu6vb8zZs3i/POO0+89tprorq6WqxatUqMGzdOnH/++THnARArVqwQtbW10Q+fz9ffT6fXVq5cKUwmk/jTn/4ktmzZIq666iqRm5sr6uvruzz/o48+EoqiiN/85jdi69at4s477xRGo1Fs3rw5es4DDzwgcnJyxCuvvCI2bdokvvvd74rRo0en5fPvrLfX4+KLLxaPPfaY2LBhg6iqqhKXXXaZyMnJEfv374+ec+mll4r58+fHvBZaWlqO1lPqk95ejxUrVgiHwxHzXOvq6mLOGUyvj+bm5phr8dVXXwlFUcSKFSui52Ty6+PNN98Ud9xxh/jnP/8pAIiXX365x/N37dolbDabWLx4sdi6dat49NFHhaIo4u23346e09trnG56e01+9rOfiV//+tfi008/FTt27BC33XabMBqN4osvvoiec88994iJEyfGvEYaGxv7NE4GMwPI1q1bBQDx2WefRY+99dZbQpIk8c0338T9OC+++KIwmUwiFApFj8XzIk4HJ5xwgli0aFH0c1VVRVlZmVi6dGmX53//+98XZ599dsyxGTNmiGuuuUYIIYSmaaKkpEQ8+OCD0a+3trYKs9ksnn/++X54BsnV2+vRWTgcFtnZ2eLPf/5z9Nill14qzj333GQP9ajo7fVYsWKFyMnJ6fbxBvvr4+GHHxbZ2dnC4/FEj2Xy66OjeN7zbrnlFjFx4sSYYxdccIE444wzop/39Rqnk0TvA8ccc4xYsmRJ9PN77rlHTJkyJXkDE0JwmWkAWbduHXJzczF9+vTosXnz5kGWZXzyySdxP47T6YTD4YDB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MJlMYujQoeKCCy4Q1dXVfRonu2YTERFRRmPODBEREWU0BjNERESU0RjMEBERUUZjMENEREQZjcEMERERZTQGM0RERJTRGMwQERFRRmMwQ0RERL32/vvv45xzzkFZWRkkScIrr7zS68cQQuChhx7C+PHjYTabMXToUNx33329fhwGM0Q0oH300UeYNGkSjEYjFixYgNWrV0OSJLS2tqZ6aFGjRo3CsmXLUj0Mol5pa2vDlClT8NhjjyX8GD/72c/w1FNP4aGHHsK2bdvw2muvHdaJPR6GhEdARJQBFi9ejOOOOw5vvfUW7HY7bDYbamtrkZOTk+qhEWW0M888E2eeeWa3Xw8EArjjjjvw/PPPo7W1Fcceeyx+/etf45RTTgEAVFVVYfny5fjqq68wYcIEAHrz1kRwZoaIBrSamhqceuqpGDZsGHJzc2EymVBSUgJJkro8X1VVaJp2lEdJNPBcd911WLduHVauXIkvv/wS//3f/4358+dj586dAIDXX38dY8aMwb/+9S+MHj0ao0aNwpVXXomWlpZe/ywGM0SDzCmnnIIbbrgBt9xyC/Lz81FSUoJ77703+vXW1lZceeWVGDJkCBwOB0499VRs2rQJAOB0OqEoCj7//HMAgKZpyM/Px8yZM6Pf/7e//Q3Dhw+Payz79+/HRRddhPz8fGRlZWH69On45JNPol9fvnw5ysvLYTKZMGHCBPz1r3+N+X5JkvDUU09h4cKFsNlsGDduHF577TUAwNdffw1JktDc3Iwf//jHkCQJzzzzzGHLTM888wxyc3Px2muv4ZhjjoHZbMbevXsxatQo/OpXv8KPfvQj2O12jBw5Eq+99hoaGxtx7rnnwm63Y/LkydFrEfHhhx/i5JNPhtVqxfDhw3HDDTegra0t+vWGhgacc845sFqtGD16NJ599tm4rhVRJtm7dy9WrFiBl156CSeffDLKy8vx85//HCeddBJWrFgBANi1axf27NmDl156CX/5y1/wzDPPYP369fje977X+x/YpzaVRJRx5s6dKxwOh7j33nvFjh07xJ///GchSVK0C/K8efPEOeecIz777DOxY8cOcfPNN4uCggLR3NwshBDi+OOPFw8++KAQQoiNGzeK/Px8YTKZot3Zr7zySnHJJZcccRxut1uMGTNGnHzyyeKDDz4QO3fuFC+88IJYu3atEEKIf/7zn8Jo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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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)" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "metadata": { + "id": "-S1A9E3WGaYH" + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 31, + "metadata": {}, + "output_type": "execute_result" + }, + { + "data": { + "image/png": "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4Vf+3XffFYMGDRIGg0EMGzZM/Pvf//bbr6qqePzxx0VKSoowGo3i6quvFkeOHOmIlxISrTkfffr0afJaWLJkiRBCCLvdLiZPnix69Ogh9Hq96NOnj5g7d65m3piFaN35ePDBB31lU1JSxDXXXOM3X4YQkXV9CCHEt99+KwCITz/9tNGxtH59NPd+WHcO7rjjDjFhwoRGj7n00kuFwWAQ/fv395tzp05L57iza+05mTBhQovlhfAOX09LSxMGg0H07NlT3HzzzSI/P79NcXLVbCIiItI09pkhIiIiTWMyQ0RERJrGZIaIiIg0jckMERERaRqTGSIiItI0JjNERESkaUxmiIiISNOYzBAREVGrff7557juuuuQnp4OSZLwwQcftPoYQgi88MILGDRoEIxGI3r27In//d//bfVxmMwQUZe2detWZGdnQ6/XY/r06di8eTMkSUJFRUW4Q/Pp27cvVqxYEe4wiFqlqqoKI0aMwKuvvhr0MX75y1/ijTfewAsvvIBvv/0W69evb7QSeyB0QUdARKQBCxYswKWXXoqPP/4YcXFxiImJQXFxMcxmc7hDI9K0adOmYdq0ac3udzgcePTRR/HOO++goqICl1xyCZ599llMnDgRAJCXl4eVK1fi4MGDGDx4MADv4q3BYM0MEXVpBQUF+OEPf4hevXohISEBBoMBqampkCSpyfIejweqqnZwlERdz3333Yft27djzZo12L9/P37yk59g6tSpOHbsGADgX//6F/r3748PP/wQ/fr1Q9++fTFnzhyUl5e3+rmYzBBFmIkTJ+KBBx7Ar3/9ayQmJiI1NRVPPvmkb39FRQXmzJmDHj16wGQy4Yc//CH27dsHALBYLFAUBTt37gQAqKqKxMREXHHFFb7H//Wvf0VGRkZAsZw+fRq33HILEhMTERsbi5EjR+Lrr7/27V+5ciUyMzNhMBgwePBgvPXWW36PlyQJb7zxBmbMmIGYmBgMHDgQ69evBwAcP34ckiShrKwMd911FyRJwurVqxs1M61evRoJCQlYv349hg4dCqPRiJMnT6Jv3774zW9+g9tvvx1xcXHo06cP1q9fj7Nnz+KGG25AXFwchg8f7jsXdb788kuMGzcO0dHRyMjIwAMPPICqqirf/tLSUlx33XWIjo5Gv3798Le//S2gc0WkJSdPnsSqVavw3nvvYdy4ccjMzMRD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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "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", + " y=\"search_trends_bruise\",\n", + " data=local_symptom_data\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": { + "id": "Hd2A8707Uhz2" + }, + "source": [ + "We see that the slope of the line is positive in the graphs for cough and fever, but flat for bruise. That means that in places with increasing new cases of COVID-19, we saw increasing searches for cough and fever, but we didn't see increasing searches for unrelated symptoms like bruises. Interesting!" + ] + }, + { + "attachments": {}, + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Recap" + ] + }, + { + "attachments": {}, + "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", + "\n", + "Thank you for using BigQuery DataFrames!" + ] } - ], - "source": [ - "# similarly, for fever\n", - "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": 63, - "metadata": { - "id": "-S1A9E3WGaYH" - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 63, - "metadata": {}, - "output_type": "execute_result" - }, - { - "data": { - "image/png": "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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": [ - "
" - ] - }, - "metadata": {}, - "output_type": "display_data" + ], + "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.12.6" } - ], - "source": [ - "# similarly, for bruise\n", - "sns.regplot(\n", - " x=\"new_cases_percent_of_pop\",\n", - " y=\"search_trends_bruise\",\n", - " data=weekly_data,\n", - " scatter_kws={'alpha': 0.2, \"s\" :5}\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "id": "Hd2A8707Uhz2" - }, - "source": [ - "We see that the slope of the line is positive in the graphs for cough and fever, but flat for bruise. That means that in places with increasing new cases of COVID-19, we saw increasing searches for cough and fever, but we didn't see increasing searches for unrelated symptoms like bruises. Interesting!" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Recap" - ] - }, - { - "attachments": {}, - "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, 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!" - ] - } - ], - "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.12.6" - } - }, - "nbformat": 4, - "nbformat_minor": 0 + "nbformat": 4, + "nbformat_minor": 0 } diff --git a/notebooks/visualization/tutorial.ipynb b/notebooks/visualization/tutorial.ipynb index 89a5ed87b8f..0923e03bc71 100644 --- a/notebooks/visualization/tutorial.ipynb +++ b/notebooks/visualization/tutorial.ipynb @@ -27,7 +27,7 @@ "id": "e661697d", "metadata": {}, "source": [ - "# BigQuery DataFrame Visualization Tutorials", + "## BigQuery DataFrame Visualization Tutorials\n", "\n", "\n", "\n", @@ -90,7 +90,7 @@ "metadata": {}, "outputs": [], "source": [ - "PROJECT_ID = \"bigframes-dev\" # @param {type:\"string\"}\n", + "PROJECT_ID = \"\" # @param {type:\"string\"}\n", "REGION = \"US\" # @param {type: \"string\"}" ] }, @@ -147,6 +147,18 @@ "id": "fb595a8f", "metadata": {}, "outputs": [ + { + "data": { + "text/html": [ + "Query job caa8554f-5d26-48d7-b8be-25689cd6e307 is DONE. 0 Bytes processed. Open Job" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, { "data": { "text/html": [ @@ -284,7 +296,7 @@ }, { "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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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjIAAAGdCAYAAAAIbpn/AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+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": [ "
" ] @@ -366,18 +378,18 @@ "
\n", " \n", " \n", - " \n", + " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -390,20 +402,20 @@ " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", @@ -414,24 +426,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", @@ -440,13 +452,13 @@ " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", " \n", - " \n", + " \n", " \n", " \n", " \n", @@ -464,14 +476,14 @@ " \n", " \n", " \n", - " \n", + " \n", " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", - " \n", - " \n", " \n", " \n", " \n", @@ -490,24 +502,24 @@ "" ], "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", + " stn wban date year mo da temp count_temp dewp \\\n", + "0 010014 99999 2021-02-09 2021 02 09 23.9 4 3.2 \n", + "1 010014 99999 2021-03-19 2021 03 19 41.9 4 31.1 \n", + "2 010030 99999 2021-02-23 2021 02 23 31.5 4 30.1 \n", + "3 010070 99999 2021-02-21 2021 02 21 24.7 4 15.7 \n", + "4 010070 99999 2021-01-28 2021 01 28 4.1 4 -5.4 \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", + "0 4 ... * 0.0 I 999.9 0 0 \n", + "1 4 ... * 0.0 I 999.9 0 0 \n", + "2 4 ... 0.19 E 999.9 0 1 \n", "3 4 ... 0.0 I 999.9 0 0 \n", - "4 0 ... 0.0 I 999.9 0 0 \n", + "4 4 ... 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", + "2 1 0 0 0 \n", "3 0 0 0 0 \n", "4 0 0 0 0 \n", "\n", @@ -541,7 +553,7 @@ { "data": { "text/html": [ - "Query job a2cee421-0f51-49a8-918a-68177b3199dc is DONE. 64.4 MB processed. Open Job" + "Query job b4681e18-4185-4303-96a4-f0614223ee63 is DONE. 64.4 MB processed. Open Job" ], "text/plain": [ "" @@ -641,7 +653,7 @@ }, { "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==", + "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": [ "
" ] @@ -710,52 +722,52 @@ "
\n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", " \n", - " \n", + " \n", " \n", " \n", - " \n", - " \n", + " \n", + " \n", " \n", " \n", "
0010030010014999992021-11-102021-02-092021111026.4020923.9417.93.24...<NA>*0.0I999.9
1010030010014999992021-02-012021-03-19202102018.9031941.940.531.14...<NA>2.76G*0.0I999.900
2010060010030999992021-07-222021-02-232021072234.4022331.5430.149999.90...<NA>0.0I0.19E999.9000110003010070999992021-04-052021-02-212021040517.9022124.746.415.74...<NA>4010070999992021-02-042021-01-282021020419.101284.14-5.449999.90...<NA>0.0ALF1910Sadie40Cora61
1ALF1910Mary875Anna74
2ARF1910Vera39Willie132
3ARCOF1910Marie78Anna42
4ARFLF1910Lucille66Louise70
\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" + " state gender year name number\n", + "0 AL F 1910 Cora 61\n", + "1 AL F 1910 Anna 74\n", + "2 AR F 1910 Willie 132\n", + "3 CO F 1910 Anna 42\n", + "4 FL F 1910 Louise 70" ] }, "execution_count": 10, @@ -785,7 +797,7 @@ { "data": { "text/html": [ - "Query job d1fc606b-18b0-4e7e-a669-0e505a95aa5a is DONE. 132.6 MB processed. Open Job" + "Query job 225ac92a-78d2-4739-af6c-df7552dd2345 is DONE. 132.6 MB processed. Open Job" ], "text/plain": [ "" @@ -828,34 +840,34 @@ " \n", " \n", " \n", - " 1927\n", - " 1631\n", + " 1923\n", + " 2047\n", " 0\n", - " 70864\n", + " 71799\n", " \n", " \n", - " 1918\n", - " 2353\n", + " 1913\n", + " 1371\n", " 0\n", - " 67492\n", + " 36725\n", " \n", " \n", - " 1912\n", - " 1126\n", + " 1915\n", + " 2078\n", " 0\n", - " 32375\n", + " 58293\n", " \n", " \n", - " 1923\n", - " 2047\n", + " 1925\n", + " 1748\n", " 0\n", - " 71799\n", + " 70815\n", " \n", " \n", - " 1933\n", - " 1036\n", + " 1916\n", + " 2201\n", " 0\n", - " 55769\n", + " 61551\n", " \n", " \n", "\n", @@ -864,11 +876,11 @@ "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" + "1913 1371 0 36725\n", + "1915 2078 0 58293\n", + "1925 1748 0 70815\n", + "1916 2201 0 61551" ] }, "execution_count": 11, @@ -900,7 +912,7 @@ }, { "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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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjkAAAGwCAYAAABLvHTgAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAPYQAAD2EBqD+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": [ "
" ] @@ -940,7 +952,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -987,7 +999,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -1069,14 +1081,14 @@ " \n", " \n", " 0\n", - " 2\n", - " 2021-09-19 10:25:05+00:00\n", - " 2021-09-19 10:25:10+00:00\n", " 1\n", - " 0E-9\n", + " 2021-06-09 07:44:46+00:00\n", + " 2021-06-09 07:45:24+00:00\n", + " 1\n", + " 2.200000000\n", " 1.0\n", " N\n", - " 1\n", + " 4\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1085,21 +1097,21 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 264\n", - " 264\n", + " 263\n", + " 263\n", " 2021\n", - " 9\n", + " 6\n", " \n", " \n", " 1\n", " 2\n", - " 2021-09-20 14:53:02+00:00\n", - " 2021-09-20 14:53:23+00:00\n", - " 1\n", - " 0E-9\n", - " 1.0\n", + " 2021-06-07 11:59:46+00:00\n", + " 2021-06-07 12:00:00+00:00\n", + " 2\n", + " 0.010000000\n", + " 3.0\n", " N\n", - " 1\n", + " 2\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1108,16 +1120,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 263\n", + " 263\n", " 2021\n", - " 9\n", + " 6\n", " \n", " \n", " 2\n", - " 1\n", - " 2021-09-14 12:01:02+00:00\n", - " 2021-09-14 12:07:19+00:00\n", + " 2\n", + " 2021-06-23 15:03:58+00:00\n", + " 2021-06-23 15:04:34+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -1131,21 +1143,21 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 170\n", - " 170\n", + " 193\n", + " 193\n", " 2021\n", - " 9\n", + " 6\n", " \n", " \n", " 3\n", - " 2\n", - " 2021-09-12 10:40:32+00:00\n", - " 2021-09-12 10:41:26+00:00\n", " 1\n", - " 0E-9\n", + " 2021-06-12 14:26:55+00:00\n", + " 2021-06-12 14:27:08+00:00\n", + " 0\n", + " 1.000000000\n", " 1.0\n", " N\n", - " 1\n", + " 3\n", " 0E-9\n", " 0E-9\n", " 0E-9\n", @@ -1154,16 +1166,16 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 193\n", - " 193\n", + " 143\n", + " 143\n", " 2021\n", - " 9\n", + " 6\n", " \n", " \n", " 4\n", - " 1\n", - " 2021-09-25 11:57:21+00:00\n", - " 2021-09-25 11:58:32+00:00\n", + " 2\n", + " 2021-06-15 08:39:01+00:00\n", + " 2021-06-15 08:40:36+00:00\n", " 1\n", " 0E-9\n", " 1.0\n", @@ -1177,10 +1189,10 @@ " 0E-9\n", " 0E-9\n", " 0E-9\n", - " 95\n", - " 95\n", + " 193\n", + " 193\n", " 2021\n", - " 9\n", + " 6\n", " \n", " \n", "\n", @@ -1188,17 +1200,17 @@ ], "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", + "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 0E-9 1.0 N 1 \n", - "1 1 0E-9 1.0 N 1 \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 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", @@ -1209,18 +1221,18 @@ "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", + "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 9 \n", - "1 2021 9 \n", - "2 2021 9 \n", - "3 2021 9 \n", - "4 2021 9 " + "0 2021 6 \n", + "1 2021 6 \n", + "2 2021 6 \n", + "3 2021 6 \n", + "4 2021 6 " ] }, "execution_count": 15, @@ -1276,6 +1288,16 @@ "id": "e34ab06d", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:273: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:249: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" + ] + }, { "data": { "text/plain": [ @@ -1288,7 +1310,7 @@ }, { "data": { - "image/png": "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", + "image/png": "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", "text/plain": [ "
" ] @@ -1325,6 +1347,16 @@ "id": "51c4dfc7", "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:273: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, category=bfe.AmbiguousWindowWarning)\n", + "/usr/local/google/home/sycai/src/python-bigquery-dataframes/bigframes/core/array_value.py:249: AmbiguousWindowWarning: Window ordering may be ambiguous, this can cause unstable results.\n", + " warnings.warn(msg, bfe.AmbiguousWindowWarning)\n" + ] + }, { "data": { "text/plain": [ @@ -1337,7 +1369,7 @@ }, { "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+8M0Gd5mqaEQkJSlJIyIiIiIiyePMhZxLkh2FiJwJNQ5OmBS5TRERERERERGRkU2VNCIiIiIiIiIyeKqkSZgUuU0RERERERERkZFNlTQiIiIiIiKSFCYmERqIEcJGBlbSkh2SDIYqaRImRW5TRERERERERpIe9lHP/RxmJTX8lMPcQzN/I0Z3skOTMSAajXLbbbcxfvx43G43EydO5Lvf/S6maSY7tJNSJY2IiIiIiIgMqx4qaeC3hGnETh4WHERppZlnCHOEHP4ZA3uyw5TTNQIrab7//e9z33338dBDDzFjxgzefPNNPv7xjxMIBPjCF74wNDEmgJI0IiIiIiIiMmxMTFpZRZhGXEzCwADAggsLPjp5Cy8L8TA9yZHKaPb6669z+eWX80//9E8AlJWV8fvf/561a9cmObKT03InERERERERGTZRWuhhL3ZyehM0x1jxYBKhm4okRSeDYhmmB9DW1tbnEQwGjxvSsmXLeOGFF9i9ezcAb731Fq+++iqXXHJJgm8+sVRJIyIiIiIiIsPGJIxJBAPvcfcbWDA5/gtvkeLi4j4ff/vb3+b222/vd9zXv/512tramDZtGlarlWg0yp133sm11147TJEOjpI0IiIiIiIiMmyspGMjgwjN/aY5mcQwieCgIEnRyaAMY0+aAwcO4Pf7ezc7nc7jHv6nP/2J3/3udzz88MPMmDGDTZs28aUvfYnCwkKuv/76IQ528JSkEREREREZiFgEgoeBGDgLwHL8FwgicnwWHPhYTCOPEqEVGwEATKIEqcZOPh5mJjlKGan8fn+fJM2JfOUrX+HrX/8611xzDQCzZs1i//793HXXXUrSiIiIiIiMeqYJLa/BkWegpzr+sasAst4LmReAoXaPIqfLzzIiNNLOGsLUAQZgYiefbD6MjYxkhygDYRx9DPU1BqCrqwuLpe/PZavVSiwWS2BQiackjYiIiIjI6Wj6Bxy6P/7fjgIwDAjWwsH/hkg75H0wufGJjCIGdjK5gjTm0UMFMYLYycbN9N7KGpEzcdlll3HnnXdSUlLCjBkz2LhxI/fccw+f+MQnkh3aSSlJIyIiIiJyKpFOaHgcDBu4x7+93T0hvvTpyNOQcQ44cpIXo8goY2DBxXhcjD/1wTKyGQx9T5oBVtL87Gc/47bbbuNf//Vfqa+vp7CwkM985jN861vfGpr4EkRJGhERERGRU+naDcEacE/uv8+RD53boWMHZCpJIzIQsZhJJBLDbrdgGEO9XkZSic/nY+XKlaxcuTLZoQyIkjQiIiIiIqcSC4EZjVfSvNuxXjRmaHhjEhmlIpEY27bV8/rrB9i+vYFIJIbDYWPBggKWLBnHpEmZWCxK2EhqUpJGRERERORUnAVg9UOkGeyZffdFO8HiiB8jIidVW9vBr3+9ga1b64lEYmRmurFaDTo7Qzz22E6ee24vZ51VxPXXz8Xv1+S0UWMYR3CPdUrSiIiIiIiciqsY/POh6UWwuMDqiW+PBaF7L/gXQNq05MYoMhSiUThyGGJRyMwHp2vQp6qr62DlytXs3t3IpEmZeDz2PvuLiny0tQV58cVKurrC3HTTWaSlOc70DkRGFSVpREREREROxTCg8F/iVTPtm44ubTraKdM7C4o+CYY1yUGKJJBpwvZ18PrfoKYynqTJyIOFF8Dii8E2sJeSpmny8MNb2L27kenTc7DZ+pdFGIZBIOBi6tRs1qw5xPjxFVx11YxE3ZEMJVXSJIySNCIiIiIip8OeAWU3Q8cW6KwAMwaeCeCbA1Z3sqMTSaytq+Gx/4JQD2QXgdUKzQ3w1APQ3gIXfzSevDxN+/e38tZbdRQX+4+boHknl8tGZqabl1/ezyWXTMLn07InSR0pkosSEREREUkAiyO+tKngGij8KKQvUYJGxp5wCF5+DCJhKJ0GaT5weaCgFDJy4c0XoP7ggE65du0h2tqCpKef3nKp/HwvNTXtbNxYO4gbkGFnGaZHCkiR2xQREREREZHTcmgf1B2A3HH996XnQGcrVG4f0Cmrq1txu22nPWbbZouP5K6v7xzQdURGOy13EhERERERkbdFQvEqGttxmvYeS7KEBzZyPhKJDXistmmaRKOxAT1HkkQ9aRJGSRoRERERERF5W1YB+NKhtREyc/vuCwXBYoPsgY2cT093EQxGAbBZQ+Rl7SE/ay8uezvWYASz1STWaSMWtdEay+NQaCqmaWi6k6QcJWlEREREREYLMwqRQ0AMrPnxceAiJ2J2Q7QWDBtYCk9/AllGDsxcCq/9FdwecHvj2yNhOLAbysph4qwBhTJ3bj4vvliJ31PFopnPkuGvwRKL4Ws+gtdowuqPEEzz0NKeTyTkpNR4Dd/EKcyYunyANy1JoUqahEnqbd53333Mnj0bv9+P3+9n6dKlPP300737e3p6+PznP09WVhZer5crr7ySurq6JEYsIiIiIpIEpgk9a6DpDmj8j/ij6d+h429gRpIdnYw0Zhi6n4S2b0L7bdD2H9DxXQitO/1zXPARmH1uvEHwns3xR/UuKJkKl98IjoFNXJozJ485M7uYOeFPZPhraG4pwHo4jKulk85wBs1mATGPjbScFtrt6TS2WVleuo2y0BMQ0/e4pI6kVtKMGzeOu+++m8mTJ2OaJg899BCXX345GzduZMaMGXz5y1/mqaee4s9//jOBQICbbrqJD33oQ7z22mvJDFtEREREZHj1vA6tvwIzBLZCwAqRemh/AMxW8F4zoHHIMoaZJnT/AXqeAIsXrIXxRF5kJ0SqgM+CY8mpz5Pmg6u+AJXbYP8uiEYgvwSmzAN32oDDcrtsXH35HppqG6msHk+Bq5P0YC09ljSilviSpu4eHx5XK17nPurss8gqTceoWQV5SyB77oCvKcPIOPoY6mukgKQmaS677LI+H995553cd999rF69mnHjxnH//ffz8MMPc8EFFwDwwAMPUF5ezurVq1my5DR+sIiIiIiIjHZmEDofB6LgmPr2dksZROug63lwLwdbUbIilJEkWg3BF8GaC5ac+DYDTMNHd8d2Duy9nz/9rYPWtngj3/R0FwsXFrJwYSGZme8aJ2+zweQ58UcC4ppYcgArUzlUH8RoPEDMGiVis2MAJibhcIyeHgcBXweLFzjIKsiHpkaofU1JGkkZI6YnTTQa5c9//jOdnZ0sXbqU9evXEw6HufDCC3uPmTZtGiUlJbzxxhsnTNIEg0GCwWDvx21tbUMeu4iIiIjIkAnvhcgBsJX232fJhfAWCG1TkkbiItvj1VVGSe+m5pZuKnY3caQhgteznbaWHTS3xvcfOtTGm28eJifHw9Klxbz//VPIzvYkPq5YNQbtlJXNwOPuwFy/me4uO12dYY5mabDZLQTSfWSlGzjSji5xcmVC8/b4kifLiHn5Ku+mnjQJk/Tv8i1btrB06VJ6enrwer08+uijTJ8+nU2bNuFwOEhPT+9zfF5eHrW1tSc831133cUdd9wxxFGLiIiIiAwTM3S078yJxiFb4j1IRAAIAZbe5W+1dR1sWF9De3uQQMBJZoad8aVe0poDvc+IRmM0NHTx+OM7qaho5DOfWUhJSeAE5x8kMwKmBcNiIS/Xg5mbRjjkohsnpgkWi4HbZcNut0KsGzDjzzOsYMbiTbOT//JVZMglPRc1depUNm3axJo1a/jc5z7H9ddfz/bt2wd9vm984xu0trb2Pg4cOJDAaEVEREREhpm1ACwBiDX23xfriU/usQ5sHLKMYZYCMCxgBjnS2MWbbx6mqztMbl4aWRndBENeOroz+zzFarWQn+9lxoxcdu1q5L771tHQ0JnguAK9cWGxYjjcOCxRAn4X6QEXfp8znqAhdvQJRxsTh9vBmQ4WjeIe0SzD9EgBSb9Nh8PBpEmTWLBgAXfddRdz5szh3nvvJT8/n1AoREtLS5/j6+rqyM/PP+H5nE5n77SoYw8RERERkVHLlgeuxRA9DLF3vHA2QxCpAPsUcM5MXnwycpgmWPLBkkM0tIUtmw/S3RUmK8uN09aD19PEgboZdPUcv0rGZrMwbVo2O3ce4U9/2pbY2GzTwVIc/z42DMgtjY/0NmN9jzM7wUiLj5g3oxDuhILz1BhbUsaIqxeLxWIEg0EWLFiA3W7nhRde4MorrwRg165dVFdXs3Tp0iRHKSIiIiIyjHxXQ6wVet4kvpwFwAr2qeD/NBiqMkh50QPQ8xiEN0GskZ7OSkryesjPysHETjRmZ3/NbLbvW3HS09hsFoqK/GzYUMOhQ20UFSXoTW/DBa6LoOvXEGuCrCKo3QttjeDPjidhzCCYXWCbCTigZSf4yiD3rMTEIEPn6MrLIb9GCkhqkuYb3/gGl1xyCSUlJbS3t/Pwww/z0ksv8eyzzxIIBPjkJz/JzTffTGZmJn6/n3/7t39j6dKlmuwkIiIiIqnF4of0L8YbBId2x/t72EvBOQ8sQ9DkVUaXaA103AvRvWAdh2nNYt+BGBazAgwHu/efTW3jRBqaxxMzrac8XVaWm82b21m37nDikjQAjgvjE8mCT4PVhAmTYc92aDkUb7nksIG1BII+aNsC3hIovzHePFgkRSQ1SVNfX891111HTU0NgUCA2bNn8+yzz3LRRRcB8JOf/ASLxcKVV15JMBjk4osv5pe//GUyQxYRERERSQ7DDs658YfIOwX/EU/Q2GaCYaW7K0zl/jSczlkUF9TS1pVNXdOk0z6dYRgEAk5ee62ayy+fipGopUaGFdzXgm0ShFaBdxdMGQcNbmjqgVA6WLLA6YIJH4HC8yBNU8tGBU13SpikJmnuv//+k+53uVz84he/4Be/+MUwRSQiIiIiIjKKmBEIvxFPbhjxKplQKEokEsPtdhKN2inM3k3loQUDOq3HY6ejI0QwGMXlSuDLRsMKjmVgXwqxw+DrhnwnRLwQbI4f48oGh3qLSmoacT1pRERERERE5HRFjo5gf7svUSxmYmJiGAbRmA27rWfAZ7VYDMLhGNFo7NQHD4ZhgPUdVTJWwJkxNNcSGUVSpGBIRERERERkLHKCrRTMpt4tNrsFi8UgGovisHfR1DrwJUPhcBSbzZLYKhoZuzSCO2FS5DZFRERERETGIMMAx/mABaK1YJqkeex402x4nIfoCfo5UD9jwKdtauph2rRsrFa9ZBQZTvoXJyIiIiIiMprZzwLXh4EQRDZjjW1jVnkDXV02Nuy8mOa2gVXSdHeHsVoNzj67ZGjilbHHGKZHClDtmoiIiIiIyGhmWMD1QbDPh8hbEGvHlZnG399op6Mrg4KCgZ3u4ME2ysrSmTUrd2jiFZETUpJGRERERERktDMMsJXFH0C6B5Ys28Yf/7iNtDQHfr/ztE5TW9uBYRhcdtkU7Hbr0MUrY4tGcCdMitymiIiIiIhIarniimm8970T2b+/hfr6TkzTPOGxsZhJdXUrbW1BrrpqOsuWFQ9jpCJyjCppRERERERExiC73crHPz4Xn8/B3/++ly1b6snMdJObm4bdHn+/vqcnQk1NBx0dIXJz07j22llccMF4DCNFGoBIYqiSJmGUpBERERERERmj7HYr11wzk2XLilmz5hCvvlrNvn3NRCIxABwOK8XFfs47r4xFiwrJyUlLcsQiqU1JGhEREREZ0Uy6iFEP2LFQgJEqb6eKJIhhGJSWplNams6ll06murqV7u4wFouBx2Nn/PgMHI6R33+m++j/7Njx4sVIlXE/o4EqaRJGSRoRERERGZFMQoR4jjCriNEEWLEyASeXYGN2ssMTGZW8XgfTp+ckO4wB6aGHHWznANWECGHFSi55TGcGGWQkOzyRhFKSRkRERERGHBOTHn5PmL8DASzkAxGibKeb/bi5ERtzkxyliAy1MGHWsoaDHCANLz78hAlTzX5aaeFsziFAerLDFFXSJEyK3KaIiIiIjCYx9hLmVQwKsTIOAw8GfixMwaSTIH/DJJrsMEVkiB3iEIc5RBbZ+PBhx44HD7nk0UILe9mb7BBFEkpJGhEREREZcSLsxKQT413vkBsYWCgiSiUxDiYnOBEZNjUcxoKBHXuf7QYGaXg5xEHChJMUnfSyDNMjBaTIbYqIiIjI6BLGwHKCxqB2IAKEhjkmERluYcJYOH5TYytWYsSIqqpOxhAlaURERERkxLGQB4B5nHfITZqwkI5B7nCHJSLDLIMMQoQxMfvt66ILPwEcOJIQmfShSpqESZHbFBEREZHRxMYcLIwnxh5MIr3bTdqJ0YiNs7EQSGKEIiNPU1M3+/Y109DQOSTnb28PUlnZzOHD7Zhm/6TJUCihhDQ8NNNEjBgQbyzeSQdgMp7xWPSyVsYQTXcSERERkRHHIA03N9DN/xBj19GtJuDCwXk4uTSZ4YmMKI2NXTz66E7Wrj1EV1cYl8vGvHn5XHHFNIqK/Gd8/s7OEH/96y5eeaWa1tYgdruF8vIcLr98KlOnZifgDk4sQDrzWcgmNtJAfe92J07KmU4JpUN6fRmA461OlQFTkkZERERERiQrE/HwdaK8RZRDGDiwMhUrUzFO0KNCJNW0tQX52c/WsuWtw8wtamZ8Ri0E2+hY187L+3O45MPLSJ9xNqQNLpkSDkf57//ewKpV+8nJ8VBU5CMYjLJ27SGqqlr48peXMHlyVoLvqq9iiskkk8McpotOHDjII58MMk7Qt0pk9FKSRkRERERGLAs+LJzzrrkuInLM2ld2Eqt4gc9N3U+u5RA2QpgeK1G3QUfrNjpeWE/6gaegeDGULYOcaWCcfmJj8+Y6Vq8+yMSJGXi98d4vbredQMDJ1q31/O1vFXzhC5kYAzjnYKSRxmQmD+k1REYCJWlERERERERGo/Y6rKt/yvuztuOypNFONmHDHd9nQKu1h/ZGC0WGBWPX36DyZZj5ISh/P1hOrxpt8+Y6IpFob4LmGMMwKCjwsWVLPU1N3WRleRJ9dzKaDEdj3xRpPZQitykiIiIiIjKGtNfBayvJCe+iPlZIkzHu7QTNUVarhUgUzLQ8yJ8Fdjds+h1seQROs/FvT08Eq/X4CR2Hw0okEiMcjp3x7YhInJI0IiIiIiIio0moC9b+Chp2EcmYRmfw+C/rurvDZGS4sViOLkXy5UNaLmx7DPa+eFqXKi1NJxyOEov1T+o0NnaRm5tGZqb7OM+UlKIR3AmTIrcpIiIiIiJyCrEotFZDSxVEepIdzYkdWg81b0H2VMaVZuJyWWlp7cEknkgxMWnvCGKxGIwv8UCwGcLtgBlvIGx1wI4nIdx9ykstWlRIUZGPiopGotGjI7BNk6ambrq6wlxwwfh4RU1LCz379hGuqxu28dwiY5F60oiIiIiISGozTTi8FvY+Ba1VYMbAkwfjL4TxF4FlBL1sisXivWUsdrA5yc6CObPz2bK1jvr6TuJzkE3S3AZLpraRH62Ew0EwLODKhvSpECiCIxVweBOULj3p5XJy0vjUp+Zz//0b2batAcMwME2TtDQ7l146mfMWZFD/4IN0vPEGsc5ODKcTz+zZZF5xBc5SjcdOGepJkzAj6KeNiIiIiIhIEhx6HTb+F0TD4C2MN9XtqofN/wM9rTDjmmRH+LbGCqjfAf7C3k1lZelkZbupremguzuC02lQ4qrAFTmAYXrA7gMzCp2HIdQCuUvAsELVK1Cy5JTTnubMyeeOO1awfn0NdXUduFw2Zs3KY3yhi/p7V9Lx5ps48vJwFBUR6+qifdUqQvv3U3DLLTiKiob28yEyxihJIyIiIiIiqSsShF2Pxatnsqa+vT1QBp11UPUclJwLvhGSbKjfCeEucPr6bPZ5nfgmO+MfdNVCbQ04M8BydBt2cDuhuwFad4NvKjTsgs4G8Oae8rIZGW4uvHBCn21tL71E54YNuKdNw+KMX8fidmPNyKB7yxZan3+enOuvP+NbllFAlTQJkyK3KSIiIiIichwte6H9APjG9d/nyYVgCxzZPuxhnVCok1O+jOuuj1fO9CZojjHA7o0naowYRELxJsSD1LF+PYbd3pug6b2KxYItJ4eOtWuJBYODPr9IKlIljYiIiIiIpK5oCGKReDPddzMMwIgfM1LEIvG2MydjhuM9aI7HYoVoEDDj1UNmdNChmF1dGI7jfN4Aw+HADIcxw2FwvjtZJGOOKmkSJkVuU0RERERE5Di8BeBMh+7G/vsiPfGmwd6CYQ/rhByek++PdoDRDsYRiFVDrA7MLjg6+YlIN9g9gAOsNrC5Bh2Kc/JkYh0dx53mFGlsxDFuHBbPKeIVkT6UpBERERERkdSVlgdFi6H98NGlREdFQ9C0GzKnQM7M5MX3bt68+P9HI+/aEYOu7dD6IsQOgy0C0UYwG8DcD7FD8QRONAS+8RBsA3cmeDIHHYpvyRJs2dkE9+3DjL09njtcVwdA4PzzMSzveskZC0FXFXTth1h40NeWEcYyTI8UoOVOIiIiIiKS2sqvjk9xqn0znsQwDMACWdNg7qePvxQqWQrnga8AOmohcKyPjgldO6BrG1jc4CgASwC6aiDaA4TBqAWzA/wLwDcB6rZD+WVgdw86FGdZGTkf/zhHfvMburdujX/eYjGs6elkfuhDeJcte/tgMwZH/gENz0JPDWCAexzkXgKZ55xywpRIqlCSRkREREREUpvTD4u+CEe2xatnYhEIlELevKNLg0YQpxfKzoXNfwB/UTy5EW2Dnj1g8YDVGz/OlgbeMgi3x6tXMMEIgy8rnpByp0PxWWccjm/pUlwTJ9K5YQPhI0ewer145szBWVaG8c7ES92TcPDheMLLmR+Pp7saqu6DWBByLjzjWCSJjrZvGvJrpAAlaURERERERKx2yJsbf4x0pUtgz3PQegDSSyB4EGI9YH/XKG2LLT6G+5hII3Tvg55smHQR+AsTEo49N5f0973vxAeEGqHuKbD5wP2OUea2SfGlT7VPQMYSsHkTEo/IaJYiq7pERERERETGiPQSmPtRiASh7RCEG8BwcOpSAxc0HoTsCTD3n4dviVH79niixnWcBsyuIgjWQseu4YlFhoZ60iSMKmlERERERERGmwkr4kmaTb+H9gZwGSd+dWea0NMFnS2QHoCz/uWMGgYPmBk+uhzmOK+yDVt8DHhsBI05F0kiJWlERERERESGSrQbWt6KPyIt8SU/gdmQPi/eN2awDAOmvi+ebFl7GzRsh64YuNPio7WPNvEl2APhILjcUJQPE6ZAxpSE3d5pcRaA4YRIR/8lTcc+J8erspHRYzgqXVRJIyIiIiKJFIuZHD7cTjgcJS/Pi8djT3ZIIjKU2nZA1YPQVQkmYHGAGYL6F8FTCqUfg/S5Z3aN4rPA/23Ydgc0mdDUCMHuePWMxQIeDxROh5wCoBKyVrzdXHi4eKeAbwa0vglpU8HqjG+PdsebB2eeB+7S4Y1JZIRSkkZERERkGGzcWMOTT+5mz54molGT7GwP559fxiWXTMbhsCY7PBFJtI69sOcXEKwH76R4guaYWBi69sHeX8KkL0Jgxpldy7cAxi2BjK0wdQ5EzXgVjdUGDmc8WRPcB5Yc8J9zZtcaDMMKJZ+A/aF4fxozcnS7HQKLoPg6jeAe7Y5OrR/ya6QAJWlEREREhtj69Yf55S/X0dERoqjIj81m4ciRLn7zm7dobOzm4x+f23dUrYiMbqYJhx6DYA34ZvZPQFjskDYF2nfAof8D/7R4ImOwLE7IuxFqfg7dO8GWAc7seA+YyBHoqQdbJuR9Alzjz+jWBs2VD5O+Dm2boHMfYIB3Mvhn901giaQ4JWlEREREhlAkEuPxx3fR0RGmvDynd3tJSYCmJgcvvVTF8uWlTJo0jE08RWRodVVB6xZwFZ+4QsQwwFMC7bugfTf4y8/sms4iKLoVWl+GtpchdBgwwZoGGZdC4DxwTzqza5wpqys+ajtjSXLjEBnBlKQRERERGULV1a1UVjZTXOzvty8jw8XBg21s396gJI3IWNJVDZH2eN+Zk7F5IRqErv1nnqQBsGdB9gch42II14EZi1fV2PXzRYaYGgcnjJI0IiIiIkMoFIoSicSO23fGMAwsFoNQKJqEyERkyMQi8aVGp7OM0TDixyeS1QPWJC1rEpEzkiK5KBEREZHkyM/3kp7uorGxq9++cDiKYUBBwTBPWhGRoWUPAAbEQic/LhYBTHCkD0NQIkPIMkyPFJAitykiIiKSHOnpLs49t5SGhi7a2oK92yORGLt3NzJhQibz5hUkMcJhYJpQfxgOVUJne7KjERl6gRngKYbuwyc/LlgHznwIzD7pYZFIjOrqVqqqWggGE1x1IyIjipY7iYiIiAyxK66YRmNjF2+8cZCqqhYMI77UacKEDG68cT4ejz3ZIQ6dql3w0uNQuQPCYfAFYN65cN4HwO1JdnQiQ8Pqhtz3QNUDEGoGR0b/Y8JtEGqCkn8Ge/+eVQCmafL66wd4+uk9VFe3Ypom+fle3vveiVxwwXisVr3nLiOEetIkjJI0IiIiIkPM47Hzr/+6iPPPH8+OHQ2EQlHGjfMzf34BPp8z2eENnf0V8Lt7oake8seB3QntzfD3P0JjLVz1ebCP4QSVpLa890KwHmqfiVfMuPLB4oovgeqpATMKeRdB4QdOeIqXXqri/vs39iZnLBaD+vpOfv3rDbS1hbjyygQ0GxaREUVJGhEREZFhYLVamDkzl5kzc5MdyvAwTXj1KWiqg0kz326g6nJDmh82r4b5y6F8fnLjFBkqFhuUfgy8k6FhFXTsjidoDHt8klPOeZC1DCzHT1R2dYV54oldWCwGEya8PZ1p/HgHNTXtPPNMBeeeW0Jubtpw3ZHICZlG/DHU10gFStKIiIiISOK1NsGerZBT2H/CjccL0QhUbFaSRsY2wwrZZ0PW0nj1TLQ7Xk3jLojvO4nduxs5dKidSZP6j8/Oy/OybVsD27c3KEkjMsYoSSMiIiIiiRcOxRMxJ1rOZLFCsGd4YxJJFsMC7qIBPSUUihKNmtjt/RtxWCwGhhE/RmQkiFnij6G+RipQkkZERETkZEwTuvZCy5vQfTD+YsszHtLPAtdxqkQkLj0LMnLi/WjS3tUUNRaDSBgKSpMTm8goUFjow+930NzcQ2amu8++zs4QdruFggJvkqITkaGiJI2IiIiMbV2d8USBzQ65hWAZwFtxkQ448BA0r4FoR3xii2lC0ytQ9yTkXAgFHz5hT4mUZnfAogvgsfvjS58CR5dsRKNwoALyimD6wuTGmKJCtBChCxtpOAgkOxw5gaIiHwsXFvL88/twuWy9U+CCwQh79zYzb14+5eU5SY5SJM60xB9DfY1UoCSNiIiIjE2hIKx6CtatgpZGsNqgdBKsuAymzTn186NB2P/f0PQyuEvBNv7tqhnThFAD1PwfxCIw7l9UUXM8iy+MT3Fa9yLUHQSLASbxBM0Vn4JMvcAcTkGaqOdl2thFjCAWnPiZSi7LcdK/74kkl2EYXHvtbDo6QmzcWEsoFMUw4k3IZ8zI4ZOfnI/NliKvWkVSiJI0IiIiMvbEYvDYQ/Dqs+BLj1fQhMOwazMcqoKP3gTlc09+jtY3ofl18EwC27sacxoGOHPjjT8bnoOMxeCdMkQ3M4rZ7XDZ9TD3bKjYAqEeyMyD6QvAn5Hs6FJKmDaqeYROqnCSjR0/UbppZC091FPGNdjxn/pEMqzS0118+ctL2bq1noqKRqJRk/Hj05k7Nx+3WxV8MnLEe9IM7ZsV6kkjIiIiMlrtr4D1r0D+uLeTAS7A64d9O+Clv8LU2Sde+mSa0PgKYO2foHkneyb0HIbm1UrSnIjFAqVT4g9JmmbeopMq0ijDcvQlgBUnNrx0UkUzm8nlnCRHKcfjcFiZP7+A+fMLkh2KiAyDFMlFiYiISErZux26O/tXaxgG5I2D/Xug5sCJnx9pjzcLdmaf/DqGAfYAtG8985hFhlAr27Dh6U3QHGPBhg0PrSTue9g0TUKhKLGYmbBzisjIFrNYhuWRClRJIyIiImNPOHTiHjF2B0TD8elCJ2JG4tU0hvU0LmaF2EnOJTICRAlicPzlMQZ2YoTO6PymaVJZ2cKaNQdZu/YwPT1hrFYLU6dmsWxZMbNm5eFwnM6/JxGR1KYkjYiIiIw9OQXxJE0kArZ3/bnT0gj+TMjOO/Hzbd74I9IG9lP0Tol2QNqkU4YUpIk2dtLFQUwiOMjEzzTSKMFQcbMMMQ/FNLMR6N+sOUIHfga/HK2rK8xvf7uZ116rpr09REaGC4fDSigU5ZVXqnnttQNMnZrFJz85n5ISTZMSGYtMi4E5xD1phvr8I4WSNCIiIjL2lM+HwvGwfxeUTn07UdPRFk/SXHoNpPlO/HyLAzLPhUO/A1fxiatyYuH4I3PZCU9lEqWB12ngdcK0YcGOgYUoQRpZi4/JFPFPatoqQyqD2bSxnR7qcZKDgYGJSZAGLDhJZ/agzhsMRvjVr9bz8sv7GTfOT1lZOsY7/r0UFvro6YmwbVsDK1eu5uablzJuXP/v9eZ2aO6ANBfkpmtYmoikrqS+bXPXXXexaNEifD4fubm5XHHFFezatavPMStWrMAwjD6Pz372s0mKWEREREaFNC9cdSMUlEHlzvhkoV2bobEOll0I51926nNkLgVXIXRWxJc+vZsZhY5d4J0MgfknPE09r1HL8xgYeBlPGiV4GIePiTjIpIUtHOAxInQN/n5FTsHLRPK5CDDoYF/vAwwKuAgvEwZ13uee28crr+xn4sQMMjPdfRI0x7hcNqZPz2H//hYeemgT0Wisd19zOzzwLHz9fvjWg/DN/4GfPgrV9YO7TxGR0S6plTSrVq3i85//PIsWLSISifDNb36T9773vWzfvp20tLcnKXz605/mO9/5Tu/HHo8nGeGKiIjIaFI2Gf71Nti+AeoOxXvRTJwOE6aB9TR6Y7gKoeRTsP9X0L4FHLlgTwdiEGqEUBN4J0HpZ044AaqHIxzhdWx4cZLVb78ND2mU0MZuWtlKFmed2T2LnICBQTaL8TKBdnYTpgM7XnxMwXWcJVCno6cnwksvVeHzOUlLc5z0WIvFYPz4DLZvb2D37kbKy3Po7IafPw4bKiA/E4qyoSsIqzbD/nq49cNQeIre3SIyMsSwEBviGpChPv9IkdQkzTPPPNPn4wcffJDc3FzWr1/P8uXLe7d7PB7y8/OHOzwREREZ7bx+OGvF4J8fmAuTvgpHXoLmN6D7wNGJTpkw7n2QdR44c0/49Fa2E6YNLxNPeIwFB1acNLGRDOb3m74jkkgucgadlHm3LVvqqK5uZeLEU/RtOsrrddDTE2X16oOUl+ewdhds2gvTSsB5tKex2wkZXthSBS9sgo9dmJBQRURGjRH1V0BraysAmZmZfbb/7ne/47e//S35+flcdtll3HbbbSespgkGgwSDwd6P29rahi5gERERGfs8ZVByAxRcgRk6gmmYGI48DNupe8h0UoUVFwYnb7DhIJ0gRwjRgguVDsjoUF/fSSxm4nSe/ksKr9dOVVULAOt2g8P2doLmGIsFsv2wdgdcswLsI+oVi4gcTwyD2Cl+1yXiGqlgxPzIi8VifOlLX+Lss89m5syZvds/+tGPUlpaSmFhIZs3b+ZrX/sau3bt4i9/+ctxz3PXXXdxxx13DFfYIiIiMsaZmPRwhGb7LprtO4jQg4GBm1yymEmAiVhxnuC50dOa3GRgwcQEogmOXmToRKPH6dV0ChaLQSQS70nTHYwnaY7HYYNwFMIRJWlEJLWMmB95n//859m6dSuvvvpqn+033nhj73/PmjWLgoIC3vOe97B3714mTuxfOvyNb3yDm2++uffjtrY2iouLhy5wERERGbNiRKjhFerZQJgObKRhxUGMGK1U0MJuPORRwsX4KOn3fAcZR5uznlyEbqw4sXL83jYiI5HHY8c0TUzTPG7D4OPp6YmQkeEGYHIhvLU33pf73U9vbIfZ4+PLn0Rk5DOxYA5xz5ihPv9IMSLu8qabbuLJJ5/kH//4B+PGjTvpsYsXLwZgz549x93vdDrx+/19HiIiIiIDZRLlIC9yiFex4MDHeDzk4SQDF5l4KSGNIrqpZx+P0U51v3MEKO8dt31MNBqjpaWHlpYeotEYJiYhmgkwHTve4bzFIdUVhspmONAKsYEXXMgQ6OwMUVnZzKFDbcQS8EWZMSOH9HQXjY3dp3V8JBIjHI6xYEEBAEumQ5YfKmshdnTgk2lCbRMYwIo5GsUtIqknqZU0pmnyb//2bzz66KO89NJLjB8//pTP2bRpEwAFBQVDHJ2IiIiksiZ2Us+buMk+YfLEgo00iunkANU8yzSu67P0yctE0iijnT14zBIOVneyZ08TbW1BMMHrszNxRoyCgnQyjDnDdWtDKhyFp/fAP6qgoROsFpiUCf80Gebrz7ek6OmJ8NRTFaxaVUVTUzc2m4UpU7L4wAemMnPmiRtfn0pBgY+FCwt57rl9ZGUdf/z2Ox0+3E5+vpcFCwoBmFAAN7wX/vd52FoVT8jETEhPgyvPhSXlgw5NRIaZpjslTlKTNJ///Od5+OGHefzxx/H5fNTW1gIQCARwu93s3buXhx9+mEsvvZSsrCw2b97Ml7/8ZZYvX87s2bOTGbqIiIiMYSYxGtkMcMrqFgMDD4V0cohW9pLJ9N59FmyM4zIO8CiVDVupqOymp9uDL+DE5gxiOo6wa6eDnr3nMPPck1cTjwamCb/dAk/tBr8TCn0QicHW+nhVzb8ugoWFyY4ytUSjMR54YCPPPbePzEw3RUU+QqEoGzfWUFXVwr/921nMmpU36PO/732T2Lq1gV27GpkyJQuL5fiJmvr6Trq6wlx99Qz8/rcTmWfPhMlFsL4CGtvA54kvcyrLVxWNiKSmpCZp7rvvPgBWrFjRZ/sDDzzADTfcgMPh4Pnnn2flypV0dnZSXFzMlVdeyX/8x38kIVoRERFJFZ3U0E41TrJO63gLNgwMGtnaJ0kD4CSb7I4P8cfHO/EVV5JXHMYwwkTDdjoOTWfv2hy2NJgsm95FVtbxp1eOFvua4aUqKPDCO2/F74SdR+CJXTA3H2yp8WboiLBz5xFefbWa0tIAgYALALfbjt/vZPv2Bp58cjczZuSeMLlyKuPHZ/CZzyzgV79az9at9eTkeMjNTcNqtWCaJi0tPdTUdOBwWPnIR2Zw0UX9e0rmZsAlZ53RbYpIkpkYmEM8fWmozz9SJH2508kUFxezatWqYYpGREREJC5ICzFC2Dn9pIkNL93UH53oZO2zb++uIG89V8TU8ml0ZnRjGCaRHheRbg8+i8mBxgZ27DjCOef0bz48mmxvgPYQlAX67xvnjydx9rfAxMxhDy1lbd/eQHd3uDdBc4xhGBQV+dm1q5GamnaKigbfx3H27Dy++tWzeemlKt544wA7dhzBMOKVVV6vg8WLizjvvDIWLCg47QbDIiKpasRMdxIREREZOWIDfoaBgUkMk1i/JE0oFCUajWHBSbCl74vleAWDSSg0+sdvh2LxqRTHex3usELEhDFwm6NKMBjFMI5fuuRwWIlEYgn53ispCXDddXO47LIpVFQ00d0dxm63Uljoo7Q0oOSMyBhnDkNPmlSZ7qQkjYiIiMi7WHEBBjEiWE7zz6UoIZwEMI5zfEGBD5/PSUtLT+/44WM6OkI4nTYKCkb/ZKdCbzxBE4rGkzLv1NgNGS7IT+BtBoMR3nqrjk2bamlu7sHrtTNrVh7z5xfg9ToSd6FRrKjIB8QnK9netc6ssbGLzEw3ubmJG/2ekeHmrLOKEnY+EZFUoySNiIiIyLt4GYeLTII04ybnlMebmEToJJ8lGMdZM19aGmDevHxeeqkKl8uG220H4kmGffuaWLiwiKlTsxN+H8Ntbj5MyoDdjTA1C+xHEzXtQajvhCvL4V05qkHbs6eJ//mfjezd20QsZuJ02giHo/zjH1WMG+fnX/5lNgvVpZj58wsoKwuwe3e8se+xRE1raw9tbZ1cebkXh6OK5pgXqyUNHz6MFHm3WkQSJ4ZBbIh7xgz1+UeKASdpgsEga9asYf/+/XR1dZGTk8O8efNOa3y2iIiIyGhgw00mMzjES7jI7Ld86d1CtGLHSwZTj7vfMAw+9rE5dHaGeeutWsLh+HIqm83C7Nn5fOIT8wbduHUkcdvh0wvgv96EXY1gEu9L4rLB+WXwwWmJuc7+/S387GdrOXy4nUmTMnA63/6TNhKJUVnZzP/7f29y001nMXdufmIuOkoFAi4+/ekF/Pd/r2fHjgYg3hdy1pR9fPXGvUyaVU99czudVi8N7tlE3CuYaswkHyW4RESS4bSTNK+99hr33nsvf/3rXwmHw71jspuamggGg0yYMIEbb7yRz372s/h8vqGMWURERGTIZTOHFiro4ABeik+YqAnTQZAWCjkbFyeuhsnMdHPrrcvYvLmOiopGTBMmTMhg7tx8XK6xU9w8IQNuOw821MCBVnDYYFoWlOckbqrTX/+6mwMHWpk5s/9UIpvNwqRJmeza1cgjj2xn5szcfst8Us20adl861vnsWFDDYcPt1OUtYm5EzcS9bVSb7diwU9atAtf+yoqY+2s8bZxFkspQMuWROT0mFiGvGeMetK8wwc+8AE2bNjARz/6Uf7+97+zcOFC3O63a1X37dvHK6+8wu9//3vuuecefvOb33DRRRcNWdAiIiIiQ81JOmW8nyqepJ392PHhJBPL0WRNhC56aAJM8jmLQpYfd6nTOzkcVhYuLBzzy3C8DlheOjTnPnSojY0baygq8p2w+sgwDEpKAuzZ08S2bfXMmZPa1TQQr6g5//zxEOuB5ofoiUKl3Y8NG3bshC0B7NFmyroraHRNY5dtB3kUYEmRF0UiIiPFaSVp/umf/on/+7//w263H3f/hAkTmDBhAtdffz3bt2+npqYmoUGKiIiIJEMa+UziwxzhLRrZSicHiS/iAQsO/JSSxWwyKT/lkihJjOrqVlpbg4wbd/KR0R6PnXA4xv79rUrSvFOkAiIH6bQHiNKIi7ffeA1b0vGEq8gPt3DQ1kgbLaSjeekicmrxnjRDm9QdTE+aQ4cO8bWvfY2nn36arq4uJk2axAMPPMDChQuHIMLEOK0kzWc+85nTPuH06dOZPn36oAMSERERGUmcpFPEeeRxFh0cIEoQsOAknTQK1GR1mEUi8X4+pzPS2TAgGh34OPUxzQwBEaLYMKDvSx7DACzYzCgxYkTRvHQRGb2am5s5++yzOf/883n66afJycmhoqKCjIyMZId2UmNnAbSIiIjIELLhJp0pyQ4j5QUCLux2Cz09kZP28onFTEzTJBBwDUtcJiYd1HOEvXTRCBh4ySGbiXjIOuVSuGFjLQRLOu5YN1gNYphYjsZmicUTkG1WDy7ceFGfSRE5PSYG5hD/nDt2/ra2tj7bnU4nTqez3/Hf//73KS4u5oEHHujdNhoGHp3WWz8ZGRlkZmae1kNERERGNhOTbhrppJYwXckOR2RApk3LprQ0nUOH2k56XF1dC9lZUWbPimGa5pDGFKaH3TzHJh6hktdppJJG9rGPV9jIn9nLy0QJD2kMp81WAM6z8ERb8MQMuunCxMQww7gjh2l1FFDnyKKEMpwMT4JLRGQgiouLCQQCvY+77rrruMc98cQTLFy4kI985CPk5uYyb948/vu//3uYox2406qkWblyZe9/NzY28r3vfY+LL76YpUuXAvDGG2/w7LPPcttttw1JkCIiIpIYbRzkEKtp5yAmUex4yaGcAhZjo/+7UCIjjcNh5aKLJvBf/7WexsYusrI8ffabZpiO1p001NTwoQ80keX+O/RMxbR/AMM2I+HxxIiwh39QwzbSyMJLTm/VjIlJkHYO8CYmMSZx3shYHue9BmusjcLQ67RE6ggRxsSg3p5Ple8cyoypTCPxnysRkUQ4cOAAfv/bfcmOV0UD8QFH9913HzfffDPf/OY3WbduHV/4whdwOBxcf/31wxXugBnmAN9auPLKKzn//PO56aab+mz/+c9/zvPPP89jjz2WyPjOWFtbG4FAgNbW1j5fSBERkVTTzkF28wRB2nCTiRU7IToI0U4Os5jIpb2Ti0RGsmg0xh/+sJWnnqogGo1RUODD5bIRDIapq9lEuKeOc8+BT3/cicsVhNhBMDLB/SUMa2J7Jzawh208SRpZ2E9QeRKkgyAdzOaDpDMuodcfNDMMoa2Ew9toMY/Qak8n5Cgn21JKNrma6iRyhlLldeix+9zR+iN8fvepn3AG2tu6KQ/cetqfU4fDwcKFC3n99dd7t33hC19g3bp1vPHGG0MZ6hkZcE+aZ599lu9///v9tr/vfe/j61//ekKCEhERkcQyMTnMOoK04qek951+N07seDjCDrKZTgYTkxypyKlZrRb++Z9nMXFiJqtWVbFz5xHq66PYrK1MHn+Y5ee4OOfsNBwOADdYAhDbDqG/YrrKT6vp8OkwMalnB2CeMEED4MRLF03Us3vkJGkMOzjnYXfOIwfISXY8IiIJVlBQ0G+oUXl5Of/3f/+XpIhOz4CTNFlZWTz++OPccsstfbY//vjjZGVlJSwwERERSZwQbbRxABeZ/RqY2nBjEqWN/UrSyKhhsRgsWTKOxYuLqKnpoKsrjIOnKMw+hNUxu+/BhgFGIUR3glkLRkFCYogSoo1aXKfRYNdBGi1Ux/u/jJQmwiIiCRLDMgwjuAd2/rPPPptdu3b12bZ7925KS0sTGVbCDThJc8cdd/CpT32Kl156icWLFwOwZs0annnmmVHRhEdERCQVxYhgEsVygl/9BhYiI6WxqcgAGIZBYWE8SWL2xCB8gj/iDQeYkfgynwQxiWESw4L91HFiIUYUMEFJGhGRIfflL3+ZZcuW8Z//+Z9cddVVrF27ll/96lf86le/SnZoJzXgVNcNN9zAa6+9ht/v5y9/+Qt/+ctf8Pv9vPrqq9xwww1DEKKIiIicKQd+HPgJ0d5vn0mMGDHSyE5CZCIJZC0CTDCj/feZjWBkgSVx3+dWHDjwEKbnlMdG6MFNYGQ0DhYRSbBjI7iH+jEQixYt4tFHH+X3v/89M2fO5Lvf/S4rV67k2muvHaLPQmIMuJIGYPHixfzud79LdCwiIiIyRKzYyWMOlTxHiA4ceAEwidJODR6yyGBykqMUOUPWhWApAXM3MAWMo42wYy1gtoL9cgzDc7IzDIgFK7lMYw+rTrqMKUaUKGFymZawaxPugo46sNjAXwSGkj8iIu/2/ve/n/e///3JDmNABpyk2bBhA3a7nVmzZgHxXjQPPPAA06dP5/bbb8cR79AmIiIiI0wec+mhmXq20MWRdzQPzmYCF+Fk7E6fkNRgWNIxXTdCz6/ijYKB+PIiD9jfC473JfyaOUymhq20cRg/hf0SNSYxWjmMjzyymHDmF4yGYM8zUPUidB0BixUyJsHkf4KC+Wd+fhGRQRiJPWlGqwEnaT7zmc/w9a9/nVmzZrFv3z6uvvpqPvShD/HnP/+Zrq4uVq5cOQRhioiIyJmyYKOM95BNOa1UEyWEiwwymNhbWSMy2hnWckz37RBdD7EaMJxgnQGWqRhDUG3iJp0pvIddPEcL1Tjx4yANgCDtBGnHRx5TuBAHZ1jFY5qw5bdQ8Tdw+sFXCLEINGyFlkpY+K9QuDABdyUiIsky4CTN7t27mTt3LgB//vOfOe+883j44Yd57bXXuOaaa5SkERERGcEMLPgYh2+kjAEWGQKGJQCWC4btehmUMJPLqWM79eyii0bAwEkahZxNPtNxk37mF2reB1WrwFcA7ndMVXX64chO2P0E5M+NL4ESERlGg+kZM5hrpIIB/wQ3TZNYLAbA888/37u+q7i4mCNHjiQ2OhEREREZ+2LR+MNqj4/LHoW8ZONlOcUsJHi0QbeLAHZcibvIke0Qaof0sv77/OOguRJa9kPmxMRdU0REhtWAkzQLFy7ke9/7HhdeeCGrVq3ivvvuA6CyspK8vLyEBygiIiIiY1CoCw5vhKpXoaU6vpTH6YXSZVB8FvgLkx3hoDjwnPmyphOJhuINgo+XyLI6IBaOP0REhpl60iTOgJM0x0ZWPfbYY/z7v/87kyZNAuCRRx5h2bJlCQ9QRERERMaY2q2w/qF45YfFBq4AYIGO+vj2HX+FKe+DGR8E65kt3TEx6aCFOqpo4wgmJl4yyKOMANknnMg0InkLACOerLG+a1hHVyO4MsCbn5TQREQkMQb8W2/27Nls2bKl3/Yf/vCHWK3WhAQlIiJyJiIE6aINC1bSSMdIkXdeZOQzidFOGzGipOHDztifitlNFz1048BBGr54gub1n9HS2E6TbSppaXZyve8oDjHN+GjpzX+ESA/MvRYsg/s3HCVCBes5wE6CdGHFjoFBDXupYgsFTGAaS3AkcknSUMqfG1/K1LgbsqYStUKUIEawC1tXPUb5h8GVnuwoRSQFmcNQSWOmyN9zCesq5nKNkl9uIiIyZkUJc4DNHGY7PXRgwYqfPEqZRxYlyQ5PUlwtB9nDNlo4QgwTD2mUMImJlGNN3J9kI0YXnexiK4epJkwIK3byw1kUrPkbzz0Lqw9OobMHnHaYPR4+eA6U5BLP1vjy45Uiu56G7ClQsnjA1zeJsYu1VLIZN36yKOqtmjExCdJNNTuIEmE2K0bH18Dugfk3El3/c3oaX6PHbAWixGwOImXzSJu2BH+yYxQRkTMy4N9GFosF4yQN3aLR6BkFJCIiMhgmMSp4jQNsxoGbNDKIEaGJA7TTwEwuIovSZIcpKaqWA6znVcKE8BLAgoVuOtnKOrrpYjZnja5lN6cQpId1vEodh/Hiw0uAMCH2dG/kWSK8dTCXLAOKsqArCKu2wP56uOXDUJR99CSezHhFTeWqeI+aATYUbqGeanaSRjquoyOxjzEwcOHBipUa9pHPeAoYHc12oxnFVCxfQk9NF2ltQaxWL91ZOTTmOHFZXqQcDz4Kkh2miKQYTXdKnAEnaR599NE+H4fDYTZu3MhDDz3EHXfckbDAREREBqKVOmrYSRoZOHtfkDmw46aFGqrYQAbFWFKkVFZGjhhRKthKmBBZvD1kwY6DbhxUs4cSJpBBThKjTKyD7KeeGrLJ7a1QsZt26rY10eK3MfXCGLYN8WXybidkeGFLFby4CT524TtO5C+E2m3QXAWZ4wcUQw37iBDEdZLPqx0nAIeoIJ8JoyJR1sJ+GhwHSCtdRvfR+AECmLRQzSE2MpX8UXEvIiKj2Xe+8x1uvfVWPJ6+zeK7u7v54Q9/yLe+9a1BnXfAf6lefvnlfR4f/vCHufPOO/nBD37AE088MaggREREzlQLhwkTfEeCJs7AII0M2qink6YkRSeprI1mWmjER3q/fS48hAlyhLrhD2wI1XAAG7a+S4jMKDW1QWKdBvbyGBhm7y6LBbL9sGYnhN45nMjph3BnvKHwAB3hEM7TmLLkxkcLDYQJDvgaydBMFSYxbO9I0ED8Z52bDFrYT4jOJEUnIqnq2HSnoX6MJHfccQcdHR39tnd1dZ1RAUvC7nLJkiW88MILiTqdiIjIgESJnPCdYys2YkSJERnmqEQgSpQYMaz0H7AQ/541iI6x7814D5p3FWybMSJRA0sUDJvZ769Qhx3CEYi8c+X8sSVO5sCX08eInlbT8PjXwMQkNuBrJEOUMJbjfC8BWLBh6mediMiwME3zuK1g3nrrLTIzMwd93oR0SOvu7uanP/0pRUVFiTidiIjIgJgmdB1J53AIdh2MYkat+HxQVAQZGdBjdOAkDTeBZIcqKSgNP07cdNOF911tXaNEMTD6bR/tMsjhCA19N1psZPig2QJmvQXelXdpaoeZpfHlT72iITAs8Ya5A5RGgCMcPOVxYYI4cGMbJZO2vORSyxZMzH6J6RDtuMnA8a6KQhERSZyMjAwMw8AwDKZMmdInURONRuno6OCzn/3soM8/4CTNsYCOMU2T9vZ2PB4Pv/3tbwcdiIjIaHdszKwdB2l4U74fQEdHiIaGThwOK4WFvpM2nT8T4TA88gi8+EopWRfl4C2opacunwMHrOzZAwVl7RTPbGSKfQkO3EMSg8jJuHBTzAR28hZ2HDiPjnuOEaWZetLJJo9xSY5ycILBCDU1HVgsBoWFPmy2eOVKMWUcYB8tNBEgAwMD04Ds8kwO7mrg4GtWCmIGFks8yVrfAphw/tx39QdurwV/UXzC0wAVMJF69hMjesLKk/iUpy7GM2t0THcCspjEYTbQTg0+8jGwYGISooMIIfKYhRV7ssMUkRQTwyA2xH/7DvX5T9fKlSsxTZNPfOIT3HHHHQQCb78J6HA4KCsrY+nSpYM+/4B/G61cubLPxxaLhZycHBYvXkxGRsagAxERGa266WIn2znYO2bWRh4FlDODwHF6UIx13d1hnnyyglWrqmhp6cFmszB1ahaXXz6N6dMT2xjVNOEPf4DHH4fcXBdp1efjyf0H/ik1RI0IXZEgB5rt1Lycge2c/cScrzORuafVp0IkkaYwi246OUQVbb29kQzSyWIuS7GPkiqOY6LRGC+8UMlzz+2ltrYDwzAoKQlwySWTWLasmCwjh9ksZCsbaaC293lpeXksfeMgz+x1sLXdjWHE/x0H0uDKc2FJ+TsuEotAdzOUXwaOgf+bzaWEdHJpppYMCvo1DTcxaaGONNJHzWQnABcBJnIh+3iR1qOVQiYmNlwUsZB8ZiU5QhGRse36668HYPz48Sxbtgy7PbGJ8QEnaY4FJCIiECLEWt6ghkN48eLDT5gw+9lHK80sYzm+MbaM4WQikRj337+RF1+sJDPTTVGRj2AwyoYNNVRVtfDFLy5JaKKmogKeew4KCiArC6LN+bSvugKjcBdtvt2EohE6q4uoXFdGrnsn0WVv0UEL83hP71QXkeFgx8F8zqGUyTRSR5QIPtLJY1xvZc1o8thju/jTn7bictnIz/cSi5lUVjZz331vEgpFOf/88ZQykSxyqOUQ3XThxEWuo4DAJBtLL3uK9Q2lNPWkkeaCOROgLP8dVTSxCNTvgNxpMH75oGJ04GImy9nMSzRyCBde3KQBBkG66KYdD35mcg5po2wpZCbjSeMqmthHD61YcZBOSW9ljYjIcIuP4B7anz8jbQT3eeedRywWY/fu3dTX1xOL9e1ttnz54H5/nVaSprq6mpKSktM+6aFDh9SfRkRSwiEOUMthssnBdmzMLHbcuKijjn3sYQ7zkxzl8Nm+vYHXXqumrCwdvz+eBHG77QQCTrZta+DJJ3dTXp6dsKVPq1dDZyeMf8dkXjPkpr4qiybG48GHgYEFGztfLmbe0k4ajAPUUcU4piYkBkku04R9tbB2N+w4CJ09YLfFJwUtmgzzJ4BvhBROWbCQQwE5FCQ7lDNSX9/JM89UkJ7uoqDA17vd53Oyb18zTzyxm8WLx+Hx2PHiZ9I7E9UGMOcacqNhLql4Lj7WyVcIzqPniYbjS5y6m+IJmsWfhbSsQccaIJv5vJdD7OYwFXTQAsRHb09gDuOYgp/sQZ8/mZz4KGBOssMQEUlZq1ev5qMf/Sj79+/HNM0++wzDIBodeNN7OM0kzaJFi7jiiiv41Kc+xaJFi457TGtrK3/605+49957ufHGG/nCF74wqIBEREaTwxzEirU3QXOMgQUPHg5xgJnMOe5Ul7Fo69Z6gsFob4LmGMMwKCrysXPnEerqOsnP9yboehAI9O1hESNGBy3YcfT2BUrPCXJoXxqhdjeG30Id+8dUkiYWg0gE7PZ39fMY47buh7+9CVuroaMHfC6wWyFmQlUdvLETCjPh3Blw6QLwqiVRQmzf3kBTUw8zZvSvihs3zs+ePU3s3t3I3Ln5xz+BzQkLboj3malcBQ27oGU/YMS/gX0FMO0SmLAC0s48gZKGnykspIyZdNOBiYmbNC17FBFJoOEYkT3SRnB/9rOfZeHChTz11FMUFBQk7E3I00rSbN++nTvvvJOLLroIl8vFggULKCwsxOVy0dzczPbt29m2bRvz58/nBz/4AZdeemlCghMRGekihE+YgLFiPTr2OZoySZpgMILVevxfUA6HlUgkRjg8uHcVjiccBuu7PrUmsaNTT97+RW61QigGkYiBFSsRwgmLIVlCIdiyE159Eyr2QTQGLiecNRcWz4XxJWM7YbNqK/zmRWjrgnFZMD63//1GolDbAr9/GfbWwI0XQ1bqrD4cMqFQFMMAi6X/N5jdbiEaNU/979xqgwnLoewcaKyAziMQi4IjLV5B40j8dCIHLhyjcGmZiIiMTBUVFTzyyCNMmjQpoec9rSRNVlYW99xzD3feeSdPPfUUr776Kvv376e7u5vs7GyuvfZaLr74YmbOnJnQ4ERERroMsqil5rijULvpJo98bCk0ZWPcOD/RqEk0GsNq7ftuR2NjN1lZbrKzE/fudX4+bNzYd5sFKw5cdNPe24y1q8OKxxfF7Q3TRpAAiW1gPNz2H4Rf/wF274tX0WQE4omo1nb481PwzEtw9kL42JXgHoOvSVfvhAeej6+cmV584mSUzRpP4GT74suhDOCm96ui5kwVFHix2y10dYXxePr+fGtq6iYQcPZZBnVSFgvkTI0/RERk1Ir3pBnad4dGWk+axYsXs2fPnuQkaY5xu918+MMf5sMf/nBCgxARGa2KKaGKfbTQTPqxMbOYdNIBQBkTU2oU98KFhZSUBNi9u5EpU7J6EzUtLT20tgb5wIcmEXK3EcbAS+CEY3FP17JlsG5dvKLmWGN9A4MA2XTTQZggVtNJW5ODpZcepNvRgAsvhUw401tNmupDcO/9UF0Dk0ogFukiGAzidDnJy/ZQXADNrfFETU8QPnMtOMdQj+TmDvjtSxCNwsTTbO3icsDUIlhbAU+vh4+cM6Qhjnnl5TmUl+ewaVMtU6dm4XTG/5zs6gpz8GAbF144gaKit5M03XTRQzcOnKSRmKWOIiIiyfZv//Zv3HLLLdTW1jJr1qx+U55mz549qPMOeLqTiIi8LYMs5rKAzWygnjoM4qNQnbiZzkzGUZzsEIdVRoabG29cwH//93q2bz8CmJimiSfNzns+6cL33l28wnoMDPxkMIHpFDJ+0Ims+fNhyhTYtQvKy99e+uQlg0x6aDbrqaow8BUdoXDpdmw4KWfJqG0UGo3CQ4/A/sNQktfOlvU7qTlYQyQSwW63U1hcyLRZ08hMT8PpgJfXwKQyuPSCZEeeOG9WQE1zvIJmIFwOyPDCK9vh0oWQNgYrjIaLzWbhU5+az333rWPXrkai0RimGV/SuGTJOK69djaGYdBFJzvZxiEOECaMDRv5FDKNGfhH2TQlERE5uVTsSXPllVcC8IlPfKJ3m2EYmKY59I2DRUTkxEooI4tsDnOIbjpx4CSPgt7KmlQzfXoO3/72CjZsqKGmph2n00bmkiO0F+4haIlPezGJ0cIRNvIqUaKUMHlQ10pLgxtvhF/+Mt5EODMzPorbYjEItRTQWp/FuLw2rvxkLfMLF5BLCZ5RPBJ95x7YsQfyMrpY9+oajjQcwR/w405zEwqGqNhZQWtLK8tWLCPN4yLNA/94Hd5z9tiopolE471o3A6wDuLvtPx02H0YNuyNNxOWwSss9PHNb57Lpk21VFa2YLUaTJ6cxcyZuTgcVoL0sJbXqaUGLz58+AkTYh97aKGFZZyLl9NcEiUiIjICVVZWDsl5laQREUmANLxMHkPTgs5UerqLCy6Iz8XupI1XeAsXHrzvSJBk4qKFI+xhCwWU9vaPGaiyMrj1Vli1Cl55BQ4fjo9l9noNrvyAkxUrcigrG909aI5ZsyneMLixfT9HGo6Qm5+LxRLPVtjtdlxuFw21DRyoOsDk8skU5sK+A/EGwwvHwKTefbVQWQdFg5zIbLfFkztrK5SkSQS3287SpcUsXdq/rOkg1dRRSw45WI/+uWnHjgs39dSxn33M0PhoEZExIxUraUpLS4fkvErSiIjIkGqklh66yKb/OF4f6bTQSDMN5FI06Gvk5sJHPgKXXgr19fFmullZkJ5+BoGPQJUHIM1jUrHrAC6XqzdBc4zVasXusHNw/0Eml0/G6YwvkWpoSlLACdbeDcFwvJJmsNwOaGpPXExyfIc4iA1bb4LmGAsW3Lg5wH6mM6vPFDYREZHR5De/+c1J91933XWDOu+AkzQvv/wyy5Ytw2br+9RIJMLrr7/O8uXLBxWIiIiMTVGiGHDcpV+Wd4wpT4S0NBg/PiGnGpEiEbAYEAlHsNpOMPrdZiUcenvEuGHEnzcWRGNnfg6LBcJj5PMxkoUJYz1BY3ArVqJEiWGeYetwEREZKVJxutMXv/jFPh+Hw2G6urpwOBx4PJ7hS9Kcf/751NTUkJub22d7a2sr559//qCb44iIyNiUhh8LVsKE+i1p6qELJ27SRnGfmOGU7oeqgwaZOZlUV1Xj8/fv6RHsDjKuZBwQX/YVMyEtcVPPk8pljydZorH4eO3BCEfAOwabBnd2hti4sZbNm+tobw+Rnu5kzpx85szJw+22n/oECZZJFkeoP+6+bropYhwWVdGIiMgo1tzc3G9bRUUFn/vc5/jKV74y6PMOOElzrFPxuzU2NpKWljboQEREZGzKJp8s8qnnIJnk9i5/CBOinRbKmIb3DCe9dNFJkB6cuPAwdn8XLZoDa9+C4rJSDh84TFtrGz6/r3eSQFtLGw6ng5LxJQAcaYaMAEwfXF/mEWdcdnxC05E2yM8Y+PNNEzp6YNoYG7r21lu1/OY3b1Fd3YphGDgcVkKhKC+8UMn48enccMNcysuHty9TCaVUU0ULzQRIx8DAxKSDdixYKWVCSjZWFxEZq1KxJ83xTJ48mbvvvpt/+Zd/YefOnYM6x2knaT70oQ8B8ZFSN9xwA853jImIRqNs3ryZZcuWDSoIEREZuyxYmc1SNvEqjdRhEuvdXsh4ylkw6BdrHbSzk20c5hBhwtixU0gR5cwkDW8ib2NEWDAL8rKhuyefmfNmsnPLTupr6nuTNJ40DzPmziA7LxvThJp6uHg55Oee+tyjQaYPlk6Dv64dXJKmpRP8Hlg8JfGxJcu2bfX88pfraGsLMmVKFnb72yVGoVCUPXua+MUv1vGlLy1h0qTMYYsrixzmMJ8tbKKeOgwghokbNzOYRSHjhi0WERGR4WSz2Th8+PDgn3+6BwYC8Xc5TdPE5/Phdrt79zkcDpYsWcKnP/3pQQciIiJjl5cAi7mIeg7SShMWLGSQQzaFJ+xbcSrddLGG1zhCPV78uHETIshedtNKK8s4FzdjZJ3PUQE/XHYhPPSIQWbeFM4ryKP2cC3BniAut4uCogJ8AR+xGOyuhMI8uPi8ZEedWEunwvOb4gmX9AEUTZkmHGqEZeVQMjaGfRGNxvi//9tBc3MP5eXZ/SqdHQ4r5eXZbN3awOOP7+Tmm5cetxp6qJQxgWxyqOEQ3XThxEUeBb2VNSIiMnakYk+aJ554os/HpmlSU1PDz3/+c84+++xBn/e0kzQPPPAAAGVlZdx6661a2iQiIgNix0EREyhiQkLOt59KGqgnl1wsRxM9duy4cdNAHdVUMZXpCbnWSHLxedDRBY89C+FwgMJxAQL+eIPgaDRePVPfCMUFcOO1UDbGlvZMLoSzy+G5TeC0n96kJ9OEqnrI8MElC+Kfq7Fg165Gdu06QklJ4ITJF8MwKC72s2VLPVVVLYwfP4gSpDPgxcdkpg3rNUVERIbDFVdc0edjwzDIycnhggsu4Mc//vGgzzvgnjTf/va3B30xERGRRDlANU6cvQmaYyxYceDkINVjMkljscCHL4Xx4+Cl1bB1FxysjSceTCAnEz70PlixFIoLkx1t4lkscN0F8d4yb+yMV8WcrKImEoXKenDa4IYLoHwMJa2qq1sJBqN4vSfPVAUCTg4caGX//tZhT9KIiEhqMIehJ405wnrSxGIJGDt5HANO0tTV1XHrrbfywgsvUF9fj2maffZrupOIiAyHCOHeJsTvZsVKmPBx940FhgEL58CC2bD/IByui08tcrtgchlkpCc7wqGV5oJ/vTQ+pen1nVB9BHL8kO2LT30yTegKQU0T9IShKAuuOx8WjpEGysdEIrHTqgoyDAPDMIgmYoa5iIiI9HMsL5KIZcUDTtLccMMNVFdXc9ttt1FQUDCsa5tFREQg3oujZYeLV948SKyhHbvDoHiqm6kLvQSy7fTQQwFFyQ5zyBlGfDnTWFvSdDrSXPCZ98F75sDqXfGqmj01EInFPy8uO0wsgBUzYcEkCIzBVdqBgBPTjCdrbLYTv7sYCkWxWAwCgTE4e1xERCSJfvOb3/DDH/6QiooKAKZMmcJXvvIVPvaxjw36nANO0rz66qu88sorzJ07d9AXFRERGaz6+k7+5382snZzPYeD3XiyQtjcVra81corj9qYd4WbORe7KbGMT3aoMoSam7tpbu4h3evgY+d7uOwsg0ON0BOKV9N4XTA+D6yD60s9KsyZk09enpe6ug6KivwnPO7w4XaKi/3MmJGcjslNTd20tPTg9TrIzR2D2TIREUnJEdz33HMPt912GzfddFNvo+BXX32Vz372sxw5coQvf/nLgzrvgJM0xcXF/ZY4iYiIDIeWlh5++ct1bN5cx/gZmeTPi9Fd0oLpiGFGTNp29/Da30KUMZ7cS/KSHa4Mgebmbh57bCerVx+kszOM02ll7tx8rrhiGjNLA8kOb1j5/U7OP7+U3/9+Gz6fE7/f2e+Y5uZuOjtDXHXVdNxu+7DG19jYxWOP7WTNmkN0dYVxuWzMnZvPBz847aRJJRERkdHgZz/7Gffddx/XXXdd77YPfOADzJgxg9tvv33QSZoBp6JWrlzJ17/+daqqqgZ1QRERkcFataqKzZvrmDozE+O8dmzlUbz4cbWl4Qq7yZudRukHvKx5uYWmxu5khysJ1tER4uc/X8sTT+zCYjEoKvLh8dh58cUq7r13DTU17ckOcdh94APTuOiiCRw82MauXUdoawvS0xOhtbWHnTuPUFfXyT/90xTe+95JwxpXW1uQn/1sLU8+uRurNf61crlsvPjiPu69dzV1dR3DGo+IiAytYyO4h/oxktTU1LBs2bJ+25ctW0ZNTc2gzzvgSpqrr76arq4uJk6ciMfjwW7v+65MU1PToIMRERE5kZ6eCC+/XE0g4MQoCxIq6MbW6MSIWHAARMDsNnEU9dBQ2Mybbx7m4ouH94WpDK01aw6yaVMd06Zl43TG/4Rxu+2kp7vYurWeF1+s5NprZyc5yuHlcFj55CfnUV6ezapV+9m7t4lwOIbDYWX27DzOO6+UJUvGYbUOb4n46tUH2bKljvLyHByO+Jozt9tORkb8a/XSS/u5+uoZwxqTiIhIIk2aNIk//elPfPOb3+yz/Y9//COTJw9+WsGAkzQrV64c9MVEREQGq6amnbq6DgoLfYQKWjBiYET6vvA0MLB02XBO7WFHRcOYS9LEYiaRSAy73ZKSjfvffPMwDoelN0FzjNVqISvLw+rVB7nqqhnY7WO4Ec1x2O1WzjuvjHPOKaGmpoOenghut42CAh8WS3K+T9auPYTLZetN0BxjtVrIzHTz+usH+MhHpictPhERSawYxjD0pBlZvzPuuOMOrr76al5++eXenjSvvfYaL7zwAn/6058Gfd4BJ2muv/76QV9MRERksCKRGLGYidVqELPFIHb8X9RG1MBig1AsOswRDo1gMMLmzXW89toB9uxpIhqN4fHYOeuscSxeXERpaSBlEjZdXeF+L/qPcTishMOxo0ms1ErSHGO1Whg3bmT0eunuPtXXKko0GsNiSc2vlYiIjH5XXnkla9as4Sc/+QmPPfYYAOXl5axdu5Z58+YN+rwDTtJUV1efdH9JScmggxERETmRQMCFx2OnvT1EWrOD0LhuTEyMd72rEvNECVVayE/3JinSxNm3r5lf/3oDe/c2YZqQkeHCYjFobu7hj3/cytNPV3DeeaX88z/PwuUa8K/0UWfy5Cy2bKnHNM1+iammpm7mzMlLic9DMkXooJ2ddFJJlCB2AviYQhoTsbzjz8pJkzLZufPIcc/R1NTNokVFJx0bLiIio0u8kmZo3zQaaZU0AAsWLOC3v/1tQs854L9kysrKTvqOXTQ6Nt65FJHUFCZKK50YGKSThnWEjfpLZbm5acybl88//lFF+cF0ghM66HH1EKyyYrUapGVZMb0xIpEoRoWbhVcWJTXeYyOivV4HOTmeAVe7VFY2c++9qzl0qJ3JkzP7LfEpLvbT1NTNX/+6m+7uCJ/+9PwxX0GybFkxL71URVVVC6Wl6VgsBqZpUlfXicVisGLF8f9GCRKmnS6sWAjgxTIC/8gb6UxMWtlMPc8TohGwYGDDJEQza3FTTCEfwEU+AGefXcKrr1ZTVdVCSUmg92tVW9uBzWbhvPNKU6YCLBV100MnPTiw4SOtXzJdRGQsqa+vp76+nlgs1mf77NmD65M34CTNxo0b+3wcDofZuHEj99xzD3feeeeAznXXXXfxl7/8hZ07d+J2u1m2bBnf//73mTp1au8xPT093HLLLfzhD38gGAxy8cUX88tf/pK8PI1WFZHEiRFjO4fYxkFa6cIAMvExmxImkac/MEeIFSvKePPNw+zf1EnHbtjX1EJ3VwSLDdLL7Ixf6iF2wMFcRzFTp2YlJcbjjYieMyc+dri4+PRGRIfDUR58cBOHDrUzfXrOcft2GIZBVpYHh8PKP/5RyeTJmVx00cRE386IMmFCBjfcMJff/nYzW7fWYxgGsZhJerqTK6+czuLF4/ocHybCFqrYzSE66caChVwCzGYCxeQk6S5Gpza2cpgngBgeSjF4OyEYJUgnVRzkEYq5BifZTJmSxXXXzeHhh7e842sVIyPDzVVXzWDhwsLk3YwMmR6CbKWCSg7SQwgbVvLJZhZTySY92eGJyBAysWAO8ZubQ33+gVq/fj3XX389O3bswDTNPvsMwxh0AYthvvtsg/TUU0/xwx/+kJdeeum0n/O+972Pa665hkWLFhGJRPjmN7/J1q1b2b59O2lpaQB87nOf46mnnuLBBx8kEAhw0003YbFYeO21107rGm1tbQQCAVpbW/H7R8Y6bREZedaxl3XsxY4NPy5MoIUuDAzOYxrTSG5Vhrztuef2cttt/+DAgVb82Q68BVbCRGipDWGELFxy/lS+c8f5ZGd7hj22jo4QP/nJG2zYUEN+vhe/30lXV5hDh9oZPz6DW25ZSmGh75Tn2bSplrvvfpXS0gBut/2Ux+/d20RxcYA77lgx5qtpAOrqOtiwoYampm68Xgdz5uT3680Tw+RVtrKN/Xhw4sVFlBjNdODGyfnMUaLmNEUJUsl/EaQJD+OOe4xJjA72kcNyCri0d3tt7dtfK7/fyZw5eZSUpE4fpVQSJsLLvEkVh/DhwY2LMGFa6CCAj/NZRKYSNZJCUuV16LH7fLL1UdL8aUN6rc62Tt4f+OCI+ZzOmTOHiRMn8rWvfY28vLx+v9tKS0sHdd6ELdyeOnUq69atG9BznnnmmT4fP/jgg+Tm5rJ+/XqWL19Oa2sr999/Pw8//DAXXHABAA888ADl5eWsXr2aJUuWJCp8EUlhrXSxlQN4cJLO2y/s8wnQQBsbqGICeTgS9yNTzkBhoY+sLDcej42WliA9h6IYhp2CNA+WNIPcnDSystxJiW3t2kO89Vb/EdEZGW62bKnjH/84vRHRb7xxgGg0dloJGoh/Tvbta2b79gbmzMk/o3sYDfLyvFxyyclHW9bTwh4OkYkXD67e7S4c1NDEZvZRRLaWPp2GDnbTQx3uEyRoAAwsOMj4/+zdd3xc13ng/d+5907v6B0ECPYqFlFUL5YlWYpky07c4ia3ZB1Lsexs1im7tnc3To/9OnGNo7WduMRxl23JsiSqUuy9gAUAQfSOwfRbzvvHgCBBACQAEizi+frDj8Up555773Bm7jPPeR6G2UcRN+Mi/+W5rCzIm940+zakytWjjS5O0kkxMdzk37tcGPjw0kkfjTSzkdkX0lQU5crmoF2C7k5XViZNU1MTP/rRj2houLjdRGd8xRGPx8f9XUpJZ2cnn/nMZy6oFzjA8PAwAAUFBUA+fcg0Td7whjeMPWbx4sXU1NSwefPmSYM02WyWbDY75XwVRVHO1sEgSbJUEptwX4wAPcTpYogaii7D7JSzHTzYi8djsH59JclkjmzWRtMEwaCbRCLHiRNDdHYmppWxcrFt396ByzWxRbSmCYqKpt8iurl5iHDYM+3t+nwuLMuhpyc5q3m/HnXSTxaL4jMCNJBv0x4jRA9DDDJCIZf/l7grXYZuJA4a5w4auomR4gQZusaCNMq1o51uQIwFaE4RCEL4OUk3a8jhwX15JqgoinKR3XXXXezZs+fyB2mi0eiENB4pJdXV1Xz/+9+f9UQcx+GP//iPuemmm1i+fDkAXV1duN1uotHouMeWlpbS1dU16Tif//zn+exnPzvreSiKcu2xRmvFT1Z3RkfDQWLhTHyicllkMvZYjZZAwE3gjMzaU22Yc7nLU8T+XC2iXa7pt4i2LGfGy0GEANu+KCuYXxesc/SAMNCwcbDVv+tpkVgwrYwjQb7EsGoicS0ysdCneJ3oaOSw1L85RXkdkwjkHGenzvX4M/Wv//qvvO9972P//v0sX74cl2t8kPrBBx+c1bgzDtI8//zz4/6uaRrFxcU0NDRgGLNfCvCxj32M/fv38/LLL896DIBPf/rTPP7442N/j8fjVFdXX9CYiqK8vkXxo6GRxcJz1ttikiw+3MS49PVNZkMi6SXBcfrpI4kAigkwnyIKXycdNiorQ0gpsW0HXR+f9trfn6Kw0EdJydyuiZ5KQ0MBe/d2X3CL6GjUO6OsGMeRSAmBwPSWR10LIgQAgYlNkixDpMhho6EhkBTgJ3yV/Lu+3AxC5Kv8TGx5fyabNGK0n49y7SkgSjNtk75OkmQoJIpXZdEoivI6snnzZl555RV+/etfT7jvQgoHzziqctttt81qQ+fyR3/0Rzz55JO8+OKLVFWdXu9cVlZGLpdjaGhoXDZNd3c3ZWWTr7n3eDx4PNNPEVcURakgRgUxWumnnAjGaNeSLBYDJFlGFVEuz0X/TGSxeIkmGukmjYV7dD8O08MO2lhKGTdRh4uru7Ds2rXl1NZGOXKknwULCnEMSQ6L5FCOwaEMDzywEL//8gQrZtsi+hSJpB+L+g0lbNvdiePISTs7na23N0lhoY+lS8cXwnWQDJPEwiGED+95lqu8ntRQTAAfOzkx9subhsDCJouJjaCVfhZQ9roIXs6lEAvpJYRFHBdTdyjL0UeAOnyozk3XoloqOEIzfQxSSGw0HCpJksbBYQG1aFdYPQlFUS6ea7Emzcc//nF+//d/n7/8y7+8qN2nZ5X6cvz4cb7whS9w6NAhAJYuXcpjjz3G/Pkza/0ppeTjH/84P/nJT9i0aRN1dXXj7l+7di0ul4tnn32Wt771rQA0NjbS2trKxo0bZzN1RVGUCXQ0bmUJz3OAToZwkIDEQGc+pWxkwRV/EWfh8DxH2U8nhQQoJjg2Z4lkhCw7OImDw+00XNVflCMRLx/5yFq+/PWtvHS4hRGZxZYOHr/O9W+sYN2byi/b3GbaIvpM7eR4liGOkGZ4naDjx5J0Zy/rK4vwnON8OY6kuzvBAw8sorDQf8Z4A+yihS6GsHEI4GUR5aymFtc1UAQ7i4WFwB5NwNYAB3ChUUwhAXy8wCEEsIDL95q5GngoJswyBngNDQ/6WXV+AHIMAIIY6xBX8fuLMntRQmxgFVvZRxd9o4vfwIOb5SxgPiqzXVGU15f+/n4+8YlPXNQADcwiSPP000/z4IMPsnr1am666SYAXnnlFZYtW8YvfvEL7r777mmP9bGPfYzvfve7/OxnPyMUCo3VmYlEIvh8PiKRCB/84Ad5/PHHKSgoIBwO8/GPf5yNGzeqzk6KolxUMQI8wBpa6aOXETQEpUSoomAss+ZKdoIBDtNDCSF8kxRtDONFR2M/XTRQTM0kRZKvJvWLo2z4X2Vkd2awOyQBj5uSZX5ciwTPakd4gGUUXabsp5tvrmHBggJ27uykvz9NKDR5i+gzdZPjO/TQQY4SXEQLQ/TeX82Ofz+G5RngpqICXJNc+DqOpLGxj+rqKHffXT92ezsDPMM+kmSJEcBAI0GG1zjGCGluZ+lVHaibjv2cZIQsq6ljhDQZcmhohPARxItA0EOc7TRRQxGeayjLaDZKeSM2SeIcQMONiwI0DGwy5BhAYFDMHYRZfrmnqlxGNZRTQIQ2ukmQwo1BBSUUEr3if+xQFOXCOIhzVIO7eNu4kjz88MM8//zzM05WOZ8ZB2n+x//4H3ziE5/gr//6ryfc/qd/+qczCtJ85StfAeD2228fd/sTTzzB+9//fgD+6Z/+CU3TeOtb30o2m+Wee+7hy1/+8kynrSiKcl5uDBooo4Grq4WxRHKYHiRyQoDmTAHc9JPiCL1XfZDmOP10R4a59Y7asWVdkD8WrQyyjw7u4PK1/Z1Oi+gzvcYI7eRYgHesJfSN99dhJG22/aKFLb0mKypihMOe0TXODt3dSXp7U8ybF+GjH11HdXV+GYpEspsWkmSpOOPCqIAgPnIcoYuFVFBFwcXf8StEkizH6CaMDw+uKQMwBQToJj627EmZmoGfSt5GkAUMspMsPUhsNNyEWUaM6wiySF2IKwTxs5i68z9QURTlKrdw4UI+/elP8/LLL7NixYoJhYMfffTRWY0rpJQzagXh9XrZt2/fhHbbR44cYeXKlWQymVlNZK7E43EikQjDw8OEw6odpKIorz85bL7FVgQQwXfOxw6QwoXOB7j+qr6Y+jUHaaSHSqIT7hskhYbgvazHfRUs68nh8De0YyIpOSuY4DiSzdvasF4YwH8gTTJpcioZp7g4wM03V3PbbfOorDz9+TZIkh+xBT9u/Eys0XaSfq6ngQ1c3HaRV5JW+vgFOyknct6MoTYGWEs9Gy9jUO9q42CRow8HCx0fbgqu6vcTRVGUuXCtXIee2s8fDf+SQHhus5iT8SRvjdx/xRzTs8u1nEkIQVNT06zGnfG31+LiYnbv3j0hSLN7925KSkpmNQlFURRl9pzRriv6NJavnCrkeL4uLVe6LNaU+2ugY2Jj4VwVfUQsJBYSY5LzoWmCmg0l1FxfxX3NPrq6Epimjd/vYuHCQiKRibVBbGwcJPoUy/TEaPHc1zNnBq9xQf7fkDJ9GgZelXmkKIqinEGijVaAm9ttXEmam5vnZNwZB2k+/OEP85GPfISmpiZuvPFGIF+T5m/+5m/Gtb5WFEVRLg03On7cDJM5b+PbNCblhK76eiSlhDlG/6QX4iNkqSR81XQy8qFRjpujpCk462NZIknhME94qa+PUV9//mVqIXwE8JAkg4fguPscHHJZm+P7htn+ymtkszbFxX7Wrq1g6dJiDOPqfl2c4seNG4MMJr5zhOokEgdJYJKMI0VRFEVRlMthxkGav/zLvyQUCvEP//APfPrTnwagoqKCz3zmM7Nec6UoinI5ZDAZIY2BRpTAVZtZoqGxhFKe5xg2DjlsHBw8GOOKHts4mNgsnsNfwCUOafqR2JiJAIO9Fm63TkVF6Jytp6di4TBAGoAY3rH24QsoZh8ddDNCCaGxDKE4WSSSpZSN1Xa5kiQSOXp7k7jdOuXlITRNIBBcT5CjpOnDpBADgcBB0k6OGDrzBnWaBgcJBt2UlJw7ldiDiyVU8ipH8JIdW/JkS4e93R20H0gy8MQwbsuFYWik0ya/+c1xli0r4UMfWkNZWXDCmH1xiKcg4ofCGWYXT3UOp3y85dDRMYLjSMrKgni9M1+yVkSYMqK0M3DOIE1y9PjUUISUks7OBMlsDr1IIxhyT5hvNmvR2ZlA0wQVFaHXTVBLURRFUS6UvAQtuK+0TBqAtrY2fv7zn9Pa2koulxt33z/+4z/OaswZf/MRQvCJT3yCT3ziE4yMjAAQCp3vt1tFUZQrRw6L3ZygkQ5SZNHRKCPKdcyj8iotprqAYl6lme20IhBI8hk2xQQpJ4xA0EmcEkLMp3BO5jDIcTrYRn+6g5efzLH3BYPcUJiQUcTiRcU89NBili4tntZYEskBethFJ/2jF/iF+FhNOcsooYgAd7CAFzhOG0OjzwEfLq6nhsVc3FaIFyqTsXjyySO88EILg4MZDENj4cJCHnxwEcuXl7CaAL2YvECco2TGWtf6Bmy0n/bxxdf2kkqZeL0Gq1eX8eY3L6aqaupoyQpqGCbNETroJ4kAenuTNO0YwXglwLK6AnT99BedVMpk585OvvSlLTz++MaxVt5dg/DT12DHMUjlwO+BdQ3wlhugJHrufc6fw97Rc5gCoGD0HC6nZEIQTUrJli3t/OpXR2lpGUJKSUlJkLvvrufuu+vHzfd8NARLqaSDQYZJEcE/4TE5LAZJspxqehrT/OvPdvPqoTZ6zCRaBOpviXHL79SwIVDNEruI537bzDPPNNHdnUAIwbx5Ue67r4EbbqiaVQBSURRFUZSr27PPPsuDDz5IfX09hw8fZvny5bS0tCClZM2aNbMed8aFg68210rBJkVRpsfB4XkOcpB2AngI4sHCZpAUATzczYqrMlBzkkF+yG6aGQQkXgwkYGITxksYLyUEeSOLKefivxcOcJTj/IqMleaXX/Wy7bkcwQKLUKGFK1vGSHuUwgIfjz12w7QCNTvoYBMt6Aii5OuuDJHBRnIbtayjEoA4GZroZ4QMXlzUEKOE4BWVFWXbDl//+g6eeaaJggIfhYU+cjmbtrY4sZiPRx/dwPLlJcjRzJnDpEnj4B5xeP6f9tK4q4eysiDhsIdk0qSjI059fQGf+tSNk2a9nOIg6WKIdgaIpzL88BuHyTYKaksmf32bps3Bg728610r+d3fXUp/HP7+p3C4DSpiEPRBIg0dA7C0Bj71FohNvXl208lzNCMQxM46h7dSy/rRc3jKyy+38vWv78A0bSoq8llGvb0pkkmTt71tCW9/+8xaO0skO2lhO03Y2GOdnmwc4qTJYVFPKdVHy/jKP+2gsaePXKWNy6thD0oSvTlqbguz8WPV2D+V7PphFz6fQUlJAMfJZ93ouuBDH1rDbbfNm9HcFEVRlNe/a+U69NR+/ufwb/DPceHgVDzJ70XeeMUc0+uvv5777ruPz372s4RCIfbs2UNJSQnvfve7uffee/nDP/zDWY0743yh/v5+Pvaxj7F06VKKioooKCgY90dRFOVK1s4gR+miiCAFBHBj4MdDBVGSZNjDCSRXV+w6fzHaBgjWUkktMYzRt3eBIE6GRZTwIMvnJEDjYNPBFmyy9B4sZe8rNpXzPFRWhQj4/BjRfhYs9dHfn+bJJ49wvt8GkuTYTgdudMoI4sXAi0EZQTzo7KCTJPl00jBeVlPJLcxnPTWUErqiAjQAhw718dJLrdTWRqiqCuPzuYhEvCxdWszAwOljIhBU4eENRPkdCtBeG+bonl6WLCmmtDSIz+eiqMjPsmUlHD8+wKZN5y5WpyGoIMZ65iNe89P7ok1VYXTKx7tcOgUFPl566QSJRI6XDuYDNMuqoTgCPnf+/5dWw8GT8MqhqbedwmQr7bjQKJ/0HHaQ4HRKcCZj8bOfHcZxJIsWFREKeQgE3MybF6WoyMdvfnOcjo6RGR13gWAN87iHlcynlCwW/SSIk6aAILezlDvlUp79ZQsd3XF8ywyCMTcFPh/FFX7K5wfpei3JieeGefLpI4QK3NTVxQgE3IRCHhYuzGek/fznR0inzRnNTVEURVGUq9+hQ4d473vfC4BhGKTTaYLBIJ/73Of4m7/5m1mPO+PlTu95z3s4duwYH/zgByktLVUpvoqiXFU6GMDCnlCnQiCIEqCTIeKkJ10ecaUaIk07wxSMFowN4KGCCFksAHpIUIif2BztU4oeknTjo4hj+7PkspJgOB8k0nFjkiYn4lRWFnD4cB/d3clzZoC0M8IwGSonKYMcw0s7I7QRZxFFc7I/F9vBg71kMtaETkxCCKqqwjQ29tHZmaCiYvz+btvWgcdj4HaPr+Gi6xoFBT42b27jd3932bSWAR07NoCmifM+tqQkQEvLEO3tcTYfLiLiA+OsEjIuA0JeePUQPLB+8nHaiTNMlgomnucCfLQRp404i0fP4fHjA5w8GaeuLjrpnPbv7+Hgwd4Jx+h8BIJ5FFNLEXHSZDDR0Yjix0BnYDDN/v09BMvddIskoTPeF7xBA8eSnHh2iPhgFu/yiV+ZqqsjHD8+wNGjA6xceWUtsVMURVGUS8m5BDVp5nr8mQoEAmN1aMrLyzl+/DjLli0DoK+vb9bjzjhI89JLL/Hyyy+zatWqWW9UURTlcjGxp8yzMNCwca669sTWaBPuM4sEu9DHCp4OksKawxbDDhYONhoGuWwWbdxFvRitr+LgdutYloNpnvv4njr+kxX+1Ubr7czl/lxsmYyFpk3+qjt1THK5iccklTInBGjOfF4uZ2PbEv3cdXiBfDFeXT//jyqaJnAciWVJ0rl8QGbS7RuQyU1+H5x+TZ77HJ7e51zOxrIcXK6JOyOEQAgmPUbTJRBE8BM56/ZT29XdGhImZGFpuiCXshECpJiYAeZyaViWPO9rWlEURVGU158bbriBl19+mSVLlvCmN72JT37yk+zbt48f//jH3HDDDbMed8ZBmsWLF5NOp2e9QUVRlMspRhAHJr2ATJAliJcQvsszuVkK4yWIhxGyeM56W89fKkMBc7dG2EsMN0GyjFBa5caxGQ0eCCQOIDDw0defprDQR1HRuTN6Yvhwo5PGwn9WG+00Fh50YlfROaqqCiOlxLadCZks/f0pCgp8k3Zsamgo4NCh3vxSqLOyVgcHM6xZU47LNb1flAoL/eRy9qRjnSmRyOH3u4hGPSysgBf2Q+UkdaaHUrBuwdTbi+HDi0EKk8BZWWtpTNxnncPy8hDesJddXSnMaAAbCGhQYUAgZ2IYOuXl5yiAM0sFBT6Ki/2c6BnGCAtMbNyjwU3pSCzToXhxgN6+FDIFZyej9feniUY9lJerBgqKoijKtc1BXIJMmitrFc8//uM/kkgkAPjsZz9LIpHgBz/4AQsWLJh1ZyeYRU2aL3/5y/z5n/85L7zwAv39/cTj8XF/FEVRrmTzKKaQIF0M4ZyRjZEiS5oci6nAPfP49WXlwWAZpaTIjdVqgXyAppMRighQP0cdnQDcBCliKVmGWbQOymsMThwxsWybHCO4CZIZ8jI8nOWOO+bh87nOOV4ZQeYRpYcUuTOzLbDpJkktUcomWUZzpVq7tpza2ihHjvRjWfnXnOl36NHTDFoZbr99Hn7/xGNy003VRKNeTpwYxnHyWRz5NtEj6Lrg9tvnTXvJ8Zo1ZQSDbuLxbH4cJCksEuQwz/h30NmZYMWKUioqQtyyLF+Hpq0fTpURkhJO9kHAAzcvzc+nuztBU9MgQ0OZsXFKCTCPKL1TnsMI5aPL2RwJu4NBWhdXsuPECC3DObotaMzCC8M2vz44QNWCQpYvL5ly/0zT5sSJIVpahmaUceN269x1Vz1OSqIPaKQwsZE4tqTzaJJopZfat4ZZuKSQrmPJcWOnUibt7XHWrauY8TIsRVEURVGufvX19axcuRLIL3366le/yt69e/nRj35EbW3t2OO+973vkUwmpz3ujK9EotEo8XicO++8c9ztp36ds22V8qsoypUrgIfbWMILHKJjrHWzxI3BCqpZSc3lneAsraaKYbIcoot+Tn8IFBHgLhZOyGa42Cq5gRwj9McauecjWX7xDYcjB8HAg0/GiPhz3HvvfO699xzpF6MEgjupJ4fNCYaxR3830dCoJ8ad1E26jOZKFYl4+fCH1/CNb+zkYGcf5vUadr2G7tcof1sBosHNEFmieMY9b8GCQt73vtX8x3/sZf/+HoQQOI5DLObjd393GevXV0x7Dg0NBVx3XRkvvniC8sUhejwZ4uRwkLjRKcGHaJd4vQZ33JEP/qycB++8DX70Kuw9AZrIB1SKwvD7t0BQDvH//X+H2bu3m2zWJhBwsXFjNW9+82KiUS93UEcOmxaGxp3DOmLcSf3YOfxNEr41BLUPLseTzNK5pyO/fEiAJQTMK8J+61qSmk70rP2SUvLyy638+tfHaGuLI6WkvDzEPffM54476qZcZnamu+6qo7NzhKefO05vW4pukcSRkmiVlxUfKmFVeTm//6HVfO+r+zh8uA/HkUiZD/DceGM173rXimmfB0VRFEV5vZII5Bx/P5vr8efKRz/6UTZs2EB9ff20Hj/jFtzXX389hmHw2GOPTVo4+LbbbpvJcHPuWml9pijKzKTI0kIfwyQx0KmkgDKiV9XF/9kcJB0M08YQJjYx/NRTiH+OAzSnt28zwknitDE4lOHYTo1EZxi/x8/y5SUsXFg4rYvmU0xsmhmim3waaSlB6oiO1dq52rTF43xj4ADtepJg1qA8GsRf6GJAZKkhxNuYP65w7SldXQl27uxkYCBNKORm9eoyamoiMy7cPziY5vNffoWndzQhgoKiEj8uQ2MkaTLQlaLU7+dP3nkD992zYNzYbX2wqwmGkxANwnX1QCbOP/zDZlpahqisDOH3uxgeztLdnWDdugr++I9vIBBwY44G2jrJd2YqHc2SOrWkqN+CP+8BU0K1C2zTprexl75jfUhHEqmKULisnOOGi/dF4KGzPsZ/+9smnnhiF0IIysqCCAHd3fmMl3e+czkPPbR4WsfGcSTHjg2wa18nbZk4Wqlg0dpCGmKFY/NNpUx27+6ipWUIXRcsXJjP7pmsjo6iKIqiXCvXoaf28z+Gn8MfnttM51Q8wbsjd151x/RUe+45C9L4/X527drFokWLZjXBS+1a+cehKIqiXNlepZNnOEktQfQzVhvbOJwgwT3UcANlc7Z9ieTfE4d5+sXjDL6QYKgjg3QkLq9O+eowJbeF+fiKNSwQ0fOO9a1v7eanP21kxYqScYG3bNbi6NEB/uiPruf22+edd5ynE/C1QVjuzmfqTOWECQU6/N8S8I4eukQix5/92bPE41nmzRs/5/b2OJom+Ku/uovCwqunU5uiKIry+nGtXIee2s/vDG+6JEGa90Ruv+qO6UyDNDNe7rRu3TpOnjx51QRpFEVRFOVKcJDB0cbP48vB6Wj4MDjEwJwGaYbJ0RvMsPFNNfjfoDPUmcE2HXxhF6FiDy0iwQlGWDBhUdF42azF1q3tFBf7J2RGeTwGhqGxY0fntII0jTlwc+4ADUCxDh1W/k/9aLJRY2MfXV0JFiwomPD48vIQBw/2cvBgL7fcUjvhfkVRFEVRlCvVjIM0H//4x3nsscf4kz/5E1asWIHLNb7Y4anCOYqiKIqi5EkkOWyMKer1uxBk57iteL5RusRAw3DrFNWO7yilw7giv1MxTQfLcs7ZHjyVMqc1J9OBaXQGRwccwD4j9zffgtzBMCYe03zwSGKaV0+rdkVRFEW5mqmaNBfPjIM0b3/72wF45JFHxm4TQqjCwYqiKIoyBYGgkgD76KcI74T7E1jnzWC5UGHchHEzQg7/hFbtEhtJ8dk9picRCLioro5w4EAPoSIP/WQYJoeDg0fq9CaS3NMwf1pzKjEgM41F1yMO+AVEzogLlZeHCIU8DA1liMXGt2RPJHJ4PMactO1WFEVRFEWZSzMO0jQ3N8/FPBRFUZRLJJ2FrkEwdKgoAF3VPb1oLBz6ybeiLsQ7LnNmOYUcZIATJCjEQwAXIOkjgxed5XPYJh3Ajc51FPM0rYyQGytSbCPpIEkRPhZNI1AkhOD2O2p56cBJWrvbcJXo6EIDR9LXksJdqNO30WKQDLFJAlJnWuuDpxL5IExo8iQjHCk5NmSzQrPJRgQE8/OurY1w3XVlbNrUgtdrjLV2z2YtmpoGWLeukoUL5/aYKoqiKIqS56DhTJExfDG3cTWqra2dsALpXGYcpDmz37eiKIpy9bBseGonPLsHeodB16CuFO5fD+vP3xlbOQeJZC/9bKeHPtIAFOFjHSWspJAcDq2kGQFOEsfGwY9OAR7K8XMnldQSmvN5rqGYQbLsppdeMmNJw8X4uJdawtPsBObZ6KWwI8zAr3rJ7DXRtHxGbWGpj+vfU83QPIuf08zDU3SsOmWxG1Z5YXMKlnjAfVYWc9dAjue32Qy0Q6uWZnPAYuMiePSGKNVBN+95zyqSSZM9e7rGljYZhsbKlWU88sh16PrV+WVOURRFUZSrx/bt2zl06BAAS5YsYd26dePu379//4zGm3GQ5pSDBw/S2tpKLpcbd/uDDz442yEVRVGUOSIlfO9F+PkWCHrzGTSWDYfboKUH/uBeuGF63YqVSWyjm9/Sho4ghgeAPtL8khbSWHRisp1+wvi4Dh/9ZBggA7i4jWrWzHEWzSkGGndTzVIKaCE+2qrdSwORcwZTzjRCjs1aF2veVsm6deW07hkik7AIFXmYtyZGuMSLhUMLI+yhj5upmHIsTcAjUcg4sDsLIZFfAqUDrYMmzz7tkO0XzCtNURk0GUnBr7botPYN8s8PFVBc4ONTn7qRvXu7OXq0Hymhvj7G6tVleL2z/oqjKIqiKMoMXYuZNG1tbbzzne/klVdeIRqNAjA0NMSNN97I97//faqqqmY17oy/wTQ1NfGWt7yFffv2jdWigXz6M6Bq0iiKolxm+QKx4EYgRnMlWnvhuT1QEoHiyOnHhv1wpAN+tgXWNoBLXdfOWBKTLfTgQaeE07VRfBj0kuZZ2smgU4qP4OjHbhQv9UhaSHKAONdRMHau5pqGoJog1cyuXssRhhgiS60IodUJiusmjmOgEcTFfvpZRwnec3zdKDLgjwvhpRRsSkKXlS8SfPCwhTZos3Z+hpg7/93C54ZwwOZgs8ZPj4zw4VUFuN0669ZVsG7d1MEgRVEURVGUi+1DH/oQpmly6NChse7XjY2NfOADH+BDH/oQTz311KzGnfHX8ccee4y6ujqeffZZ6urq2Lp1K/39/Xzyk5/k7//+72c1CUVRFOXCjGBxmBF2McwQJg4SFxqLCLKcMAdO+hhOC2qKJz63qhBO9EJTFyyaXcD/mtbKCENkJw16FOBlJ30I3NSdtZxJICjCw0mS9JOdtKDwlaiDJDoC7TxBpQhu+sjQT4bK8wSEwjrcH4I3BKDTgoTl8AddCfxFDrGzEny8LjAMePmYyYdXXejeKIqiKIqizM4LL7zAq6++OhagAVi0aBFf+tKXuOWWW2Y97oyDNJs3b+a5556jqKgITdPQNI2bb76Zz3/+8zz66KPs2rVr1pNRFEVRZsZG8jL9vMYgA+TwoOFHRwMy2LxEP1sZJFPsxwmXIsTEQIDbyC99ylmXfv6vBxb5jNLJEnA1GG17PTkXGjYSk2m0OLpCWMjzBmgAdMRY16jp8mgwzw1D0kFIOWVml2FIEtlpD6soiqIoyhy7FltwV1dXY5rmhNtt26aiYvYZvjMO0ti2TSiU/zWwqKiIjo4OFi1aRG1tLY2NjbOeiKIoijIzNpJf080rDBBEpw4/+lkfXiV4SGFzIJYgsd5i6GQF0ez4dsX9IxANQHnBpZz960cBHtxopLBGOzadlsIiiAsLHRs54fwMYxLGRWya9WCuBBHc5HDO+7g0Fh70CcdkOsJunZpiyYFmjeLI+G05EtIZjcUVEiklJ0/G2b69g+bmIaSUVFdHWL++grq66NhSbEVRFEVRlIvt7/7u7/j4xz/Ov/zLv4wVC96+fTuPPfbYBa0ymnGQZvny5ezZs4e6ujo2bNjA3/7t3+J2u/n6179OfX39rCeiKIqizMzL9PMKAxTjJjT6du4g6cfEwiGCCz86fnRW+f10lqbYZ3ey9kQ1fid/4ZxIQ9cQPLgeisKXcWeuYhUEqCfCQQaoQsNNvqd5Dptu0qwgRq8J+zqylEs/hRUOhhsSmCSwuIkivFw9fdAXEGUb3aQwCZJFI4WDD+es5Vz9ZFlOAQWjhZRnQhOCB1Z4ONhi0j0IxdF8kWHHgZO9gnDQ4d46D9/+9h42bWpheDiLz5f/N/Dqqyf51a+OcOON1bznPavw+0df61gMY+JBo3C0YpOiKIqiKBfHtVg4+P3vfz+pVIoNGzZgGPnvIZZlYRgGjzzyCI888sjYYwcGBqY97oyDNH/xF39BMpkE4HOf+xwPPPAAt9xyC4WFhfzgBz+Y6XCKoijKLCSweI1BguhjAZoTpDjICPHRBTguNCrwcB1RvLrGTUV+XiLFgZMjeJryaTMeF9y6FH735su4M1c5geBuqslh08II9miWiY5Gg4xQuKWazT+XbGtLkZYOwXKLeW9MsuhOk+u1Qm5gkkJBV7BKAjQg6OO3hOlAx0TiIsc80qzFJkovabzorKJo1sGQ310Yofmmfn6+TdB4QuNUUkws4vChWw2O/PY4v/rVUcrKgtTURMayZqSUDA5mePrp45imw3s+uopXXUPsJU4SCxca9fi5jSKq8J1jBoqiKIqiKFP7whe+MCfjCnmqPdMFGBgYIBaLXZFpxfF4nEgkwvDwMOGw+plYUZTXh+0M8kM6qcOHjuAEKbYyiInEi4aGIIeDiaQUD7dThIHghJ3BHHSz7OA8vJrGokpYVgPG1ZPIccXKYdNEnE7yP2SUE6Dl2QDf/qZECCgsdxgSJh3dEjsL73qXzgcfCkyrvsuVxGKE43yHZvYxiB/w48fExRApSjnK7RhEuJMq1lB8QRkrjpTs7EnxfHOaobRDaVjn3voAZkecv/qrlygq8hOLTR5oGRnJ0tY9wqq/WsxQjSCGixAGWRx6yVKEh3dRSYUK1CiKoihz4Fq5Dj21n18f3oIvPLvOkdOVjif4SGTD6/6YXpRmqwUFqpCBoijKpbSbOG7EWHHWgyQwkYTQxy6KDXRyOPSS4yRp6vBTrrvpKMpyw60pFs6yBbMyOTc6i4mxmBgAiYTkyz8zcbth3jwN0CjAoL4e2tsl238FD98MhYWXd94zFWcvFi3Us5xecvSQIoEBuAnSyWp6WMJa5nHhX540IVhXGmBdaWDc7d/4rwNks9aUARqAUMhDxptmZ3qQW2UFPpGPRHrQCKFznBSvMcjDKkijKIqiKMo0xePxsQBRPB4/52NnG0i6KEEaRVEU5dKxkQyQwz9ax2QQkzgmXrQJWQtuNNJYdI4Gadyj3YRGmPtWTlJKTBN0HXT96soWuRgaGyVdXZIFCybue3k5HDwoOXjQ4ZZbrq40pjgH0PDhxk0lbsrwk8ZGIpEIgnRRe1Z9motJSsm+fT3nDNCc4lrmIz6SxbAEZ9YvFggKcNFIgiQWAfV1SFEURVEuiERDznHNmLkefzpisRidnZ2UlJQQjU7epEBKiRAC27ZntQ31rURRFOUq4yCRMBaOMXGQcM5lM2d/RDhz1PJZSklbm2TrVotXX3VIp/NLferqNG6+WWf1ah2v99oI2Jgm2DYYk3zSapoAJLncJZ/WBXPIoJ0R8dDRCI5+acrhxSYH416hF3n7jsS2HXT9/F/UpEcg7Pzr8mwuNFLYYy3UFUVRFEVRzue5554bW0n0xBNPUF1dja6P/8HNcRxaW1tnvQ0VpFEURbnKGAjcaCRHs2GiuHCjkcXBOKtL0KlgTHj0ojr/d4l7Dn6JyOUk//mfJs89ZzE0JIlEBF5vPlCxbZvN1q028+drfOADLhYuvPjZIxLJIP100kacIQSCCDEqqCFM5JJ38ykvh1AIhoYgFht/XyIh8XigvPzqC1j5qCbNFjyUTrjPIk6YVYg5/KVL1zVKSgIcOdJPWdm5l+zJNhN9oR/dmHichzEpx6uyaBRFURTlIrhWujvddtttY//9yCOPjGXVnKm/v583vOENvO9975vVNi7/XirKVcS2HU6eHKa5eZB02rzc07lkJJIecrSRJTUhJ0O5lIaGMjQ3DVHSrRGXJhKJF50qvNhIcqOdhSAfkElg40OnHj8Aw1iEMM7Z1WZ4WNLU5NDVJSfNQJiMZUm+/W2Tn/zEwucTrFihUVurUVqqUV6usWSJRkOD4Phxhy9+MceRIxf3dZQly4u8xNM8xV520UU7nZxkPzt5gafZzw7sOVriNTACTd3QOzz+9poawXXXabS2StLp08cxm5U0NUmWLdNYtGj6QZqeniRNTYMMDqan/RzHkbS3x2luHiSRuDhpO2FWoeMnQ9doTlf+PSJLLwI3Ua475/OlhE4TmrIQn+XL4Oaba8hkbCzLmfIxjiOx96aYFwrSrmXHApYSyRAmOSRriWJcZYWbFUVRFEW5Mpxa1nS2RCKB1+ud9biz+vnoO9/5Dl/96ldpbm5m8+bN1NbW8oUvfIG6ujoeeuihWU9GUa5kO3Z08OSTR2hqGsS2JcXFfu68s457723A5bq6akrMRBNpnmeQZjJYSEIYrCXI7cTwqjjvJTM0lOGnPz3Ma6+1kUjkMD0wvELH+5ZaqmujrCZCEptusqSxxi47veisJTLWpnsAkw1EKcQ9YRvxuOSnP7XZvNlhZETidsOKFRpvfrNOXd25z/Urr9g884xFTY0gEpn8otftFixZAocOSZ54wuQzn9HweC78AvkkIzzJJvo4gYMfHS9leKnFhwtBiiSNHMBGsoq1Fy3Lo38EfrIFth6DVBa8briuDt68HioLQQjBe95jkExa7NkjMU0HIfI1elas0HjkEWNatXra2uL89KeH2b27i0zGwu93sWFDJW95yxIKCqYOth040MPPftbIkSP9WJZDLOblttvm8cADC/F6Z5894qeOUu6jl2dIcZz8siYHgzAlvJEAC6d87vEs/HQIDqQhJyGsw40BeCgKoRm8ja5bV8H8+VEaG/tYsqR4dPnYaVJKGhv7mFcW4u3hWl4hThOp0ZlCAJ1bKWQN0Rnvv6IoiqIoE0kEco5/+Jjr8afr8ccfB/Lf9f7yL/8Sv98/dp9t22zZsoXVq1fPevwZf0v7yle+wv/8n/+TP/7jP+b//t//O1YMJxqN8oUvfEEFaZTXpa1b2/nKV7aRSllUVoYwDI2+vhT/7//tpr8/zfvet+qKbEF/oZpI8x90M4BJKW7caMSxeIoB+jF5O6XqV+hLIJUy+Zd/2cr27R2UlgaprAyRSpscf7GbX51I8rZPrqSwKshtFHKSNJ1ksXAI46IeP8GxAE0OLxoriUzYRjot+fKXLbZscSgtFVRWCtJpeOklh5YWySc/aVBTM3lww7YlmzZZ6DpTBmhOEUIwfz40NTns3euwfv2FBTi7yPB99pLgBF7CGLjJ4XCCFCkslhEmQBANjRaOUkUNRZMs05mpeAq+9GvY0wLlsXxQJpmF3+6DE73wqQehJAIFBYJPfcpg3z7JkSMOUkJ9vcbq1WJatXm6uxN88Yuvcfz4ABUVYQoLfcTjWZ588ghtbXEef3wjoZBnwvMOHOjhi1/cwuBgmqqqMG63Tn9/mu9+dx89PUk++tG106rpMhmBIMZ6/MwjQSMWIxgECbAQ7zmObXMWvtANHSZUucCrwZANPxrKZ9Y8WgKeaU4pEvHykY+s48tf3sa+fd0UFfnHAlZDQxl6elJUVob48IfXsLyghGXEOMQIQ5h40ZlPgCq8l3wJnKIoiqIoV79du3YBp5oZ7MPtPv3jp9vtZtWqVXzqU5+a9fgzDtJ86Utf4hvf+AZvfvOb+eu//uux29etW3dBE1GUK5Vp2vzsZ4fJZCyWLCkau72mJkJfn4tNm1q47bZa6upi5xjl6iORvMgQA1g04Bu7mPHiJoDObhKsI8wi/OcZSblQ27a1s2tXF4sWFY1lQPh8Lm6LVvHMvpO89NsTPPD+pRgIavFTO8k5GSDHMBZvoHhs6dOZtm932L7dYdGi08EDnw+iUdi3T/Lb3zo88sjkV9BHjjgcOeJQUTG9C16PRyCl5JVXrAsO0rzGAEOcJIKBi3ywwkDHjUYfOfrJUYoHH35GiNPGiYsSpNl8BPaegCVV4B79JPW5IRaA/a2w6QD83o35291uwdq1grVrZx4Uef75Zo4dG2D58pKxoIrP5yIW87F3bzevvdbG3XfPH/ccKSW/+MURBgfTLF1aPBZArqpyEQ57ePnlVm69tZbly0smbG8mPBTjoXjaj386Du0mrPDCqZi2T4OIDttSsCsNNwTOPcaZGhoK+JM/uZEXXjjByy+30t4+gpSSSMTLww8v5vbb51FdnQ9IRnBxAwUz2T1FURRFUWbAQVyCmjRXxo8rzz//PAAf+MAH+OIXvzjrVttTmXGQprm5meuum7je3OPxkEwmL8qkFOVK0tIyREvLEFVVE//xFRb66OgY4eDB3tddkGYAiybSlOCa8GtzAB0LyRFSKkhzCezY0YlhaBOWqIQ0FyuLCzi5Lc6xd44Q9LgoxoNn9APSGa29MTiaPXA3xdxK0aTZA7t2Oeg6E7I7NE1QUgLbtjm8850Sn2/ic3t68l2KgsHpf3BGItDc7Ey5lnc60tgcYhA/CXTGr/s1RveybzRIA+DFSw+do42iL+xDfutR8HtOB2hO0TWIBfNBnLfdANoFfFexbYfNm9soKPBNyHpxu3U8HoNt2zomBGk6OxM0NvZRWRmecGzDYQ8tLUMcONBzwUGamRixYVcKyozTAZpT/KO7tic1syANQHl5iHe8Yzn337+A3t4UkH9fjkRmvw5cURRFURRlOp544ok5GXfGQZq6ujp2795NbW3tuNufeuoplixZctEmpihXCtN0sCwHt3viL/5CCDRNkMu9/orpmkgswDXFxayGwFStay+JdNrE5Zr8aj/qduPKCe43SzjkSdNBGnvsvAjCGGyggFWEqcM/ZXAilQKXa9K7cLshk4FcLp9dczZ7Fi9/TQPLyheRne1KQQuJPfabzcRBNMQZxwIEGg5TF5qdiVQOpipF5TYga4IjL6w6v21Lcjl70vceyAdqUqmJBcxzOXvK9yzIB96y2Uv7npWTYMn8EqfJGEDqAk5NKOSZdNmXoiiKoiiXxrXS3elSmHGQ5vHHH+djH/sYmUwGKSVbt27le9/7Hp///Of513/917mYo6JcVmVlQaJRL/396QntXnM5G00TlJeHLtPs5k4MgxgGQ1j4J2nrbCMpm6T4rHLxzZ8fY8eOzkmzTvr7UyxdWszt/lJuRdJCmhEsbCQeNCrxUjCN8zR/vmDLlsmr1Pf3SxYu1AhN8TI/VSvNtuW0CuFCPuhTWSkmFHydCT86RQTowsCNCWdk00jyQRxP1uB4J/QNgBnMEsxEqXQJliwA4wI6Ly8ogyOdk983kIANC8C4wHriLpdGXV2MHTvytYjOJKVkZCRLQ8PEJTyhaICsv5yX23wEwiHcuk2pL0G5P4EmLaSUVFZe2vesiA5lLjiRg9hZx11KyEioU28niqIoiqIoMw/SfOhDH8Ln8/EXf/EXpFIp3vWud1FRUcEXv/hF3vGOd8zFHBXlsioo8HHTTTX85CeHCARcY7/WmqbNkSP9LFhQyOrVZZd5lhefB431hPgZ/cSxCI++XdhITpChDDfLmOHahGuYlJLu7iSplDnj5RgbN1bz3HMtNDUNUlcXQ9PyNV16evJLTO+4ow5Ny+fIhHExAgTQqBmtHzQdN9yg8dvfOhw/LqmvZ2wbvb1g24I77tCmDKgsWaJTXCzo7pZT16URDiIUB83GHgkQj7t529umnptEMsIIGcdiKBPAg4dyL7jP+AFFR7CeAn5KCVma8BJEQyCBEWlhJjQO73aTTtkYwRR+X4pjz1XyWiMsXwwfeheUjpZUydjQlQVdQIU3///nctNiePlwvkhwTVE+G0hK6BzKZ9jctvTcz58OIQS33z6PPXu66OwcoawsiBACx5G0tg4TjXq56aZqABwHOgbgQCs8tcNFu2cZrV1DBKWB7tJpHokSdmWIxA+xtCbCmrXl9JAjhySGMe3XyWwZAu4Mwdf6oM+CQj1/zBwJx7L5AM6G4PnHURRFURTlynQtdXeaazMK0liWxXe/+13uuece3v3ud5NKpUgkEpSUXLp17YpyObz1rUsYGEizZUsb2ewQQuQvYhsaCvjIR9ZcUDvbK9lNRBjAYhtxOskhyC8qKcPNwxQTmXmc95rU3DzIT396mH37esjlbEIhNzfeWM1DDy0mHD7/Eo3a2igf+MBqvv3tPezf3zN6oe4QjXp5y1uWcOON1bSR43v0s5kRhrHRgArcvJkYbyI6VqdmKtXV+ZbQ3/62xf79EiEkjpOvHfOWt+jcfPPUz49EBDffrPPDH1qUlk7MptFKOzAWHkAr7APNIdnnY17BfNasXz3peL30ckgeYlO/ZFdPCUPpIEGCLPaFeFOJwW2FcCpetJoInSxhJ10M0wdEkAjMEZ2hRh96xRChki48njjSNih7eAvsTLLrqVX887+5efQjsC0Lz/ZB92iQZp4f7i+F9dGpl2ItqoT33g7fexn2tZ4O0sQC8PYbYW39OQ/3tK1fX8Hv/d4yfvazRvbu7UbTNKSUFBf7efe7V7JgQSF7muEXW2HHcThwIv+8ZdVBInqCtpPDmCkbB0EbQVKhJbz9dz38ODpIMxksJCEM1hDkdqL45jBYc3so38XpmZF8AWGNfMZThQs+UATlUyy3UxRFURRFuZYIKeWMikr4/X4OHTo0oSbNlSoejxOJRBgeHr7oVZeVa4tlORw82Mvhw32Ypk11dYQ1a8oJBl/fOfrOaObMMdJkcSjCzVL8Y5k1yrm1tg7zD/+wmZMnh6mqCuPzGQwPZ+nuTrBhQxWPPbYBn296V6e9vUl27Oikry9FMOhm1apS5s2L0i1M/pEutpNARyOIho1kBAcfGr9HjPdQjD6NXx96eyU7dzr09Un8fsGqVYK6OnHe4r5dXQ5///c5TpxwWLxYjAVqtNIO3Ne/BJ4MciRKJinIiBS1izLcXLWcFdyIOCOA1Ecvm3mVV7rDbD65EENAyJPExMLOFhGVRbynSuP+M5LXJJJ9tLCVzWSI4zKDNO4OkK4cpKCwDUOYWDk/qUwh0uUQMHO49qxg9/dupe4hnbYyCOhQ4gFbQkcmn7Hz0Xlw43kaAnUOwq7m/BKnsA9Wz4PqotnX2ZmMlPnMmT17uonHsxQU+FizppyysiC7m+Cfn4ThFHQOQE8c/O58TZz55ZKaSIq+3hSW7RAMuOkigLMwyfK39VGmuXGjEcdiCIt1hHgHpRhz+CuVlHA8C/sykLKh2AVr/VCo3k4URVGU15lr5Tr01H5+cXgvvvDcLqdOx0d4LLLydX9MZ/y16Prrr2fXrl1XTZBGUS4Ww9BYubKUlSsvvH3v1URDUIePOiapGKuc129+c5zW1iFWrCgdWy7k87mIRr3s2NHBjh2d3HxzzbTGKi4OcO+9DRNuf4URDpDGg6DwjLd1PxoD2DzPCDcSZiHnX2JVXCy4556ZZ1OUlWn8wR+4+epXc+zf71BUJCktk/gX7QdPhkx7CSMj+f1vWOBlYUWGNo5RxQIKRltiSySNNNJjZjnY3YBfhxJfFjCwEOSMHkQ6wC97AtxUCNHR2JZAsJI6aojQShOv9Z4gFxskUtCF4Tikk+WY2TDScSEzkiF/mpKGo4SWLubp3nJuqoDKM5qULQrC0ST8rBPWRccvsTpbeSz/Zy4JIaitjVJbGx13u23Dz7dCPA1lUTjSDgVB8LognYPWXsG8kgBLl+aXJUok3akU7W06N3aGCFfmiwd7cRNEZzcJ1hJiyRwuYxQCGrz5P4qiKIqivH6owsEXz4yDNP/tv/03PvnJT9LW1sbatWsJBMZ/mVu5cuVFm5yiKMrVLJUyx4q+nl3Pxes1EEKwa9f0gzSTyeKwlSQWckJ2k4HAhWAAi6OkpxWkuRANDRqf+pSbTZssXn7ZprlvkGqtD6s5gjAFpaWC2nkaVVUCIQIkGaKfzrEgTYoUvfQwlChnOOumOpA8Y190MjgEvHF6EgEOj8ANZ2W5RCkgSgEvvLiEpvkHWVaYJJnxY1mni50IKXBsN6YvgawbZqS3HE8Wzo5LVHmhNQ3Hk7DkCq0L3tYPxzqhqhD6RyBnQ3T0JeBzQzwFvXEoGv2hKYNDxp9D73Uz2GNQVnm6w5MfHQvJUdJzGqRRFEVRFEVRzm3GQZpTxYEfffTRsduEEGMdQezZ9GJVFEV5HTrVCnmq5Uz5FsrWBW3DRI62QpeTVhPJ1/2Q5C5Ru/SyMo13vMPN/fdLdjXrHC+VeHNuQgGDghgT1gE5nP7MsLFxcHAcA8npujOn5EsjO0jyLZ2nksv4yDoBcAwcZ+JyRCEFthA4LslUh8Wt5VtG5y5Ox+45YVpg2fmW346Trxc17vAKsM+Yvw04gCYEziT7pSPIXaQW5YqiKIqiXFtU4eCLZ8ZBmubm5rmYh6IoyutOKOSmoiLEsWMDFBSMXy4mpSSVMqmvv7C1Mn40qnFzgDQZHIKjoRqBRZQ2ymkmQoIiihhgMSEW4WLu1/CGQoIbVkYxCWKTJsj4AskONgJB4Iy5+PETIIDPM4RHd0hZOgFXPogjR/9nW378GpSfo95yeURgdkbINHjwG2myuTMDNRJHt3HnBEa/F0MDJomh9efyy6nKr+BlOaVRiAXzWTQeN2NBGV3Ld00SQOiM+fvQcFkaJg6+wPjolIPERlLG+QtZK4qiKIqiXG3++q//mk9/+tM89thjfOELX7jc0zmnGQdpVC0aRVGU6dF1jTvvrOPw4X56e5MUFfnHWig3NQ1SXOxn48aqC9qGhuAWwmwjSTc5DAQFDLGAFwjSjoNNFC8h+mnnIG4KKOEuolw3mpkyd3IJD+7kfDpCO3H53HhEvvCLg80gPYQppITqsccbGNRRz0BgBzXhAY4MFFMm0iSkRsrM4E57GLF93FXm4BlM0NxrU1oaxO8fH2VZu0JQ8PMoPatqqYsdQLfd2LYPkOQ8FmFtCL2vhMzuEhauh04JEQdco8ucExZ0ZvMdnkpGYxZxbAZx8CFGSzBf/l9yQn64bRl8/yWoLMgXLk6k87f3xSEagPIzloTpCHxDXrwFaUK1aRgN6NlIWslQipvlaqmToiiKoiizcCXXpNm2bRtf+9rXrprSLLPup3Dw4EFaW1vJ5XLjbn/wwQcveFKKoiivFzffXENn5whPPXWcjo4eNC2/PLS8PMj73reaqqoLz2pZg593UsC/00+cAVbwND56GKCEQvwsJkgEA4lNhh46+QUCQZTrLsIeTpROS5580uSFF2yGkwsovmOI8rUtlFf3Ew5rCARhClnJzbjPqpMznwYSIkGy6hi7RoI83V1ENqfj2GA4NpET7WT+fjubNUkw6Kaw0M+dd87jvvsW4Hbngw4L6uFmv59fP7UK/wMpYpETuMQgIPDYAk9XCd2/ugWfE+CRpfCiB44k8p2dIL/U6ZYCeHsljODwGxLsIMMIDm4Ei3BzD0FqJ0vBucR+5/p8QOaVQ/nMmZ44JDJQHIG1DfmlUKcMJ8GddPHG9RkyPpNGsmNtsMtw8zDFRFXXNkVRFEVRXkcSiQTvfve7+cY3vsH/+T//53JPZ1pm/G2sqamJt7zlLezbt2+sFg0w1p5V1aRRFEU5Tdc13v725Vx/fRV79nSRSpkUFflZu7aCoiL/+QeYBg3BA8RYRYCt7AH6yTCPxfiowo1nNOsjh0YvxaRp5yi/YYRKggSow8USXLgvQnaIZUm++c0czz1nU1AAFSVusrs2sm9XPV2Le7j3flhcVUAJ1Xgm6Rimo7Oa6/jJ8Dz6LQNdmrizKXRNx+4eZuDXu9jbOUSN7XD99ZWMjGT5znf20teX4pFHrkMIgabBI28XpP9fIVv+5W5Sa05SUNONR9rQVETLljp8ws/vPwxvWAW32LBrOF8o2BD57k7LQmBpDv/GEDvJUoRGOToZJNvI0I7Fh4lSdZkDNT4PfPReuH6lw9N9WV7cAydP6FheaNEkiZxOyNTpHRI4Eu5bo/G+9WE6cHOMNFkcinCzBD8RFaBRFEVRFGWWLmUmTTweH3e7x+PB45l8yfbHPvYx7r//ft7whje8foM0jz32GHV1dTz77LPU1dWxdetW+vv7+eQnP8nf//3fz8UcFUVRrmpCCOrrYxdcf+ac20BQgckKmpGU4yE6dt8wDh3YtGORRCKIEaWDRg7SyTIMBNXo3ISXNbiJTVqCeHoOHnR45RWbefME4fCpluM6EVnOgWfK2DWs84Y/cY8F9ifTbkp+1e3DbVsER4Zxu3V0oP+F/ZipDPayStIH2mhtHWbjxioGBzO88MIJbr21lgULCgEoiMEff1jw0hYvmzYvoPPFBcQl+Lxwy2q4bSOsWJLfXsCAmwsnzmMXWfaQpR4D7+iXAi8QRuMwOV4gxbuJzPpYXQwdmOzQM2ytztBTbVG9EvyNLtr3uzncYYDlEDQsVtcI3rrM4N4lOoYuqMNH3SRBMkVRFEVRlCtddXX1uL//r//1v/jMZz4z4XHf//732blzJ9u2bbtEM7s4Zhyk2bx5M8899xxFRUVomoamadx88818/vOf59FHH2XXrl1zMU9FURTlPLL0YBLHRyWQX8bShsVBTDJIvAgK0dBw40Uwn15iuMgg6cbm30nwCgbvJci8WWaI7N9vk80yFqA5RQhBZSUcPmzT3S0pK5s6SPN8wmIwKwhks6QciaFrmF1xzM443kI/Oc3AKQ/T2xknlTKJxby0tcU5eLB3LEgDEArCm+6Cu26Gzh6wLAiHoKRoevtygCwaYixAc4o2WpdmP1lSOPjn4Fcj25bYNrhcTBnQ2kmGHxKnD4soOvNxYegCloKz2CQ+YJPKSYbcFhRY7NTcLCZMAxM7XimKoiiKolyIS9nd6eTJk4TDp0sGTJZFc/LkSR577DGeeeYZvN4ruBPEJGYcpLFtm1AoBEBRUREdHR0sWrSI2tpaGhsbL/oEFUVRlOmRo02WxWjQoBWLA+QQQBHaWR+bAm20/bUXQS0GNpKjWHyNET5MiPpZBGqyWdD1yftau935QIlpnnuM9GgXaGnLsZbS0nLyf9e1fNdsQ8NxJLYtEUIgRL7l+WQ8HphXPeld554HcsoPSReC9Ghr84uzaA0yGcmePTYvv2zT1OQgJfh8go0bNa6/3qC6WowFbLaS5nsM4wCLcU8oZKxpEC1yiAIV6NhoNGHybwzxfqIsVIEaRVEURVGuUuFweFyQZjI7duygp6eHNWvWjN1m2zYvvvgi//zP/0w2m0XXZ589PpdmHKRZvnw5e/bsoa6ujg0bNvC3f/u3uN1uvv71r1NfXz8Xc1QURVGmQSeIhgebFAP4OEQOjfzynPEkAhvrrFbcOoJFGBzB4jsk+Bhhis6z9CmLQyMZDpEmjs2JGyB+XCeje/Ha45/b3y8pLBQUFZ37V5aFHoFbB8fjRso0Eoke86MH3ViJLJrXgxjO4Pe78ftdmKaNEILy8tB0D9W0zMNgJxkkckIQZBCHWlyELlIWzdGjNv/2bybHjzsIAdFoPtAyMCD57ndtfvlLizvvNHj7212ccJv8kPxa7OkWL9YRNODiOBbfZZg/IEaZqkGjKIqiKMpFIi9BTRo5g/Hvuusu9u3bN+62D3zgAyxevJg//dM/vWIDNDCLIM1f/MVfkEwmAfjc5z7HAw88wC233EJhYSE/+MEPLvoEFUW5NsjRJTdZoBCN4By/yV9uOXIkSaChEyKEdo79NZF0jWa9lKJPWeDXSxkB6ojTSBPlmEDBJOPqJLHxk2I+KRxygAeBD4FA0IDBYSy2kuGNlo+OjhEcJ9+RyuM5/bHRQpYfMUArOSTgBlL1kvTbHF5qT7C6NUxxtw8pJR0dCTo6TO6+O4TPd+4gza0BgwVhk505N5rfRTpt4fO7cK8oZ+ilEwSMBK7eJDWryhjKQNOxfpYujHHddWXnO+wzshofL5CmBYtaDDQEEskADhaSG/GhTzOt1zRtOjpGkBIqKkJjnaggH6D54hdz9PRI5s8XeDzjx5RS0tcHP/2pRTojsR9JEdcdFp4VoLEth3hHEulIwuUBDM/4Lx8CQT0Gh8ixhTQPcXGDWoqiKIqiKFeKUCjE8uXLx90WCAQoLCyccPuVZlpBmr1797J8+XI0TeOee+4Zu72hoYHDhw8zMDBALBY7ZyFIRVGUqRzF5ClSHMXEIp/5cQMe3ohvTup9XE4WFkc5QjNNpEihoVFAIYtZTBnl4x4rkbxGludJ035GkOYOfNyEZ5IFTIIYa+niKCl6CVE4IYQgyOGml15WsoUIPWSwAANBGToLMPCjEZaCH3f289I3m2g/OoiUktLSIHffXc8b3lBPp27x7/TRg0ktbtynzpMHopUOm+0s2z1DeLcPMbC1lVSqj6Iih02bfNh2DQ89tIhQaOL64Q5MnhZJSquzeG03gyKElXEYztiIDQvw9yTwv3wM0+tnZ6tJtqWXUGkB9SvXsm/QzQY/XKyPonIM3kGYHxKnERMBOEAIwT0E2TCNwrtSSl5+uZVf//oYbW3x0fbrId74xvnceWcdpglPPGHS3S1ZulRM+jkqhKC4OL9c7MndWQI9GRaW62PZPVJKWl7t4vCvTzDYOoKUknBZgIVvrGbBnVVo+ul/QxqCQnS2keZOAhctE0hRFEVRlGubg8CZ45o0cz3+lWJaQZrrrruOzs5OSkpKqK+vZ9u2bRQWni7OWFBQMGcTVBTl9e04Jt9ghD5sKkazRIZw+BkpurF5hBCu0Tfkzs4Rdu3qoq8vSSJh4vHoRKNeli8vYcGCQjTtyn7jdnDYzS6OcQQPPkKEsbHpposhBtnADZRTMfb4F8nwPZJoQMnoxXTPaIHfFA73TFINJcQSOrkVyXOEacUkhoMXgY3BMBoZBlnI09xMNw5BBF4gh6QZixEc1uMhdzLJls5Bqq0sS4r9aJqgtzfFN7+5i6HhDLnfK6MLkwWTBIvKizXuC3l5aV8/TelDBAMWq1eHKS93MTyc5Uc/Okhn5wiPPrphXGZONxbfZJgT5Cj3GLy7wWJP3KI5Du6EzZq2JDffV8P+DYt48VAaFzaVlWGK55fT53j48jYwbbil9uKds9V4qcHFXjIMYuNFYwkeajEmLIGazLPPNvNv/7YLIaC8PIQQ0NWV4Bvf2EkymaOiYgHHjzs0NEweoDlTJCJIx3IMDNmsKXVxKr5yfFM7W755MB+cKQ8gBIz0pNjyjQNk4iar3jp/3DhF6BzDZD8ZNl60ijqKoiiKoihXtk2bNl3uKUzLtII00WiU5uZmSkpKaGlpwXGcuZ6XoijXAInkt6Tpw2bxGRe9PnTCCHaQY4OTQ9s/xCuvtLJjRycDA2k0TWAY2mgHHAe/38WSJUXcemst69dX4vVembU2BhighRZCRPCNZmG4cOHBQx+9HOYwpZShoZHA4SnSuIHqM96qA2h0YvFb0lyPZ0K7bBvYynW4ieDiAD6aMUiQz6EpYoQV7GQh3egUIsYCLAYCD5J+HE7aJscP9SOiGsG1hYR2JvLbDrjp6krw5LYTFD7gp9TvnhCgOcXnFbh2dpFNJ7j9nkqKRb5Qrd/vJhr1sm1bBzt3drJx4+mKvq+S4gQmi3DnlxHpcFsMNsYkTUjeu6yc+hE/LzwPqyugLHh6e4XA8QH4WSOsr4SL+RIoQOd2AjN+XiKR4+c/b8Tt1pk3Lzp2e329m/b2OL/61VFqa8uQ0j1hidNkJBK52iTdLRgoguJiyKVM9v+8GaELiupOtwMvrHMR70jS+PQJ6m8pJ1RyOhhjIDCAA+RUkEZRFEVRlItCos2oZsxst3EtmNbX2Le+9a3cdtttlJeXI4Rg3bp1UxbaaWpquqgTVBTl9WsQh8OYlKJNyEoIoJEzc/zbf+1l4JcnyWQsSksDrFhRMiHjIB7Psn9/D7t2dbFhQyUf/OAaCgrOvxTlUuulB5McMWLjbhcIwkQYoJ9hhokR4xgmPdg0TPI2XYLOUSyOYnH9WUGaDJIsIKmnhwYMhtBJI9ExKcDB4CRZPDgTAiw6AgNJUypLPJ4hUBIm6x3fqam0NMBWc4RcIk29f+pjnBnJ0b1zAG+Zl4SQFJ9xn8+Xr6Wye3cXGzZWYQECyU6yxNAm1HlxI3ABe8iQ6fUzlIHlxUxQFYbmITg2AMtLppzaJdPY2EdXV4IFCyZmm5aXhzhwoJf+/h5isappjefoIPzAACSTkuJiQe+RIeIdSYoaJnY4CJX56To4SPfBgXFBGsjXIIozeTcsRVEURVEU5fKZVpDm61//Og8//DDHjh3j0Ucf5cMf/vBYG25FUZTZMgELiWuSqLhjOzR95wipX57khvIYRUVT/+IfDnsIhz2k0yavvtpGMmny2GMbiMWurECNPXpRPNkyGR0dBwdn9DEmIGHS3ko6Agmj5XrP3ka+bko+ACOwiGGdERSSSCzklD2bNPKFiqUEXYBz1qkRIp/hIp3J92NsHqaDYznoQQ151jxtJAkDnk8naGUQG4mO4NhowG6ybkpuBBkkOZv8nk2yaZcOlpNf8nQlME0H23YwjImv71NL80zTQZvmj0JSgBQSpOBUQqudc3BsiTbJNsToNmxzYvarAKzpbVZRFEVRFOW88jVp5jbTRdWkOcu9994L5PuNP/bYYypIoyjKBYuiUYhOP/aEAqYHnmym9detrKmOUBSd3pIMny+/7GnPni6++c1dPPbYBlyuC2uv1+nY7HQsmqSNA1SgcZ3uol5oaDOsUBskhEBgY6OfFSZJkcKLjwD5NTwl6PgRxJGEJPRK6LQdkuSDHLom8GiCsz+rPKNLWaxJAjiQD97E0OjAnnQBTw4oNwwsj07GcnDlxo+TTpu4JAQ8BlkcPFN8GHvDboLlfgZahnDHTj9mBIc9MsvxdJr6Oj/VowGjNA7dSJoxGQKWY4x1sZJIEkjm4aI8CIYOaRN8Z3Wf7k9DxAsXuRP3rJWXBwmFPAwNZSYEDBOJHF6vTiAQJJ2e3ni6DVggNYk7v3qMULkfb8hFejCLv8A77vG5pInh1giXTzzTNuC/Rr7oKIqiKIqiXE1mHOp64oknVIBGUZSLwoPgFrykkAxx+tf+xGCa7U83E414aIjOrBaI263T0FDA9u0d7NvXkx8Ph1ZMurAmZHVMxZSSH1kZ/reZ4t+tLLtsi722xU/sLH9jJvm6lSEuT895RDq0ODadjo2Uk2+jnHJiFNBP31hWDUCGDGlSzGMeXvIX2jXoLMfNMWmzKWvz3FCW/QmTdlNyzJZ0moLv5HL81s4hpWTYhqYc9Fk2BXKQOB1YpMZt30ESxyE62kw6gTN2POTofW5gvs9DYSzGYK+OffJ0vkUuZ3Ps2ACrQ2EWh0P0TJGLYUuIaxrh28sgYTNyPM7ISJYRabPdydB0bIDCcj+rNxQTxaEInUoM1uLGAzRisQsTE4mD5CQWUTTW4mN5CSwqhGODkDsjYyaRlTSdTFAvB/HZmXHzkUiy9JKe5JjMVg5JKxYnsTCneE3V1ES47royWlvjpNPm2O3ZrEVT0wDLlpVw//3FjIyA45z/dSmkQLTquAokRUX5j+9oVZCqtcUMnUySS50+H1bWpu94nNKlBZQuGb+8TiJJIak6q4W3oiiKoijKbEnEJflzLbis1TVffPFF/u7v/o4dO3bQ2dnJT37yE9785jeP3f/+97+fb33rW+Oec8899/DUU09d4pkqijJXbsVLDzYvk6EDGwG07+jE6U6zYWnZrFpwBwJubFvy/CsnOHldgK0iQxwHF4IGXNxDkPm4p3y+lJKfWFl+4eQoRLBcaGN1cKSUxJG8YJtkkbxH97LJyfGKYzEk89tYqOm8SXezWBv/FuvBwzrWs52t9NM3FiAxMKinnsUsGXusQPBGx8u/Hx/g6EgW2TqCyNq4Qy6qGmLcVlNIHMm3zAzPmTCYcpPWjlHseYkCzwlqjCya7ifJCrxspBeDJkyGcJDkl7qkcUjBaMhG4ENQO+jmyGs+mhqrSfTl2H1skNZ0LxV6O349x5IlxXz0kTWc0AU/YIAhLKKjHyVSQosDR2xB92CWzqYk5pDNi3vacDsSK+pGlvsobPBT9IESWitGaCNBBB8VhCnHwzIkR8hxGIskNuVoFGLwMCHm4QIdPrwGvrodGvvBkZDoGaR722Fc/T3sDdn8j6fc3HhjNQ89tBg93EkvL5KmFQcLFyEirKaIm9GZ2AL8fBwkL5NlE2m6RwNtlejciY8NeMYt0xJC8J73rCKZNNmzpwvTdBACdF1jxYpSHnnkOsDFL35h090tKS8/9xcP25aw3UPhBgvNK/OvEiFY8+5FZBMW7bt7sXP5bQhdUL6ikA0fXDquBTfACJIAgtV4J9+QoiiKoiiKctlc1iBNMplk1apVPPLIIzz88MOTPubee+/liSeeGPu7xzPzL9WKoly5XAjeToD1eDiMScqyeHJTHxGfn3J99r/0l5QF+PGuVmrbC6mqClOOThbJHrK0YfEhotRPEahplg7POjmKERSJ8Re4QggiCOYj2WqbtDkO7dKmQGhUoJFFstOxaJUOf2h4WXRWoKaQQm7nTjroIM4wOjrFlFBMMdpZAan/2NVJVzpNQVcSw2eAy8BJWKR39NOZ05m/oJA9OZttVo6NeisLAz/AJRIM5IrJWDqF3n4K9BfpZJBd3I2FRgCBRj6N0gEiaJSi4UUjPGKw/Rc+ek5q2AUWq+d7iHiKaemM4QrX8sG7TW7dWILf76IMSS8WLxCnH5tiDNocjZ2WRiaZY+Cr+7B39hNtiOLUx/B1JujsTeD1S0o+WkZsRQwXOjYOfSRIkmMBxTTgpgSDw+RwAW8lyFq8FJ/xcVUVhj+7BXZ1wo7Dw/z6569ROBBncX2YYMBgaCjDj398mOb2Vh587AC6bwQvxQhcWMTp4RlyDFDJW9Bm+DH4DGl+RAo3UDx6vtqw+X8kyAK3nRX4KCjw8alP3ci+fd0cOdKPlFBfH2P16rKxLmT33mvw/e9beDySgoLJAzW2LTl8WLKs3oOn0KQHaywTxh/zctvjq+na30/v0WGk7VBQF6ZydTEu38T968FmBW5qL+9XAEVRFEVRFGUSl/Ub2n333cd99913zsd4PB7Kysou0YwURbkctNEMlwZcNJ8c5BcnEtSUXdiySrvARWdHhgWNCcqq8t11vEAYjcOYPCOTrLc1tlk2bY4kI8EnoFrTGMJiWEpqzlHR1S8EKSl50clxp+YmJE61DxdEpOSQdHjazrFQ6BO6UXnwUEfdOeffPZLhF31xvLpGReSMmjxuN/F4lqNHB9AroySkhlu3cTyH8YoR4s48PJog6Qh6zVI03UuEQwRYgs68sWFcCFxIsqPLXiJoHDhg0NOmEaqxsHSox0XpfC8L58GBk2CGwD86FR3B7xClFjdbSXJYZthlC2wkwZ39JPYMMm9hEW6vQVxK4lU+CmSEkf3dZHam8K4oGx1Hw43OMBl6GKGOQiJorMFDExaFuMYFaMaOvwtuqoFDvzmONznM+nWlY8V4fT4XkaiHLTv2Mm/nCDffNG8sw0WnGJ0AcfYR4zqCNJzzPJxpAJtnSBNEUH5GTaE6NE5i8RtSrMNN4Kxgm9uts3ZtBWvXVkw67pvf7CKdhl/9yqK7W1JZKQiF8gFBy5J0d0v6+qC+XuMPPuTiRNDPfxIniTO2LcOtU7WmhKo1525rNYiNBmzAf87Cz4qiKIqiKDPhOBrO2R0n5mAb14Irfi83bdpESUkJixYt4g//8A/p7+8/5+Oz2SzxeHzcH0VRrh6plEkuZ49lGcxWv3BAgJ4a3+pHSoFpC75vpfnrTIZNpk2n4zAiHToch+dMi//IWhw3BS02U9aXAbClJCElxlnXukIIyhAcdmz6plkD52yvNQ0wpAuKJilOHAy6SaVMjsdzaAh0kSMlsqRlEQKBEBAQEiydYelHw6SM1gnjeMnXVukfXbZzotEAn0NWlzRgUHIqAKBD2AebD49/voZgNQE+TDH3OcVUSD934CO8a5iAYeAePYdBBCkccprEXeyhf+sAdvbM8yLwYDBICmu0NpFrtINV3znaRCeTOXbs6KC0NDgWoIF8fRfDbWKLJEd3hSYEIwz8OJgkOD7l2JM5gskADqWTfHSWodONzbFZ9EwyDMG73+3i4x93c911On19kgMHJPv3OzQ2SjwewdvfbvAnf+JmwQKdW/BzAz5OYJFiYuemqQxj043NnfhZPYulXoqiKIqiKMrcu6Jzne+9914efvhh6urqOH78OH/2Z3/Gfffdx+bNm9H1yTu2fP7zn+ezn/3sJZ6poigXi23n2z/PsHHSBKe6GznW6YtYW8I+26bRdnA0ySoNCie54G6VMChhhyWJS1hhMGknp/wWxKSXyW4EcRxMKSd0YJqOjOUgBWiTxHjG2jfL0Ui7cHAAR55+S9cBLxp+KbCEhhwtb3vmVMRom26bfLBmIOdgGRpLcbEA17jghsuAVHbyuQoEQQx8mETRsFImuuv0cdVEvl6NFKC7BU5O4pgOuuf0+7iGwMYZrZiTJ+GcIQjTdLAsB5/PRTZr0dExQmvrMIlEDgeT4RQEQ5L+XpvC4vGfGQINh9w5Rp9ke+SPnzbJCTVG5zpZW/Tp0DTBjTcabNyoc/y4pLvbwbLA7xcsWqQRDp/epgfB7xHGAbaQJoZGETrGFC80E0k3FmkkbyDAA4Qm3QdFURRFUZTZchyB48zt94u5Hv9KcUUHad7xjneM/feKFStYuXIl8+fPZ9OmTdx1112TPufTn/40jz/++Njf4/E41dXVcz5XZXoyFuwZgN19MJSDoAtWFMCaovx/K4rXa6DrAsty0PXZJ/uF0ZCA7stfnEsJB2ybo7aDS5NE0IkyebA3pgnSUhKQkiNW/o1yqcGEZUu60NCwcZ0d/QAGcCgUGgVidvtQX+THMzBEQkiiZw2ezVoYhkaxR6NZgibduBG4tTgZJ9/qOScFfl1SISRDSIYppA8HF+BFoJMPZJlIBnCQWFRWuEntM1hSqE/IPhlKwpr5U8+3XGgEgDiSgvlR2nd2I6VECEFOgiEEBoJMv0nx0iiGf/zHTw6bAG5co0GzU0WVfecIJoRCbsrLQ2zZ0sbQUIaRkRy6LvB4DKTUSI0IjuzP8Q9/Eefuh7zcdq8XTROjPaMcvJRO72SMKkXHjRi3zOiU4dFivKVTvKamSwhBQ4OgoeHcr5sgGr9PmFJ0XiPNMUw8o+3VT2UhmUj6Rjt4lWHwO/i5FT+6CtAoiqIoiqJcsa7oIM3Z6uvrKSoq4tixY1MGaTwejyoufIU6MgRPNMLxeP4Xco+WzwTY1AFVAXjPwnywRrk6DDqSQSkJCigWYkIAY7ZKSgJEIl4GBzOUlQVnPU4kAz5dI17ixkZywoYDlsQ92sCvwvZMebFaKXTapYVbQABotCSFmqDsjOtvKSWGhAah04xDg9TQR4/BoHQYAR7SXHhncFxyOZvOzhEAVpaFWHpEY4cmCWRsXK78xi3LYWgoQ2VlmMVRDx0ZyYijEzZLcXm3kGOEjBNCAjFXDl10YFCMh4UsxkUvNkkkaRzio4GFNbh5Iz7kUg9fOiJo74PKwnw2k5TQ1g8BD9yyFEbIkSSHDxeRM5bM1AqNlZqLV+wcJevLCTzfymDzMKHaCEOapBSNwe4cGhC9o+CMxbaSLDYSSTGnlyYNIQnYglCXi2YbyorBl48/4TiSjo4RTNMmFvNy5Eg/LpdOaWkAXdeQUjI4aFNcEmD5xiFyiRw//rbEMuGuB12kRRteSgmxeNrnBqBe5luF75YmiwVERgNwaSTt2GzEQ/UFBmlmwovG7xDiNvzsI8trpOnEwh7NPzIQLMfNBnwsw4Pvyl/hrCiKoijKVUo6GnKOa8bM9fhXiqsqSNPW1kZ/fz/l5eWXeyrKDLWMwJcOQHca5ofhjFUOmA40j8BXDsDHl8PKwss3T+X8BhzJz3MmWy2bpJR4Baw0dB50uai+gMyXU6JRLzfcUMUvftF4QUGaoY4kG+cVEVlWzC8zkiZTkJI6PnQqhJew5plyGVKp0ChE0IekSEBCwklbUqafbsN9XDpU6zq/o7l52slxWDrgSBwgIARv1FzcpU/d5vtMjiN54YUWnn76OO3t+TpaVVVhbn1DLSdDgs5sCk93AsgviyktDbJ6dSkuTVLksQlkXPSk16I53VR69hDRe/HqkqBho1HCrbwZP0XsJEsMHR8O3UiCCKox6MFmJznum2fw7tsM/usV2HfidJCmMARvvj1He20bLzBAFhs3GvOJcQOVFOBDCME7DQ9pJPvmhYi9dynN/3GI/oO9hIRGFEE6qlHxloWU3RgjTgZBPmBroFNOhGIC+eOLZN8eAc8G+VKLjuNAcSHcdTOUxrp46teNHDs2QDJp0tjYh9dr4HLp9PWlEEIgpSQY9LBq9Xwi0W7S0U56u9I8+eMEJQssli0toYIHcRGe9uvphGPzpJ3juKPRjuAoNgWaTaUBPiFYjZvfI3BZivGG0blptE5NHIcMEo38sqgImioQrCiKoiiKchW5rEGaRCLBsWPHxv7e3NzM7t27KSgooKCggM9+9rO89a1vpaysjOPHj/Pf//t/p6GhgXvuuecyzlqZKSnhZy3QnoTlsXx9ijO5NFgQhsPD8F9NsDQGxrURJL3qJKTky5kse2ybUiGo1AQpCS+YNq225I99bsrP0RFpujZurOLZZ5uIx7OEwzPPjHMcSSKR4/fvXMFOO0LOMjGkTQkCPxopR7DdcbjBEAQnyXRxAas1g52OSe9ofZyTtqTOdrA16EVSrGm81/BynWawRhrsdiy6HQevECzRdBqEPmkdm8n8+tdH+c539uJyaZSW5gNTJ0/GOfHNvfzOf1vNzlWl9CZzhLMOpUEPhSV++jVISJvbDYOHw16aPRo91kOk5UoCrmMEjSxRUcxyllFAhFVIbsbLAXI8TYo0GvMxKEAnjcNmsrQJm/+2JsTyWoNdTTCUgGgQFtWbbC08wiGGieKlEB8ZLHbTTQ8p3sIiIngoFBqPGj4OOBbHb5xHfEEJyV09hAayxEIesquiPDtPxyckLrJksdDRiOAlgGc0aCPZvFPQ/G0f8zIGRaUCw4Defvinr6UxR9opi/RQVRUmkRgiHs/h8+kUF/spKvJjmg5+v4uKihB+vwtJBA+lBMoGObR/hBOvVnH/0rtxMf3uYW2Ozb9YadqlQzmCO/DSgkW77eBC44OGlzXCg/syB0N0BLFLmMmjKIqiKIpyiurudPFc1iDN9u3bueOOO8b+fqqWzPve9z6+8pWvsHfvXr71rW8xNDRERUUFb3zjG/nf//t/q+VMV5m2JOzuhyr/xADNKUJAbRCOxuHQUL5OjQJpR7LbhK1Zhy4bskBAQL0BN3g0Fk1R0HaubLFs9tkOizUN96mW0wKiEvY7Di+YFu/wTC975FwWLChk2bIStm5tZ9my4hnXpmlqGqS8PAhryjhqS2rRSUhByeicHSS9UtJiOyw3Jr+ojQjB9bqLk47NCcehCzgsJQ0I3qS5uUl3UaflnxsVGrfrbmZzfTw4mObJJ48QCLioro6M3d7QUEBr6zAd32/kv//V7ewuFuxx8sVf+4EyofGgNNhg6xS6BAsCkJ9Aw+if8QwEy3CP1p8RbOB0UMGLTgSNQ1i8TIa3FQapPCOjbQd9nCBOFWGM0SUzbnQCuGglzgF6uZEqADxCsEZ3sUZ3QYUXKk7/Y5ZIIqT5BSmSeClFJ5LvR4WNpB+HHtOh6+kA1VmDdQ2nD2h1peTA/gF6+gq4blmOQCBNZ2eCaNSL15vPolm6tJjCwjPalZMvauwmipsoNSVJDmx1GH6zTtEMllY+b5ucdGyWCZ1UyqS9fYT+rgSO5bCz0MMT+4cZLIuwbl0FxcWB6Q+sKIqiKIqiKGe5rEGa22+//ZztbZ9++ulLOBtlrpxIQDwHNee5dvEbYNpwYkQFabJS8nRa8nxG0mbng1uB0SUMA8BBU/BsxmaRIbjHp7HePbGo7VzYYdl4YCxAc4ouBIVCssWyeZtbYlzgXDRN8L73raK/P8WhQ30sWVI0rUCNlJITJ4YxDI33vW81m4Iu3JZNFsGZrY00IfBJSbuULJVyykBXAMFizaBeg92WzQOawe+5XcRmWQx4MocP99Hbm2LJknzUwBl9T9SEoKIiRGNjHxwe5CPXV9IrHU4OpTm4t5sDz5/glz0pfuFIPB6dtWsruOGGKhoaCsa1oz7bHnK4YELWh4agAI0d5HgIieuM+xsZwIM+FqA5RUcjgItD9LORyvMuqxEI7sFHJQavkOEgOTqRY8+KobG61c9wm5eFFWcV5h3OkE7Fcblj9A0FcOsjpNMmPp+Bx6MTj2fp7U1NCNKcKRbzcvz4IL29SYqKpn7cmTJSstMx8SUtdh7ppaMjv12XS0fXBWZAZ6+06P3GTn7yk8Ns2FDJ/fcvvKCleoqiKIqiKFcb6QjkHHdfmuvxrxRXVU0a5epkO/kgw7Su20W+TfK1LOFIvplweDELYSFZYJwKiow/gCMOHLDgyIjN7/k1HvDNfaAmJSVTNeFyky8EbXFx3ljKy0P84R+u52tf287+/fnlLQUFvin3MZHI0do6TDjs4b3vXUXN2gr2JDIcsCVJRzAkNdIaRDRJSMvnnNgyX0PmfCEXN/k6M0WadlEDNJAvFpx1JC0SWrM2qdGuRn4ENRpkbEkuZ2OaNi/+vJHf/raJnp4kPp+LUMiNpgmSSZOf/vQwzzxznGXLSnjve1dRUTH5cp40zpTLclyc7vh0ZpAmizUhQHOKgYaJPaG991QEghW4WY6Ldmw6sTGReBDUY9Bu6rxmgfusF1q+NbtE0wW2o+Vbeo8uRTv1mrDtczXszgf/HEdiz+BNJoekdyjDkX09ZLuSBINuSkoCY9uUXheR8iDLl5fQ25vkySeP0tjYxx/8wXrq62PT3o6iKIqiKIqigArSKJdAxA26gIwN3nMsB7FHL7oiF75a5qqVk5InEg6bstCgSwLnyIgIaYLFGnTa8L1UvoDv3b65DdIs0DUO2s5Ya+UzDQCrdY2LuRixvj7GJz6xkR/+8AC7dnXR3j5CNOolEvGg6xqOI0mlTHp7k3i9BsuWFfPgmxfTsqCEvxxyOCIFI4A+2tA54cCII/CK/GuxTpv+CiUJF3XfYDTzp9BPo0tDDqTxhT2cevkPImkdyGK6dL7icvFPX97ByeebmVfoY8mSIoyzlmlVVoYYGcmxdWs7fX0pHn10AzU1kQnbrMfFQUwkcmKbbSRLcE1oe11BiC66Jt2HBCZLKESbYT0WgaAKg6qzPoacEoiEoX8QSotP3x4MunG7XSRHLHBGaGoaoK8vCQiCQReWJQkGz/3mkU5beDw6fv9UocaJeluGOLG3g6GgTu0ZwRnIvyZMQxAbzI0VdC4q8nPoUB9f/vI2Hn9845TBMkVRFEVRlNcTVZPm4rk29lK5rJbEoCYInalzP64nDSU+WHUNd3d6ISN5MQvzJwnQSAfivTDYCbn06dvLdYEPyX+mJCetuU1DusEwiAk4Zkv6LcmwDcMjOY4MJzHsfq53DeMI+6Jus6wsyB/90fV87nN38Pu/v5JIxMPwcJaeniQDg2ky2Nz4plo++ec38ud/fguNDcV8LynRkGzUNAo00DWJISQ+AT4hSUnJgAUBtGllH6WlxACqpyiKnLahJQUn0xMzwZJJSXOzQ3u7g+OMv/O3OYenykP4llZg91h4TQ3D8KEbXnJpm8ETwySXFPPM3i5e+m0TA6Uh9lUX8HRhmNaghyFHMuhIcqNBs3DYw7JlxbS0DPG1r21neDgzYa5r8RBDoxUbZzRrRyLpIX/ebsI7IXizlCJ8GPSSGg135Z/TTxoXGsspZrqyWYuWliFaW4exrImZL4UFcNN66OqFRPL07bpmYHiKSY90s2f7Hg4d6kNKiMeztLePEI9n6OpKkMtN/frr7Byhri5Gbe3E4NVkbNvhP76zD9fWbkIRL+mAgSMlWSlJS8lgxIUvbVPeefofpK5rLFlSRFPTIN/97r5zLulVFEVRFEVRlLOpTBplznl0uLsKvnEIBjJQ4J34mBETejPwu/UQu0brQptSsikj8QpJ8KwATfdxaNwsGGgDxwZfGOatlizYAIYbqnTYa8GWrKTamLtsmnIhqHDc/Ffapj/nkElnqeEIi12HqBzuY5/HIFdZxUL3MmpYgLhIcWAhBFVVYaqqwtx//wLi8SxHs8PsdvXRF8iRDeq8Sj97Tckz6SJKNI0iXQA6KyXslSYDSOI4uBC4BbgcjbasxiIdvOeZZocjqdc1lp5VF8dy4OkeeLYPerL5jLE6P9xfCsu9kl/+0uSFF2wGByWGAQsX6jz4oMHy5TrNtsM3e0xOHDewG9YxSJKOnEBggZXC0ZKE1hVRd08ZzV/ZilMTIXnvQtLVQSyX4LAtcXdniO3uprAvwTwdFrjA0DUWLy7i0KFetm5t5+6754+b8zwM3kGQH5LgMBYCcIAIggfwsZaJ2SjVhLmDebxEKycYRiBwkIRwcyvV1BM97zm0bYdnn23mN785Tnd3AiEENTUR3vSmBWzcWDUuWPbW+2FwGLbshGzu1FJJSSycJuE9hJnJoesCj0dnZAQCATelpQFaWoYBWLeuYkINo2zWIpOxuf32edMuRH34cB+HD/expipE+9E4B+uDnCz2kJP5UJUnabL4wBD+eG7cek5d16ipibBvXzfNzUNq2ZOiKIqiKK9/joac60yXaySTRgVplEvizgroSsGvTkJ3Bsp94DUgZ0NXOn+xe1clvGXe5Z7p5XPQhOOWpPasf5Xdx2HLTwTZJISKQNchNQJ7nxEkh2Dt/RKhCQqE5KUs3Oc79zKp2TKl5OsJhz1ZQX3KJn2sj6qCFpbXHMJtOsiTOsf6k6TTLaQWDWPqORpYcdHn4fEYjBSn2EwfSczRHA+NuMyxmXbingwNds3Y46uFni+IKyyOOjZRBEE0XAj6HUGHJal3T53tYEpJCrjVZeA6c6mLhO+3w8+6IKjnGxlZDhxOQFNSUrbL5PivLWIxqKwU5HKwa5dNS4vDo4+6ebHUYccuAyMhsNHQiqO4MzZWzsbWvHgKC9BiXvp2H8dJW2Q/uByzzIOWcpAjNrYhoMbHQLQa/blW9vYlSUlY4wbD0PD5XGza1MLtt8/D5Rq/NOp6PNRhsJccg9gE0FiKmxr0KYv/rqKEakIcZ4gEOQK4qCNKMdMrwPuTnxzmP//zAD6fQVlZEMeRNDUN8uUvbyOXywdPTgkG4I8+AHfeBIePgWmBnRviyZ9sYeHtMWw7Sm9vEsty6OwcYWAgQyDgJhDIty6vqgpTWRkeGy+Xs2ls7Oe668q4/vrKac0X4NVXT5LLWQT9bmKHhxDtwxglPgKGhjvjILqSdKQs9giN6zRjXAHqSMRDa+swW7a0qSCNoiiKoiiKMm0qSKNcEroG714ADRF4sRMOD0FPBlwaLIzAbRVwUym4Z9HC+PVid87BRHBmWRnpwOFX8gGaoprTP9ZHvODxQes+mLcqf1+ZDkctOGTCujnIRtpnwms5qNMlB4/24WvvYV39UYyUQ58sRBRmKclodBzIUl5sc7z4AJXU4+PitiR2kLxGDwlMagmOBRU80kvS0nC5B0lnC/E7p7vrBIRgvebCcjQGpMQzWmxWB5pNmOeavD28LSWNjmSprnG9Pv7FeTKdz6ApcUPxqeOtQ9gF2zokO/oEd9UKCsKjrcp9EA7DwYOS//q1yUu36JhxQUkBNHcKXG4IBXQStk485cYfk0jDoe+Fk2gbapGlHhgwcUZX8+iWhIyDVeIitayIypeSnLCh1oEiHSoqQjQ1DXLkSD/LlpWMm7uUELJ1bhE+XNrkRb1tCTkH3Fo+QwigAB8F+GZ8zrq7Ezz99DFiMS/l5adrtIRCHpqaBvn5zxvZsKESn+90rRjDgJVL838A/vVfm3HsLP8/e/8dZlt21ve+3zFmXDlVrtq1c+odOie1pG6BJCSBQAHJuhgwSYB8uPfaHB8/9j3GNsfHj881Nhju8TE2wZhkDmAhCSWEcrfUSR337p1T5Vy18lozjXH/mLXz3oqdpB6f56lH3SvMNddcs1Zp/vod71urlQEYGkrPq/37B3n66QWmp9OpXlGkmJ5uMjZWIAwTFhbaNJsBt902wi/8wl3fsG/NRa1WwFNPLTA4mENrzRmVoOqKrfXoquMVIJjVikk0g1cEXEIIqtUMjz46y4/+6C3XBWWGYRiGYRjfU5RIf17q13gNMCGN8bKRAu4fhvuG0v403ThdCjWWTUOc17p1Bd41M3Kaq7A+D8WB6y+k/Tw0lmH5QhrSOEKggLb+ZufsfGuOhIpYgx0mLC11GB3t4vkdOu0KrpXQ8R1ExkHV+6zPQGGwwxqLTLDzG2/8W7BOwCxtBq/pndLRECmHnOzTtdpXhTQAGQG325InY8Wq1lSFxheCthKb04Wu1tea00qzQ0p+1nMpXZPiHGtDI077LV3LrmuavkANSgguV+kIIRgfh+fmEy7MWhSzml4giGLIbi4DjAUICUEbCgMJjY0e1t4dCK1RSfrZXvx10YDVVbTHc9iOJAoUK5shTTbrEEWKej3tS6M1nG/C40vwxDL04/ScKrnwxjG4awiKHjzXhkfqcHazv44lYGcG3lCGw3nwbvC7qjVEpH9QbhR2HTu2wvp677qwCGBiosjZs+ucPr3O4cPD1z+ZtMHykSNLlMvXB0Sua3HXXWMMDeW4cKHOwkKL06fXLo3m3rKlxHvfu583vnErpdIN1lreRLsd0u/H1GoZOsC6VhS4/vfQE9DQsKIUg9d8kWWzDr1eRLcbUSqZkMYwDMMwDMP4xkxIY7zshICxF7e44ntCpK+/wE2itAeNvNlvqgAVC9hs5iqAF7dt72V9nU5JShKNUhrb1ghAa4HUkEiBFgIpBUmczg5SL8HeRCgS9HUjoS/1ZxXc9HUrQnCvLXkmVqxtNoD1EMRK48q0N0tDw6LWSAGHLcnP+C5jN2gYHKr0eN+oCkXHoCUkN7jPdSEMIEkEtsOlypgrHypFWkWlldocK2Uhkouf8jUUYAmUZQHqusbFSaJZ6cEfnYTnVtP+TxUvDUi1hrkO/M4x+C9nIcqD76evX7HBFukSrsca8HgTtvrw/iG4q5g+dzqGx3vweB+6Op2UtdOFB3y41bsc6IRhgtg8N67lOJI4Vl+34a/W6fuwrBuHj7Yt2bGjwrZtZc6cWafdDvnFX7yLkZE8+/YN4Pvf+p+6i+e5EIKEdFS7dZPwU6BveMZ9OyO/DcMwDMMwvisp+dL3jDE9aQzDeDkVZBrUXClfhUwBek1wrhmgo5I0IMjXNqftaI1C4L0EVTQAk7YgAjzfJpdzqNddksTBdkL6IocTJ1hxQpJoijWJjU2Ob26KzreijEsBlyYhg1csvXEESNJj4OqbV0yUhOD1jsWy0hyJFA0U57RGqDRkyAl4wLF4nW1z0JK4N5n+NOqlIUaQpIHHVfICJ9RkA8W1VU1ra5qRYcFcWdPekJRzaSgSJ2Bb6ci9JAHbByVspGMhFzqordlLW7pYKyUA7QvctQjZj4D0PAIuTZLqWC6/8Rwc34CtedhWuD5YOh/DY33oNmBfDPcOp+/tyvcaKLjQh/84C393BGYs+FI3DbUqIq0oiYDHeunPThd+tpj+7+hoAceRtLshSVaxTp8+cdqAeF2RKTmMjt6gJGmTlILBwSwnT64xOnrThyGlwHUt9uyp8Za37PimGwTfiO/bOI4kihLyOGSEoKc1zjWfp9KgERRucJ5EUYJtS7zrThDDMAzDMAzDuLHXRhRlGN8FdtiCGIG6YmSvm4FthzW9FvTaELQCeo0+UT9hdRZypYiMW6e71qWuIZMAXZhppRePL6a7XcG4BWe1YOv2CvWNHCsrVdxcG+yEcrNPfbVLoWKTH48YYJTqNaOZtYalFTg3BfXGt7cfGWwOU6VNRIfo0u0lqXGdDlHsk4+/fjhkA6MCRoTkF7I2v6Q9PtDxeG/k8TOOz085LnfY1qWAJklgegEuzEM/SLdxuAi7c3CqC5GCVj9maq3H2fUAVYLdiWLmBU18xVj0el1Tr8MP3m3xuu0QaE2yudSpH0KigCCtnLJzmiCwyB0cwn5yBnoKKg4WaUiTAOQshBBUTtfZUJDP2Pgln67nsLzcoTSQ50txjRMbcKACZe/6gGYpSSeDZSzY4sJsG46uXX/MPAl7s+nr/vNp+JM1yEs47MCkA8M2jNqw34UdDpwO4d/NhXzp5Aalksf2fRUePjPNc+Eys7TYoM9Ct83R2RWiuxRzY61LI8EhDR3n51ucP79BqxXwhjdspdUSrKzYdDo2N5psrZSm0Qh48MGt31FAA1Cp+IyPF1ld7eIIwVYh6ZMug7v0ehrW0RSFYERc/3praz127KiQzTrX3WcYhmEYhmEYN2IqaQzjVeIuV/BhqVlWaRPgi/a8DhaONzn1SEC/FYPWCEvgeX108RxfOd5EZhyah/YxvHsrv+25WDJt0vyD2+GO69uAfFsGLMEH85LfbStmJspkteBrGz4HvKcYt+Zw4zaZ3TbbJqtM+JMc4r6rRnBPzcJffQqOHE/HKuezcN+d8K63QflbLLi5jyEahBxhnSV6aW2DEOwUGU72J9DC+oZteTY02DHUH7f4DzOCs1XoFqFagsOD8PYyvCMLzx2DTzySBjRKwXAVvv9eeOt98MGt8OunE/78hTbLq12iSCHRTMiQX9ybZ3qpyIkTCq01WqfNg9/yFosffIfLEIpnGwmtKQtbaIQSNJogLfDLim4M1Yqi8gOjzP5f50m+Mod43Th60AEtQGgIwXmhQXJ+g2j/KAyXeMS2cJIELV1eP1LlTM9lbxnsm/SSOZukbXMGxWZllgNTbdhRhOINeuxqCdMR7OxB7QZVOQCyH6E/c5rPPHKBR1p9DuWhMxrCkKZ5OkAoQIPtSg7fN8ztPzbKl8QMAsHdjHHixCof/egJTpxYJYoUvu+h9QgXLuznuecEtZrLyEiPffvqlErR5nvRnDq1xuRkkXvvnfjWTqgbsCzJgw9u5YUXlkkSxQ5p0RGaaa1oaI1M3wIlIbhN2vjXHIgwTEgSxRvfuPWq8eKGYRiGYRjfk/TL0DhYvzb+P5UJaQzjVaJmCe7z4K97giGpL43zXTu1RPvYk+TxKI4Oo2LYOD1NZ2Uaf0eOsbvGmHNGWFnPII+tctedQzi+w9G1tFHs3z8Md924H+u37LAr+NWy5KlQs5grU1+yUaduJ88QufGAHTsqjOdHGWIcm8vVA7Pz8B9+Jw1qJkZgoAqNFnz0b2B+Cf7Bz0H2m5vkDICLxTuY5DBVpmgTkVDDZ9Aq8tvS4lgE+2x91cjsK7WU5lxfoI7AF8/A0i2gXMh2YX0OnuzBooJHn4Olv0lHQI9tjj9fXof/+rF0/9/9ZsXUp55m40ST7EiZXNbBrrdonZznd2oZ/uUv3Ms7wwrz8wrPExw4INm7VyKl4C4teff+hM8NxuTqkqQv6Mdgu6BdzXxG0S1oIlFF767hf+oUA+s9NnbUUHmXWpQwMtfA3+gxd3iSfiVLth/hdgM2woT2/nEeGx5gS+sGy7E21XVaSVMUl8OWrA3LvbRXzbUhTQJciNOR440AGhGUr3mMihXP/MkznH/4PLlKhnCowIbVYObMBtVihkPvGgIhkJZgZHeeLYdK2I5kjS6PM484Cb//W8+wvNxhYqKIEJIvf7nDwsIJRkfHGBwcZGMjZGMjx+rqAHffvUCStFhZ6TI2VuCDH7zz0uSn79Sdd44xPJxnYaHNxESR26TNFjSrOm2ind+soLk2oAGYm2syMVHitttGXpR9MQzDMAzDMF4bTEhjGK8i3+9Lng4VpxPBHkuDhhOfOkXYDpg4mEeIDdbOrNPoL1IccenX+7Qil/rIMDUVYS1vsDzvsn/fIMUqnNiAj52D2wZvXEnx7ahIwZv9zYvSfAl2loB9X/c5f/swXJiBQ/vgYg/ejA/lIjx9BL72PLzxvm9tPywEWykwQRaFxsZCSMEv5jX/V1txLBJUhGbE4tKypbbSzCcQIRhfgfmnJeoegfBhOADhQNGC1RVQGfjEl2BnDHduvfy628ZgaR0++wTU5SpPf3WKrWMFCkkLWoAFQ/sGOHt8mT/83Gk++s/uR96g87MvBD+XdVADEU+UEkoS9ktx6YJ/I9E8H2tmEsHI+w8Rrn+N3pNz3N7ss9O3qG020T01VuN8NcdQo4ulNa1WgAwSDo6XOeI6jBZvfgwXEgiB8hW3CdJQZ7oFe8qXx28DrCawkUDVhvUQFnrXhzQrJ1eYfmKa8mQZr+CxlCjWMzFj1QL1F/qsz/X5of9lz3XVJRUyTOkGf/LJIywttTl4cAghBOfPK3q9PBMTLt3uMrffvoVez2NqqsHios+zzzrceqvg3e/ex4MPbmNy8sXrg1Qu+7z97bv4oz96no2NHpVKhgEEAzdY2nTVMVjpEIYJ73znnqvGihuGYRiGYXzPUps/L/VrvAaYkMYwXkUmbcHP5SW/3VacSARDCw3Wz61THC1cuqhtzjexXImbc2l2OiwEDp60mBR9ur7NzEyTffsGEAgm8nCuCVNN2Fl+Zd5Tvw9PPguDtcsBzUW+lzbLfepbCGm0hobusyAWuSBmaNNFb4Y0EwyzzR7nlwsVvtyHLwdwJkmfo0mb2+51BG/0BB9/RuBVBPUMFKPLq6MsCY4NiyehuQz2luv3YagCR8/CJx5ukcQJhcLVA7ylFAyMFDl5bIXji10O3GScWUUKfinn8OUw4UthwlSiiTb7stjAfa7kHzgWuyoDLP3yfXzk955i5swajbxLbqyA79vMDhRwopheK6DTifA8i0OHhiiMlzm6Klj1gODGx/LiRKbrxkrLtFFwpNLqoYs6Kv3b6Mr0ed34+m0un1gmCRO8zWPiyZguMSPCRo77zJ9o0lgKKI9c3dxZIojWEo68sMz+sdql831+XiElZLMe7XaTVmuD2247xJ49Vc6ejfC8Af7Vv/IYGLh+PPeL4R3v2M3aWo9PfOIUQZAwPJy76fIlrTVzcy3a7ZD3vGc/b3rTtpdknwzDMAzDMIzvXSakMYxXmVtdwf+rIPn9tuJIL6EeKVxH4m72K01iRSIkHZUuPykLgS/BVYLAEsTx5YjZlekI5egVTJ2jGOIY3JsUFDgO9Ppffxtaw1wEj3cUX03OEHrnELJLTtqM2S4DtkCJiKOc4RQXGLYGeFPuID+QKXAihrZKu6QPWLDbTgOB/9EHy0mXzlrXNKG1JERhOgZb3GCpkNhcHtQLNPIG47kh7beStBS9rzNaGiAnBG/3bL7ftTgRaxqbjWnLQrDPFpeWbN2yt8qt/+T1PP74LF/84gUuXKgTx4rFyVGinqKqNXv2VJmYKDIwkGW+I5AKYnF5GtS1bnZaCJEe82ubT6trHnOjydJxECOuGbWdvr7AciUq1iQ3OSFVqIljhetePuhRdDkoEkKQJOnxdF2LWk2glCCXu/k0r++UZUl+/McPUyh4fPKTp3j++SUqlQzDw7lL+xkECYuLbRqNgIGBDD/xE4d5+9t3m140hmEYhmG8dphKmheNCWkM41VovyP4lyXJV3YU+RfVDKtrPTqjTjrVp5whbmwwgIuyYK+bMAUkCHq9mK1bS4jNS/K1PlQ8GP4W+r18p2ZVzEfDPo+HMV0Ng0LQ2O+jnnIYqF4daGgNnS7s3Hbz7UUa/nID/rapiDJHKObPIZWHimqsaMkyULbg9iyM2QX6BMywSIceb5B3codbvuF294zD7EnIxNC102qai4IQRkahmYVOBCdGYcNLe5UVQqhtpEHOvm0ex48qVKKR1tUX5I31HuVajl1D39zBd4XgsHPzi/qYmF51jcG313n7WxzWF4tY6wW+FOZ51qtwV8m5aoqQLSGxoBLfvIeyK278ty7ZrLC5doncxa3rzcd4N8inimNFtNKoRCEtSaIkDpIIRXstJF9zKQx4BBEsrMNyIw3yMi6ossVANcvaWu9SdVK1Klha0iilUEpRKl1ev7W+DocPS/yXLqMBwLYl73nPPu69d5wnnpjj4YenOH++fikQdRzJ+HiR9753P3ffPc7IyM3HiRuGYRiGYRjG12NCGsN4lcpJwVsHPOpv2cbv/+lRBoKAbNGnsaPE8wsN+gtNRkcLHCglNFWX2cAhZ1tsnSwD0AphpQfv3QWVl/giNopgdlnx5/T4sBexrtNqFVtontcQvC4k9DTqnM1B30YJxbIKmVuGgarDfXfcuLNtouGP1uCTTRjJnyGTP4dUeQQ+WJDVsKEk50KLhUByTw52Z2BE1FhinUd5lu/jPrKkB0BrzeJim34/5o7JLE+e9igswPwkuAl4CTS7afNeazsU74PjEXgluNiGZ6oQExRidm+R/MODVR75WpELZ1YZ2zuEznlIpejMNei1Q37sR/dTzqZfsx06BAT4+GS5cXDT7mieO5GWp9x+iyCb2exPwzpP8RRrrKLRWLaNmkgIJ1a5PV6jXT/MWjRJRl+ugulZkLUg2wS8G74cNXn5OF+ZMXVimMyDc00IM2hDRkAzSYOfJIbpOgzlwd/8azJ22xil8RJrZ9eo7qgRYzOKz0a9TtxIuOtdYyx3LI5MQasLUqTL4GIrhtBGFnazdOoUxWKPajXDli2S8+djZmY2GBwsMDY2gtaapSWNlPCmN1kvS8WKEIKJiSITE0Xe9rZdXLhQp9eLEEKQzTps21bG982fVMMwDMMwXqNMJc2Lxvw/SsN4lfvhd+5lbbXLI49MszrbQAjBQDlDHcjlXM6fWiXvdSns2EtxzzjLdo6lVcjY8NAEvHvnS7dvWsPDT8OnH4Uv6oRj+yxsX7CtmjA0lmz2OtF0h2KOH5Q84mnOH+0SbqmTDPaxDmq6wubDC0U+NFLDv6Z049EOfKYJE04fL3sOrT2ETgOX+cTmRFBkrlel08+QKIsvroXcV2jzulKd/TlYEmtMMc9+dnDq1Bof/egJjh1bIYoUxaLH6NgkamYva8plbgASCV4Bxmvg2jC0H3Iz0FyEWCTILRu4wy2y+Zh2TvBf7Cw/8w8O8xufWOZIMY/yHUgUfqHIg/dJ/vGP7KJNm2O8wDxzREQ4OIwzzn4OkCetuIhjzW/9QcKf/1XM8mL612d4VPKB99r85N9r85h8lAYNqtSwr/jaVijq1ga3lp7khYbk+WACSfr3q2bBuzNwYh60e+NR2SMSShJaCsqb98ebS8Mm89dX4GQFTNjwSB2iAOZbgEiDmz0FeMM4ZMoZ7vzJO3nqj55i9uQqQmgSOyLMJ+x8W5Xa3SM8dSYdZz5QTAMaJRRBqYO7XGHd240a1Mwtnmd2tokQglJJEcc5CoXDnD3roZSmXIb3vtfm3ntvMrrqJZTNOtxyy+DL/rqGYRiGYRjG9z4T0hjGq5zv2/z8z9/JG9+4lWPHVgjDhPHxIqOjec6cWWdjo0+p5LH74BDLboGZFrgW7KvALdV0Wc5L5bOPwx98DEJbMfXGGMvTeKuS+WUL4ojhrWln2awU3DoZ8YwjWdnSZKjVJd93yLvQ8yL+R7TC+uMJv/q6IeTm5Byl4YutNFzIZxYJrC4yrgEwnzg80R9gvVsgCDJINLaMaWiXJ9p51gOfaEAyWWxyhmnkmQK/9ZtPsLTUZny8iO/bbGz0Of3UCxy6vc27ttzDWWXRyMP4EFRy8IkNqGShuh+WRhXnssv0Ck0ywqbousRCcZIWpwdqbH3XQYZWekTNPsKxyN41xnDV47Tus8pXWWGZAkUyZAkJOc1p6jR4gNeTJcv//v+L+f0/iLAcqA2k0cjivOLXfzOkvvU4u7+vzhBDSK7+MCWSqqihnFXeXDmK3RuhmdiULLjdgyQH//sczHZgyw1W4DgCtkp4TqXLymyRTm2qfZ0lchsb0AzTXj01nQY5fQ3PNNOlYe/YAYN7Bzn0yw/hP7vAHa0WryvZuAdtTu+q89Rsm8jzqbo2CE3oByRORGajzOCF7YxP2jyvbuP2kS3sryzT78cMD+fZsmWYc+dc1tY0hYLg1lsttm4Vpu+LYRiGYRjGq4GppHnRmJDGML4LWJbkwIEhDhwYuur2vXsHrvr3rz8I+8XV6sDHvpQ2BO7sj+nnNaUuWEVNvwPL0zaVkQTX2+wuq0CrkG7BYzyIydrp7YXEY0NFfFU2OLJY5tbRtFLmTAAn+jBmQ+zOILSNQKI0nArzdGMXFbo4IsG10jBIak0XSQfN440qu3LrrMsN/vqJ51lYaHPo0NCli/pMxqFU8jj5wiw//I6tvO+usUvv7U/Xoa9gYLMCJTPcxcu1KCsPW6eVGy6SxY7PbNvltlzAQ8UCgrRfitbwQh/+e6PBrd4KQ2IIi/R5Dg4+PqssM80U3sw+/uLDMX4GxsYvhzD5vGCt2+Rsd5Y9nTwyd/O0rUyZDWude/OLjDNx+Q4HPrAbfv84zLRhInd9Rc1OGzY0TCWgAyjZcPvg1cufLtoI4Ug7XWaWz0HggqOgkEA3gXM9ONkEkQFZ8PmFt23n/YXL2/rkhSZfObFCfscGkdsFLXD6HuWZCQrLg1iRAxLGqoKp/gA///0DDJUvv/7evTc9BIZhGIZhGIbxPeEl/G/shmF8Lzs5BYtrMD4IKxmFFmDp9Grcy2rCvqC9cfkrptFUSGISbBrXLGsqKZu+nfDwfOfSbctxWqGRtxTK6qZrdoCGlqwnWWwFibZw5OU50DaKREuUjFiPXJaCAkEUc3p2mbGxwnVVF7mcS5Jonntu8arbj/agaF0ONJp2Bw2XApqL4tglVpKWDAm5POpICBhz4LleTBAXLwU0F1lYuHjMMM3fPpJQr2uGRq5PRSb2t9Ben/mprz9e2sZOlz5Rv+6+7xuHn9pM745uwEIXkiv+K4RSMBCA2wdtQ62UTlPaHDKVjjyP4UQXnt4AYtjdh8kmDHZAaOg6oH3ouXCylVbx/L/L8HcKV4c93bkiPL2TySOHGHv+YPrz3CHKc2NpQLNpoAgbbZhe+bpv2zAMwzAMw3i1UC/Tz2uAqaQxDOPbEkbpiGrLgkTAlR1MLoYb+oov0jQYSK/81TXdTqRI51GFV8x8TvTFLSquHCKttEAhEBrQ+qrKkIv/eHF8dKwlttIorXCu7YK7ybYl/X581W3R5nSjS/si9A17uujNfUz/Zlw9j9oREKPR+sZfsxYWEXE6flyDfYPSFctVoCCOvrklPfqafYD0s3jLFthehK8uwKNLcLx+xWsImMjDe8fAz8GRLrzQgekgfW8ayEm4vQCHEji/mDZRthMY7kCtC103HWU+l8DrgX9cvXEPnESljYLtwMMObtLNmLRPzcXHG4ZhGIZhGMZriQlpDMO4ZG6uyZNPznPu3AZKacbHC9x99zg7d1auq0IZHYBcFhptyMVpVKE3h3/HEUgrrai5KJcR6K5EoPDU1VffAQqpBdvz7uXHb16oN5VFN7EJREiUgFIKgaKLS6IFQWJhS4UU+lK4bmGRsRIqTkBiScr5LEtrPUqlq8dcKaUJw4Rt28pX3T7iwHSY/nNrMWbpGEzNabwoIjsmqN4uye8Q2FYaIGW0xBVXh0BrCQzbFo7dANIms1rDwnmPk09mOX0+j1ofpbOgaSmLI0sCf0Dj5DQZoBBBsuxR0ZLKQMRNRzRxOZzxuPkYr12l9Oed2+BUA3pxGpgUHNhfuTyd6e06Xba0GEKg0jHbIy7syMCJLPzROahruLjQzu7PcGYAAMfpSURBVNZQDNLlYb6GeyZuHNAAlLKbgZa6HMTcSKefNm4u5W7+GMMwDMMwDONVxPSkedGYkMZ4TYvRLBOhgCFs3JdwBWC3C0ur4NgwNvL1L1K/HWGYsLDQAmBsrIDj3HjqzUq8Oc3HgurmQ6Io4S//8hif/ew5Njb6+L6NEPDVr87wqU+d4Z57xvl7f+9WCoXLQcG2Mbh9L3zpadhStDg6rAi8BD+JCdo2+YpCyoBuXePmbHJZia47eKpPNo65uNoy0pplP2C8n+GtOy9fle/yoAt8sieYcIYZzp2mERfohRo76RKSRVox/djBtSKE0ETCIScDVOKws9gg42ygRZY33LKF3/2bI5w7t87QUI5czkUpzZkz64yNpUEUQIKiQZv9OYtH6j7PfrrL1MMdOo2Ybl7SkgniCcHCZxSVOwT2+zuUPBvCLHjiUinPRgxtBT+Sz5CVFg0aOJ0yn/ujAV54NMfiisV6u4Du5+lF0IkEeklgBxI7B+5IwpKtiE9U2RlVKY9enqPd78f0ehGuY5HLOSAEHTpkyDDK6Dc8Tyo+HNYRi4ttbFsyNlzAuqK7tBCwM5v+XGv/INyfg093wU8gv3n+hApmNGyX8K7dN3/t23ZANQ9rLRgs3fxx8+uwazT9MQzDMAzDMIzXEhPSGK9JGs0zdPkSLRY2u4kMYPMABe4nj3Xd8OFvXxjCp74An38E1jbS5UG7tsE73wK3HfzOt6+U5otfvMDf/M0Z5udbCCGYmCjytrft4g1vmLxUATMfw1+14Jkg7fWSFXC3D+/Ka/7m/36Bj3zkBIOD2aua62qtaTQCPve5c0RRwt//+3fjeenXhhDwEz+YVj08d05T3b7G+qRFhhaVkRjdiTj3tYSoIXEzFvauMqXRPFumFBtOyJpMqz+EhpG+zz/ZO0LOvRwsfakH6xL6MYhwDJG5gIoDhPYYtRokwmEjUyLu2fRiH41AkuC6MYXCOiPVWVZFi53r25g+E9JsBhw5srw5vtlnbCzP7t01fuZnbmdwKMtZ5jnKBdZoEWQEc5/dxdQnHMZrDqP7fALLouF0iFBETZh+WFONJH/3FxXrXZdj/XS/Ly4PensR3l8sM81tPBse5SO/73HsCy6ZakCXQXw7gz1kEWhNsSFozyuSDYHqCoJViSgq7AMOF8b3cLL3JH2arJ7uMjfbJAwTLEswNJxn1/4SSanDXvZdGul9M3Gs+PSnT/O5z11gZaWDZQm2b6/wgz+4+1JQ9Y386/th7cvwbAjzSXqbBLYK+LU7oXjzYh5GKvDAPvjYk5D1IHeDx6400qVqb74V7Jd/urZhGIZhGIbx7TCVNC8aE9IYr0lP0uHPWUejGcRGIlgj5i9Zp03C2yi/KK+jNfzRX8KnPg/FAowNQxzD0RNwYQb+p5+COw5/Z6/xiU+c4k/+5AiuazEykkdrmJ5u8J//89cIgpi3vGUnKzH85gacCmHchppMq2k+2YHnlnusfmmKkZE8xWqW6QiaMQQaBALX88ltrfHIV2e5555xHnhg8tJr18rwyz+h+PO1Y2xhjq9Ek0wnJXQ/IFuIsQ+6dFaL1AMbOd/joZk5/vvb9/KFqRLPrHWJtGJPweeHbitQzV7+OlqI4eMd2OWCVDDXqyKtAQr+AjqogZBsY4WKbBHYGdqhR5RY5EWfHYUGh2vLtKwO7XbC0f9zkYVnOuzbV2P37ipzc01WVrrk8y4f+tBd7N8/yElmeYSjaDQlcnQv2KjHWniDWdZLNloJsriUE5uWDFF+wsBWweBT8KYXMtz6OsFTXViMwJdwMAO7vbTXzm52M/PMEMuP9Ni3PeDcTAmrn6VckEzF4AqBWwHflbTXNcmwIIoFuW0eOz4omJHbeWq+yUzzEZxul0w2SyHnEOuExWCJ1vk13rjtVg6WD32Dc1Hz3//7ET72sZPk8y5jYwXiWHHixCoXLtT5xV/U3HffxNfdBsBYAf78rfCx0/DoclpFc6AMP7oHhr5+RgTA+98A9Q589QR4DgyXwbGgF8JiHWwJ77kfXn/LN96WYRiGYRiGYXyvMSGN8ZrTR/FZmkhgyxV9PiZwWSHiEVrcRY4BnJtv5Jt05jx86dF0eVO1fPn2Qh5OnIGPfQZuPZBW13w71td7fOITp8nnXSYmipdu37WrytRUnY997CT33TfBl7THqRAOuGBvFgllJFQs+JuZEKpFYq14sgWtJK0GkZuPUxqkcIgDwW//zRS77t7CsHu50mjdXac/PM9EmOeeXptwqc+yKNGSFWwnRg4K7MUMpQiWjzf4wu427zxQ4Z0UCDcb7zrXFC492Ut7uhxy0yVZD9cFzy8d5NbRLsXMOu1+FSkled0nK3rU/IS8rchZikhJEhWRtxTHH/OZfm6N79u/BXezSmd8vEiSKI4eXebZZxfZub/Mc5xFIhjYDOfOP+7idiwO3tJiLYpIwgFaiYVG4imfnS5sK8PyWsjDX+7xhtdn+P7ijauvtNY884hPEYeqsDhSt6lkNU0NMXBxgZeXE3TboHOCoe0WUR2sdciOC859pcz5rxW46805alu7YIXYSuDXq5z7MCzsLuP+hHvD179oerrB5z9/nqGhHIODl5eVFYsep06t8dGPnuDOO0dvukzuSr4D778l/flW5Xz40Dvg0Db44tF0glOcpNu8exc8eAju2nXzvjaGYRiGYRjGq5CppHnRmJDGeM25QMASEVu5/qK2hs0ZAs4QvCghzbFT0O7C9smrbxcCJkbh3BTMzMO2Ld/e9o8fX2F1tcv+/QPX3Tc+XuTkyTVOnFzj0ckxyvJyQHORI2B9tUtz2xDt40tkJNTsq8cmA8Qa1ipZHj65wa+eDvifdvgc2JwKfTJe40SYsNZxSaKEoeYa49YKgeODk+DbfVoLO2nHRWZHBvnXs5Ivuunkp/7mF23BgteV4Z4SjPlwPIKMSI9TxUorapZbBc6u3sm2gWcpeWvEyiGJ8iTaIuvElG2NJfpIq0+gYIittJ5coe9pcK9+Q5YlqdWyPProLPe9b4i63Wbwiuqp6aMWmaKmZFtIu8G4l8FXRTRpuJXZbOGiBy0uXIip1xXV6o3DjV4PTp1S1GrQ6EAQpw1xO3E6QerKMEK5kKxp7Fs04YqgvwLZLTB7bBH7XJ6jn68xviVEeEk6UqvlkV3s8NTaIn/3/fGlpWg3cuzYCo1GwOTk9c1gJiaKTE01OHdug717rz+XXmy+C2++DR46lPafCaM0vBmpmHDGMAzDMAzDeG0zIY3xmhOhUegbnvxysxdNdINRxt+OMEovOm904ek6EMVpz5pve/th2hTkysavF1mWQGtNP0zoX59TADATwkZfoR2bYfvmF8i2gJIj8UPNQl/xW8vwS0Pp0qlP1RPWXUHFhihWbCQK2xa4KkAkMbYb0JUaL4xQS21miln+bBV2unAgk77mcgj/bR7+egXuK0PbuzoocgUULajJMq31+1CZecq5aYb8OiEJvh3jClDKY725jQFdpOC5JL0lhCuuG48N4LoWUZQQJjHK1lhXNI2OI4G8InOxpaJyg0bPlpX2BEqSm58vcZwue7MsgYrS6VdCXB7ffSUtQOgrtqXSfi+qH2E5kkQLVNvD7lz9PsIwIYoU3s0HQBGGCVKK66Z0XdxGHKtL59PLxbZgcvBlfUnDMAzDMAzjpaB56StdXpxLtFc9E9IYrzlDOOSxaJBQvuZXoIfCRjD4Iv1qjA6lF+RRBM41hTlrG1Apw/B3cJE6OlrA921areCqyUsAjUZALucwMZpntwtf7XHV7J+1GJ7tgS74ZE8ufsMKhqAT4OU8bqm4nFXwn1eglMCUyDOc0VhagWth23IzMJBYXkjSd+l3PWa6mk4CE75FwYV1BQ0FezcrcrSGtQg+uQIiDyoDF4uZfLkZaGjICB/V28FGbyurzhqh1aGYaVLEIwyKLHeL3FpoonWAmiyy/lSXJ8sCe7NKaMxLpxKtrXW5445RBt0CGVw6BOQ3R1hXRhSr0zYJCoHEu0HVFUCrpcnlJIXC9QlOkiiOH1/l8cfnOXmyQK/nUBvLopISSSJwBfSu+UMjI1AlgU7SD8PKpr2Bslsq9I8tULI0Fmn41mqFzM01OXVqnaGhLB/72AnuvXeCHTuuH5cO6cQvKQVBcH3Fzdpal3LZZ3S08PVPAsMwDMMwDMMwXlImpDFeVLGC+SANUUc98F66idbftiFsDpHhK7TxkGQ2KyhCFNOE7CPDLr7OiJpraA2LIfQSGHCheMVv1e2HYOdWOHUO9u4Ee/O+ZgtWN+D974RS8cbb/Wbs2VPj4MEhnnhijr17a5cuvnu9iKmpOq9//SSWJdi5tMFjbp556TBqAQJO92E1gYGyjz67Qj/nom0Li7QPDYAr0940Wmv69T6737Qbx7fZ2tJ8cUET+vD9w0MkSYHErmNRplz2mV2KiByLnJT0FweY70jW17oUyxlqQ3lsCZGGMwFs88CVmm4XRE9RabY4tqAJJvPsHnLISk02BieBRghVLw0gFBYr/SGGRUI3aNJ1Y3TsUXITKl7Ex+dyTE3sIfA6nDrdIDNa4pwjcNow3O4wJCR7Dm+jtVpgrDLMU6sLFLsZqjXBvvtjTjxm0Qj71NwceTI3+Nw16+sJP/p38qz5MZ0+0LLJ2xI76vD7v/8Mzz+/RBjGwCjz86PUG6vUrS10OzkKNYfEF4R5kAnIlsbWYI9K2hvglyG3HdYUDN+6hcXHp4nnA9acPHNn5jl/fo1GI0ApGBjI8Jd/eZxPf/os998/wU/8xK3k81cHS4cPD7NnT43jx1fYecsQQS6D1Bqx1mJxscMP//AeBgZuMHfbMAzDMAzDMIyXjQlpjBeF1vBoI62CmO6n/z7swVtr8P2163ucvJIEgh+iQgfFMXqEaARpf5Dd+LyPKvY3OYL7TBc+sgzH2umUm6IND5ThR4Ygb0MuCx/8u/Cf/wiOn0mfozX4HnzfA/DDb/3O3ouUgp/+6dsIgpgXXlghSRRag+NIRkcLLC93+JVf+QJJoumVsqzdu52lN+4isiyOR5CXsLfs8bzrcuLEKmwdJNYSCXgirWApOxo9v05+OM/IgXGOfjRk6vGE8zssgnFJ6YzNgdsP4E68QEN3mGaYWTdP0JHEKxbxrKBXb1Ms+owfHMV206+dgoSVBM7UNe0zCeeenKf+whnijQ2cDESTOT7+wDZGRrdTX5ds+IJmRdDNCEZqgpVY0ApANyx0XKTlRFBIuGPrGp9ccllq+gwVMzg7DzP/6AusLi+jHYGw4XzeZ+jWW4hPjPOHT0JjeT+dxghatfCLMZN3N8hv69I8VeDQvirymmY+WmtOn4kQI5rn72nxkdU6S02g4VA879H98POItUVuO1Aln3dptyW9nmBlZZAg1KyHYO/RqFskrYLAtYBpTXFGU6oKlpZBvA5mBMgAZKfM+L43MH+qyZFnukR1i0KoGCxtsGd3jVtuScux6vU+n/nMWcIw4UMfuvtSs2QAz7P56Z+7g3/y4fP8lZWl73sIrSm6Rd6ye4T3vW/fd3YyGoZhGIZhGK9dpnHwi0Zorb+nV3Y1m01KpRKNRoNi8TsoWTC+ri+tw+/OQgKMumn1xXIIPQXvG4b3jbzSe3i9CM0pekwRkqAZx+UWMvh8c+U/57vw76dgIYAJLw006nH6vh+owP9z8nIlUasNTx+B2YW0F83+3enPtzvV6Vr9fsxzzy1y7twGkPYf+dznzhMECePjBWxbsrLSZaneZ8/b9qJ+6Fa+1hPc5sKRHixM11n/4yfonF+HQgZRymJLgdvpE210GBzO8f0/eQczX6sx9ViCNSlYut8h6GnKDRguwq33xrwgI1ZDyNsK3Ze0WjarsQQ0+2sO/jXLbJb6mva8Qn5uls7TT0ESY+XzRLEktHqoMMC9Zw8jbzpEPoZFKdjwBTIrsCxBsSeoCehL6CUKr6PYO9Rno2TjzcLcEYmVE6xmu3SmllCrXZTvofeNILeU2VkHPhmzsSHIZ2HfQBcRdtlYFuzfD34AF84lVKuSWs1CSqjXFcvLCfGQIvezEe0JydqCg++AKMQ0Npos/+4FatMZHthjUctr1tE8/myVY4+MEGqL5D1Z9H4P2RGotoaMwB4VZLSgdkowtBvEQ+n5M34SNl6AwSzE7SZffmSayMrhu4K7hxa4ZaR/1fKmTidkerrBP/yH9181Ultr+ONl+PBSQlDvIVtdkJK4lGO44vOhccF95ivSMAzDMAzjRfFauQ69+D7/yZMNvPxL+z6DdpP/4+7v/WNqKmmM71gvgY8upz1D9lyxWmJ7Jg0wPrMGb6yklTWvJg6CA2Q5wLe3xOPTq+nSrkO5yw13MxaUbHiiAc824d5yenshDw/e/+Ls9434vs29905w770TRFHCv/yXXySKkqumPm3dWiKXc6g/eoHMrdvYM1xmPYLVGEYmynTf/wDJ01PoI1Mkay0iNE7OpfbGPWTu2EbfLjL7dEBlq6C9zUL7Aq8PdlXTrsNTUy69YZ9RVyA14EHWhU4HAgUdwXWLyKKWph4pCjOnkDohO5bur6c1Ky0HRJfSk+fJHJgk3FqhLMCLNMvL4GQFxVzaP2xUw1ZLknckX6jnGUKzejKmkIf2gCTOF6hWCsQaVvvpjmgbTpU07gOSgS4Ei7DQyPCWYZeorDl/RvNTP2XRe13Il7/cY34+RmvI5yVveWeGEw+26Q9ZzJ9wqfrg2aA6NssrbfJvLdH944QnFhWlHTGrsWZ2yUMOdHG2eyS7c+iZPlq6SGkh+5DpCdgqcHbCoVvg/gLs6cLvX0jHfQ+V4Zn5DnkfhocEa0GGeTXGXn0BW1zO2nO5dJnTl788xb33jl8KcKYD+PwGjGUsBot5IH/pOad68NE1uDMPzqtwiaJhGIZhGIbxKmcqaV40JqQxvmOnuzAXwM7r23Yw7MILHTjWefWFNN+JZgzPtmDEvX4iUtZKqxaebV0OaV5OFy7UuXChzsTE9elyrZZhbr7FyvFlJkbLnAnTyUlBDHEpR+ktt8AbdpFsdOjHmkwly9aqz3II545EqBjcnEBZ6RQiS0CMoFbQTAcwqNIqqotiDUqALaERQe2KNilaazpNjdioE6w1yFau2F8t0KFCZDM4Cy32fHmJ0VsLKAFRoPnEgs3EDsWdhywcAQXSkLDtQyuEzJomCDSlkmDeF9gKYglNC2IfIG3AmwiBVYWOC0kVGh2LfAh3NhNAc+EC/P2/X+Btb8uxvJyglKZatZiuBByjQTzjkag0oAEIeiHxQh+5K0Ows8+ZY4qxUOHWs8iWR2WgT/uAj85FYPWw8i7uSJZwQ7B3j2THjnTE1j8bhK0efOYcNLqwZXOE+/JyF99PX6zkBNQDn40gw2Cme9XnPDCQ5ezZdTqd6FJvmmNdaCQweYPfwwkXpvpwrg97TVsawzAMwzAMw3jFmJDG+I6FKr0Yd27QxkWK9OI5/B5LPS++58xNqg5skS71eiWEYUIcq6v6kVwkRDqCOYkSJOl7uNgo+OJIaJFxkRkXpYDN8eFCp+PCxeYmtbg8AU+TLttKxOWR0rGGVgyrYfq/aOgnsOZA0UnPFb05pk8kCVophH35YOqLW9+cXy7ihFJHs+EJzhUt6jVBkhPEGgY0bBEwtLlfAEmSvlctBEqk+9bxIBQgIkAJLBtUAlYC2VZ6DFounBuWhCWoTWl6vXR7uZxk+/bL+3d2c/+Sa0IptEYrTSCg72gcLSgrSaAkSgmEpdO+OFojbIVjK4oZQbOlWXFjKjaMKZvc5kbD+PIId41GJQq5mQpKoVAIEn39L55lScIwIUkun4Th5jjvG46Dl+lnFn5PL341DMMwDMMwXjKmkuZFY0Ia4zs26qVLfNavqZQA6CbpBfno91AVDUDZTiuDZnpQvma0ttbp2OTt3/yAqBfV6GiBctlnba3HyEj+qvvCMMGSgvJIuvSnZsH5BEoWWDKdzuVsBjExaWPhSIGUMFCBVgRaaaRKGzDrzWqaXhcKxc1qlRiW+ukSp4vX/ApApMvfVkMY9KDigJsRdAoF7KxH3O7hltL9lQKQIKIESwi8Yp6nhi1mCpK+BN0GYac9kKaAaQ01YL8GW0O2AIGAJNEkiabuC4QGEoG+GFbIzQCks/l+Nfh9GOpqVvOC5b0W795542M8iE0GSZBTKG2lAZdIJ051bYlaSeh9TWEtJ/SHNF4uxPFi4p6NvRERbMuhlMZyJCpKG2vbrTaPPtYjP99g5asvsGdnhYP3H0QySj+U+K4gn3dZWu5QAHqxg2/F5J3wuv1rtQIqlQzZ7OWTc8xLj2ugrp+6thal5/TojSeNG4ZhGIZhGIbxMjHdB4zv2JgHdxVhNkxDmYtClU4/2p+HW3JXP2d9vce5cxssL3de3p19kdgSvq+ahjGr4WZVCGk1xukuVG0YcuD5Bpxtw3KomdIx0zoh+jq9uleU4okg5tmeohlffZ9SMLsK5xdJxz3fRLWa4YEHtjA93WB2tkGvFwEQRQmnTq0xNpZnR8Vjca1HVaRVJ/UEMg4ECcQJBFojvYiMH7MWaWou7D9gURwVrJ3TRJ2EIFF0hUYEmiiC/TUItWCqkwY7OSv9kaQVNgUbMkAcw3wX1kKBXxI4WR93bIJeo0PYC9Fo+gFYGYWeX6OflDmyc4QzJYEXKZxZxXCoydgCJ4FBoAQsAZ9dDRhotxFugJXpc6KjqFvpsqaEtKpGiTRMihD4GkQ9ptOLCRMoOBpXaOIlTXfcYnZPmmMrFHXqbLBBRMQkLnvx0aWIXFax2oaNJcX5c6AGc/SfbhE8H5Osa2Yeg4XnIjLVNkHLxTkfIBohasTHzrl0VhI6azNMPztHd65L/Usnef65Bf7sz47yr//p/+DkF77EsfMh3QgqWyr0LYuGlKz5PkPlNnknuurz11rTaAS84Q2TOM7laqrDOdiTgVP99PO5qJ3AYgT3F2HgmsDRMAzDMAzDML4p6mX6eQ0wlTTGd0wI+LFRaCXwTDNdMrFZCMGBPPzseBpqAKytdfnIR07w+ONzdLsRvm9z220jvPvd+xgf/+7q0P1QJa0M+exa2pNHijSY6sXgxPBLS9CMNcLWuPmYgcGI7SMR212Lt0iP+4RzqanrvE74vU7Ap1dhpWchlGLCEXygZPFjA5Kz0/DXT8Lp+TREqRbgoYPwg3eDd82F9exsk+XlDmtrXY4dW0EIqFaz1GqZtPLFEkz9xsOciB3EjjGiN+wnHs4iJQg0Ua6PW+3ieAkNAfnYZlJkySuP3e+1+evPhLSVRIUSfAjChP3Dmr2TNqeW0yVHSkB3s5LGExAL6HUgCtJASwNTXShloNQV1LfdQm8lpD0/i0wSbKEJuwJFlenDdzDteTgnNEMSxmpw72HJkhZM1aGhQPVCmk+eoHN8irqICe7fRWh7JNqC/ihW4qF8iZZgeen+JYEinupDJ6YXaxwV0yg5rOZziBFBviL4iyaMLK6wdfg5GmIDjSZPnh3s5EfYRuBo4h19jp5XrLcUcQ3i83l6H5nFy8ZURgvoGNrL4BXWyA/ZdGYy6I+2kW+r0i65hMkasQOu9sjPLGB161S3lihq2NgIWDzyHBtYHA9fR9vKs3HndpKipujFLFgFnuqMsG99jVwcobXm1Kk1xscL3H//lqvOC0/CB0fht+fhZD8NFC/e/sYSvG/wJf+VMQzDMAzDMAzjGzAjuI0XTaTgSDutJEkUbMvC7YV04hGkSzD+/b9/lOeeW2RkJE+x6NHpRMzPN9m1q8r//D+/juHh/Nd/kVcZreFsD55vpY1xH16FCy1Y3qxWCGTCegK21FR8zeRAzNYdfWxL8BNWhgelx4pO+JftLn8770AkKbsaLTUbMXixzfclks7DknYHxqvg2LDWgnoH3no7fPCt6XIkgMXFNv/u332Vc+c2GB3N0+1Gl0IbpTTj40X8sSonI4epjZBwrY23fQj3vfcTl3zc4Q7+SIechIK28CyQXoIAtjdzfPqrgvW+g2gqlC1RRYlUCa6lODQoadouZQfa8eU+RbaGM/U0wHJVusxIa+iFYK3BSKIpORD2E9qzK7TnVmkuJuhukcLgMNHrfSILVB1cG975gODANonSsNaFxY2Yk3/xBBvPXaCPJvQ9vIzDxh07YfcI+C5uoPGqBRJPoC2g2SM5sY4dSoZLEisKWd1o0XdsKvvGGRvLIS3BVBxTsdZ5366j3DfYRyDo0iEiYj+3sI9DPNvr8W/+e5uZNc1G38aec7BWZ9g4fZwkDHHyOSzbobOiyQ/1sFyf3MAYW96yjfnuGqceO4OXkeQaTez1JoHW+EIwiQVCcCG2WO7Drvc9SHfvJE2R0O8FoEKqvR6uJxnsdNhz5CTN+QajowV+/ufv5NZbbzz3vpPAM22Y7qfh6d4MHMilfZQMwzAMwzCMF8dr5Tr00gjuh1+mEdxv+N4/pqaSxnjROBLuKKY/N/LYY7McObLE/v2Dl5raZjIOlYrP0aPLfOELF/jABw6+jHv8nRMCdmXTn0dW4ZP9tHIk0VDzNFNoCg6EgcASmtWGzZ6Gh6yGfFIF3CUcvpyEPL4hsSOLkYzabOwqyLmwLCM+fs5jJ/DAxOWmr1kPCj48/AI8eBD2TaS3f/7z5zh7dp2DB4ewrDS52bq1zPPPL/HYY7PsPzjMlJtFC3DLDirno6aWcU/M4N+3A0pdZCjwsRm9ODIpkbSsiKfiNhu6hGfHyEFJIjWIBJQmVJKjdcGuQY0vBf4VvU3W1sCqg10BZYOdpO9DRhA3wCsLqmUAGz0yyvPeMLoE+Z4iV4JmWZBrAXlNpy948gwc2AZ9BQ0BU80OC2NVkslBwnaA7PSJTi5Q+toFmhMVpGOhHIEQMSXLxWv3aRydwbMtQtcm70A2Bw07i6p3cTfWyWzLAZoMHVqJy9H5XdxXPYtrKVxc2rQ5yxm2sY3wqRzWJwX79iiOWDEDgwKGJikO5FifmqW1sETc6wOCsJPjwV+aYO/bJvCLLn/y/idxnlikvLN06Xg5CPo67akjLIugUkaeW6Fx7jzZBybZ71jEscdsUxJKRW5uhZlinvJAjQ/cM8aDD25l69byTc/ZnAWvL5GuETMMwzAMwzAM41XFhDTGy+bxx+fwffu6qUOWJalWMzz66Czve98tl8KF7zbP1NNlkitBeiHcE5pYa7IIsKATSlxLMdeQ3FOVnNUJp3TMI3FEt+tRdNLJQEiBSJ+CDAX1PsghENf0qCnnYXoVXphOQ5o4Vjz22By1WvaqY6i1ZmGhhe/bXJjv0MnVEKSVE1nPouM59E7MUXrrBKGjsPoOXbnZYHbzo8olNmfoYxVirJZNLATZMMJWipbvISX0tSSJFVzz+TYb4MXgB9D2ILRBSxANkAk0+zCs094ofQW9DY1MBCIniP10aZRQgBRYFiw2NU9saJZiQTeBZjsm9l0iBLrqEo7VUGMDZBc3sLoR1vQsXt6jUM2zdc8YaytNCCKcrEcYQi9K9ydGkM27dDe6RN0IsoCI8SyblZ7HbCvLznI7PR7kWGaJZZY5f34rWqcBlNbpOPNmX9BgiHjLEHqgixWHFB2NqGcZ2mfjFwVhN2T55Dpe4er1apLNSU5A33aIpcTPOqyeX2OXTpDCwnUsJqsW63mXO7dlaSvBrvuH+MmDpqmMYRiGYRiG8Qow051eNCakMV42/X58w7HQAK5rEUUJSaKxbvyQV71usjnOmjQAuTJTEUKjtEAITZykv3gK6CaKxdN1GlM+GwstZBAipCA3mKE8UYRMBq00yubqDV7abjoaG9KQJoqS646x1ul9ti2IogTFZvBBOr0pdi2ifnRp8xKR9o25YiGkIO3joi2IhSQTxeTCaHN8U8iG56KFINTXf3OqJF3i5G0+vOun7yWK0u11RTpdqGDD7izMqXRqk744+/vK9+tp4kHBqS6UXRh0IGr3CDs9Ei3SEeCdPsqyCbcNkQzmEM0edj9CdsP080kUm4/cnMh0+XhIKYl1jFLpYwQaIdLle5G6vB7o4vNjnbC0DPOLsK5hvmih+hIhBb6rcV2wMlmUzrIRQ9zRPDcjqexPUP0kHUF+1Qzvy2PMNRotNl9JSjQgr/hQpACEJFf0cRVok88YhmEYhmEYxnc9E9IYL5tdu6ocP75yw/vW13vceecYjvPdWUUDsCMHj66lYcN6CBlLIElDgCgRZBxNogXVrKaBxm2GfPZPTnDqyVlat+3HqRTwCVGJYuN8k/pUC1XzscuTyIYF14xHjpP0gn60mv6751ls21bm6acXGBq6PE5LSkG1mmFhoc3EaJGpapl2JkdHS8I4Im4E2OM1uh0LJxH0pSKLRG82Qk6Alb4mWPWJ6i5WoBGJJtYSG4UfxUgtsTKSrrQ4vtZHnVxAnV/GDmMiK0M0Mkq0fwhHWIxsQKUJ8wGstNPjdk8lnSzkSvhaWTC1obGizeohkYYo2oawLLByMOqnY6sB/JJPfb6ObUuiSIEtkN0Q2e6jhwsk2waJ59bJFDPpccp6aaWKSoMzV6avK4CgH+H6Do5/MfGwiBTk7YRaJh113WsnHH+6zpGnW/zp4zOcPgIrq0NpFU8kcQASTb8LSQj5PLgO2H1NPwPzkcVXzlncs0XhFV3aK12yV3yuF2MYicBNEtCaqBfhD5cI5OXfj14CvgV5Cy6EcG/5Ozp9DcMwDMMwDOPbZyppXjQmpDFeNg88sIWHH57iwoU6k5MlpBRorVlcbGPbkoce2nZp2tF3o/ur8NnltDGrAlQMvpQ0lUZosC3Ie5rBSsz5dkDz904zc2SDnUNFGlaP9XwN4YITR7h5aAcJ3dCmurCIWq7RGM9R2sxe4gROzcG2YbhjR3qbEIKHHtrG888vsbjYZmg4R8f2CBGIrIfas4Xp2w/TdMq0O4LQTuh7MeKOAzjjFay6g912iIsBcUuxkQhi16W14dJtWhS6Fp2+JpKanlD0tI0tQKoYpQUDMqb3zDzBF48SrbfRlkRLiQhiSM4hH6lRO7yfvONh+R50PdwwJpfAgOXgSonWsH1CMFOHfkdhqR7JYkgibcJSHjzBsC+whCCK0qokv1rAKjcJO1FagaPBTWKiXoTYaKNqBaLxGoVKGoXka3n8nE+n0cXKZcm7At8CN45p9BMGtg9g2Wk1Upj4JEmf0XKdZLnBF5/t8dVH+qwutumccuivNBHiFN1CDlXXWDkb27bTAiCdVjk1W1AsQLgKxT2C0V2ahYbFM9Jjz9u388TvHiXEwhJg6YQYsIXAAbwoxA8C2hoO3D1JKCTNOJ3I1EpgdwbWozSoeaD8sp7uxsukF8NiP63OG8vAd+lqUMMwDMMwDOObZEIa42Wze3eNn/zJW/nTPz3C0aPLCCFQSlGpZHj/+w9w991jr/QufkcmsvCz2+C/TqWTnpYCQV8JEiGwvQSRS6huCZlKYOpvQ1YYo/SGnWzohOH2OvHaKvVKlcj2iWPQlsDv9BFfPk7gFlnM3cfUStqvRgBbh+DnfwAKV5Rh3HPPOO973wH+6LNzPBJUaHkllBDYD21F6hz1rymCGYhCgZIShjQ8kCHO5SDS+F9qoYdW6VShpQVhWCUKa2RdyS25iFJtkRXHTkOTvkOvnSfpO1Q8wejSEuc++TQZAfnRAfodSRRB7EPS7NJ68hStJ04gyxVIYhSazPYRnm/s4vhUji0jObaNZJF5wciOPrNPHyc4PgdHwnSs09ZhuGcvUanM9IKi29IEliDyHaLJCeJuH9ULYLVNVG+hSZAbHRgqoIbyRF5aiuR4DkO7RrlwfAHR7dLUmiZQsCyy2yrokSpzfWho6CiP/IVlPv/HJ/n4hTqdDQWFDN6+/VivG2N8toGXJKzHPsnxhFj1kVvyOJYNAhwnXdZVn9FkpaZyr4VjC6o5xdS6RfGB20m+VGd5o4c7UcKPI0S/zaBW2Ju/H8mxOco7Rxi4eztNC+bDdHJWxQZEWlH0Y6NwS+4GJ6XxXStS8Ol5+PwSrPTTcGZHHn5oHO6svtJ7ZxiGYRiGcQ1TSfOiMSGN8bJ68MFt7N07wNNPL7C+3qNY9Lj11mEmJ0vf1VU0F91TTZfvfG0DjrdgsS+peRq/ALmSoh26fOGEpLHWpmRpyiqiZ9msVUbZ2l5n6Ng052QOYVlUoojSRptWophZWeLNuxvcMlAmjGG8BnfshGL26tcXQrD/TXvJFLZRXA0ZjwMynsXRfonVv5V465r8QIDSCVZskfRK2CctipPQOz1N8sWn6aPpH6ohJ/IkWRddX0WNOpyeTLClSpchkWC7EZlyn+ZGGR06qIdPkJOabrFKbw20AsuBpBuj5pdAh8hQofsNVBAgVEJmqc/wAKxuZJmbcyjcswVvZ43os0/inJknyufSMpQ4hhMXYL3Byg/dz0Y5T94VBBlBpCCJHUTWwsq66FIeUShhdzTWqCQq+USWxYxQeJGkFwpCL8ehe7YxJloE3RDblgwOZilVs0z3BF9rQhLBQL1F9mNPs9KOqGcHEU5CfqNB/0vn4bDF8r078df6uFmN3UzozEmCKMSasBAWqAD0uiaxBKUfsigeSs9xCay2JeuyysSP383cHzxGeH6VsJzHyeSRnSYL622azYCRkRz//JdvZ+jOAlM9qMcQaSjbMOCmY+7H/Jf9VDdeQlrDn1yAj89B0U4raGINxxow1YYP7YG7a6/0XhqGYRiGYRgvBRPSGC+7kZE873jH7ld6N14yAx68bST9SQnAQWuHX5+FjZUO3vIGtYEsUiucOMRTCcu5MtZUi4n6OuUr+s9kaz5TK00efmGOX/pHZWrezV9ba/jUgqDn+Lxln48QMN2B3mfAXgVGBcrNUBDgy3RJVnceStMJ4dMn6UaaYKCKPauJlI+YsPE6LbxMQNJ3CXSBsu5jkUAAsafp5yO6Z3sszDbZsafKqbl0OZbtpWF3XG8gWh10JYdaD6DeQNYKOPkC/XYHmnX2b3e4cGGFF76wyq5wF90zi1jjAyRWuqQqEi66kIHpJfSR8yQPHqbr6rSprgbpgFYSW3rgauxtPt66xs9oEg/WY01DC1bQjHiCbSXYWrJxrcp1x1AJCDSMuAr5+AnCSKNHatjtEJFzEZYNnSby2BTBvjHauwfIz7Vx7wqRA9BaSAg2XGwkwhH4t0jEbom4VVwKIht9SazSZUvDd45Tqj3I/N+eYu3pWaJ6xEo3ZsiR/OiP7uenf/p27rhjFIAHrt9d43vQ+Q58YQlG/PT75KKiAyeb8NFZuL0Ctln6ZBiGYRjGq4Xmpa900d/4Id8LTEhjGC+TlQCOtSAb9EFr5BVTfTyV0FeCIJNlV6d91fOEEOSzNssLTY424P4BcEQ6meha6yEcraf/5f3i/RsB9M6Cl4NQQphAaTMEkjZYHqw/20OtNYnKZZRO+1+EhRzECe6gjXQCklaCKkrU5hQrABVJHD8kchRdJLGycGIo+BCJtJ+GqjfBstL1GgKIEnxXIi1BYts0lpoMbBsgO1hk6ewa04+eRduS2LK5avCRFOnarrNzqNcfINRWOiVLgBMDgUa54LmQSJA5SDqCSUfhO4KlEKolzZsKN7+4VRpe6AACtjZbTB2ZQQ6WiSKNVAqJRd+yIOOj63XEhRXi3aMImQZFhV2aZKyOvcvDqeTxLIFVhjAUBMHl12n0BLaVvl6soLCrxsCue5lYO0xuNmTAht94fZbx8eI3e3oZ30OONaAVwbbs9fdNZOFCGy50YFfh5d83wzAMwzAM46VlQhrDeJlEmxfkltY3XtqlSRvtXnNzoqGnBJ1Owq+fgvE5yNnwumq6vGo8c/mxoUqXRVw5JCvRQJwGMpBW21z5GsKGpA86SdCbDXOFIA1FtL48Inpz/LO+Zg+F4OL8ahKVBtwZG3IyfUqiVLoNAQqNFlwKX7QUJFEauQtLgNLE/QhhWenI6YuvcfEQWRKiBBkphGeR6WvCUOCSHtvEveK9bb6+0DCW1bSSNNA51oOqDUNOGnYB9BUsRNBO0s9hvw9bVnpcSBTCsdJk6+KHJNLgTAAk6YgtLS4eC4GrBRXLojcmiCyQQdpH6MqR5kqD3BzL3tOaDooKktsHy/QKElvAyHd3iybjOxCq9Ny/0deEK9Pf8fA1sibbMAzDMAzjtcaENIbxMqm56dKFWdtGa42+IqzRgCUFmSAgUOloZYBODPN9WG/GiC0+59eh0YFCBk634OOL8NZheM9YGsxUXRj0YLmfLo0AyDpgD0J0AcilVSSJvhyUJF0o7LJod3ysTg+Vy6I00A8hnyHpKGwlEL6FRhBIhwg7DZushCSy0Am4UYTvahxbEMdpRYtnQT+XQa/1AZBIlBSozQtMEcbkhtJ+RFG7j5VxqO4cZOGZaQRpiGGJy5WNot2HiSGkY+FqjQNECWkgs1nhk5AGNToA2wHXhxhN3oGfHNfYS30+fr7P13oaJSCTdRiu+OyvuTxUFrzQhMfqUBjK4xd8es0uwvE3KzgFdpIQxQlCSqgW0sAoTvdQJQohBMXApbTosF5J6GcUgdRkEQSko7UdR1MPBMJK92ESi33SJo9kPoTX1S6PGDdeey5WwoUJuNbV960FUHLSpVCGYRiGYRivGqZx8IvGhDSG8TLxLbi3pnlyKUOU9QmCGN93UMCKmyEfBMi1DebbCYM+SMfifBta3ZhECQpbhwm7CbNdjduSDOYk+TL8xSz0E/jxyTQU+b4R+L2z6cWcKyErobAHlqfAb4FXTatKfCBZF1g+1O7I0AnHcZ8+g3YsetJFrDYR1SLdxQAx6uFscRGBIhI2MtGEWiMtCJoewlJUyx5Ju0epmGV5RaMCDQqsYpGw0YRWH0cnUMrQ7wXYkcJJBMotsl4PaS7WGTu0jeEH9rB+boVktU5QKyNIR7XTaiMsjb5zEuFqKpGm50mE1PQSsKUmYwm6UuBEGnpQGtdYjma2JxiQEXz0BF97eAqrEzM6UKSrLKIgRuQlWw/VePDHDzHpOhxtwUY+z8At45z+8hm8QYuu5yCVJh+FRI0meqiMLmfxT8wTuT4eEAQBfsYnm8siu5JsV9L1FXMywd2uaGuw0PgZhWjblICHXIeKTMtx5vvpRfkbB17RU9V4hd1aTpcynWrB3sLlyrhWBMsBvGsCql+nN5VhGIZhGIbx3cuENIbxMmgrzcd6ii95EIxYbFQLrLRCaq5HEEFvsUv4hbOEc21U1WUha4EP2pGI9SbeQIF8JUt9cQOtNdKxaOZzhInDgSHJpxZhdz6twHjzCDy7Bn8xDWv9dGlNZwD07ZreMdiY0iSJoJmAzGnyd8IFWxDedQA90yN+foHYUZBdAd+D8UGioECp38P3euggrVpREXRW8oTLPuWixdjtE6x89TQyZ9GbkvTroJUG20VnBxCNRTQhmWqReL5HGASE+Rznp9rI+T7l7aP88k8fZMrN0377bZz51POEM8vEkK5Zqrrod+6Ft42irJiVZQ3roPISpS0SnS4ps3sge+AMKPSoYlkJrEiz/ysv8OWvnGV4uEDRLnF+VtDpA0hi+vz5idPoKOGDH7yDHyxa/OYJmN55kPUzAf1z86gkIoliNoRGDObBklh/8RWsbkhvpEJSK2FNDFIZG0HK9Ko6kbAqIS4KXDft51ORgl2uxbGMoGgJZtowR/ofBqoOfGACbi+/Qieq8aqQseHnd8Fvn06DGqXTajJPwkND8N7JV3oPDcMwDMMwrmEqaV40JqQxjJdYoDW/3VZ8JYBBqXlwHIb3Sh796wVWLmi8UNJ7bImklWBZGrneJw48kqqNWG+SJcHdMUY/SPBcGyEhihS9tQbn4wJDWZ9MTvClFbi/CuebML0BgxJGC+myifVE8/TdmnAC5FmB7GtUVqAmoJkBq6vJ9j106zaswQn0YANdApUtoQdq4Fp0l32Cuo+lInAFStnIDZvqcwrdj9F/5xbiC12mPzmDimzsfBZlW6ggRixH5PNVHnjnEMvnSxzzPaIJCxlGiEhDrkp3yxC/+7jLb7wPfvDHJvnqvTWefGqeU80Wp4s2/f1DiPEyGUehREI0CWJMk1uJ8dqCsC9RkSbnK/ZM+gwVXRJhEYQCObdM74kpJiZKzDZ8js+Ca6fLxpQWtHoZ2pHkwx+fYv+hcZ6fHiO/AONlj/i+e8mMrKDOrxD3myTRKtHMCnI9xEpsfF/Qb3dpbzSpxJri4QMAJEIzXUloZBSDWUHNEUQalhUs9hT3FCT/y07BVBc2onQJy20l2HKDZrHGa8/2PPzKQXh6A2Y6aTXN/hLsL5qpToZhGIZhGN/LTEhjGC+xZ0N4IoDdlia72QjmjkNVrGaPz/zhc3SeWSXuZckUHKSUKA1RHMJiC41FeNtukvFhHNshVAJbg+dIfEvR6/Q4s2LzYMnheAvOtOGTM2lPmvsGLjce/ZtII2yN0xGIWzTSEmjSypPEBiuGYFqTK9oMHRijPrqFPoKkKFCRQCiNTDT9FQ/L97A6MNBRjLc1fgVOH9e4z1lkOYzIV3GdGVS/hRspPGmTq44S9Ce4f3KcPziisd/gULHBCS8fp2ZTc2ZK8+Fjil97SPL6oRyd+3byS502M+sOfgLJZtviuKOxc4okYxHlLEaDDpYNSaLpC0WQjdlmV4lii5kEamdnWYsTLM/n3CJkPchf0dPDc2C95TG71uBPPjzN+uAot04IHlmBgdiiOjaCGhlhaTEhOfI51noBiSeQvZheR1LM2URjFYK1Bp25FdyRUZZtTcvXjLqCkVLaaNgCej1oAFtGEw6WJAdLL895aHz3yTvwxqFXei8MwzAMwzC+CaaS5kVjQhrDeIk9Fyo0XApoLtL7xin/mM/K2WeRwSpxq8tmy1t0tQjbt8DgBNFgCdtJUHozWFHQS8CREh2GNLqKJIauhjNNeH4NRq4Ywa3QXLA1bqTpK0EmBzlXozU0I+hbYHVBJpqhbRKVkQgByoYYgS80qg/WkRh1XmGNSvxE4wnI5AEhqAzBzJEEzgu2FrZQGthCHLRx4gTbcnGcLGfPwkc+oqgXLZw82M2rj1M+D+urisfPSabugp1FOK0TXgggSQQ1S9NT0E40sUzDGklCnLHo2Rb5KMGyBLqjaQUx52QMocWbq5ojU8tUKj4rDehHMHRNtYrWmryv6YgMjz61yu0/lNBObBoRlC+OK5fg6ID6ep+d92yjri122hq7LVlec1nrCpZ6Xc5NLTEwPIo9qqhWYDQrUBpafehFgqyruWM0plHQdJSFE2tsW141kt0wDMMwDMMwjNcmE9IYxkusp8FGwzWjq2MN/nAJObmHzMAkvmqhkoQeEn3LVpioIJsJWscInRBHXJqKBJBYoGKwYk0rgrkO/JcWHFtLmxQPZGBrHgb8tFpGKtIJ0gLsixNj4sujfoUC6QhiCeLinGwNYjM4IgDZ1siOTqtWrvj2sGxIQo1OoCgEOSXAKYJz+TG2rej3NVQ0UgjEpZlNKSGAJJ1oszmVmxhNvDkyXJA2Qdaxpg5oJBqNRpAg0DqdvhRIjzhw6Hnw48PwrprmH2mNZcl0YrZgcyS2ptvs0lhr0Gl20ErT6SX4OZ/2yjL56uil6VKX9pGEJFbYrodre9SqsKcIQQCr63DstGBgMKZyKzxjQSMRLLfT45d34cBowpZSQn2xzonPLfCPn11GBTGWJdm9u8oDD0xy+PAw7rUjfQzDMAzDMAzj1cxU0rxoTEhjGC8hrcHvCs7MwFoPhBCUC5qxgXREdpCRZMoa1c/iVnw6MUQxyGweLQVKC4RMCPoKYctL1TFoSCJNoiSt2OKLM2mIYeWgG6UNg5d7cLoBwxlBtgprOYGwQMWabqNP3I9JYkXkO8i1Hklfs3BilaRQpjcwQGLb6JwmLIF0wBmQhMcSbK3RWpC54tuj3dAMjkqsQLGwoKlUrg6klNKEIdx1l2BtAYIoHT0tr8hpggBEXjBeSSuBAIaFxYAN54Qm0uAIyNqC/kZEnBFEOJBAFDs0sREaLNlnT63LP5p0ecj3EEJSq2U5e3adQi2PENDrRqzMLNButNFKYzs2sRLoJEL1Yp7+20fZOTeKved2unaG3OZ7jUWGQi1Lp95FDngUNm/3PBgd1qytKD74lhJvfB38x3XNJ3sJ26XAs6GW01hRxHN/9gJHvzKL2w6ZqGXxfZswTPjKV6b56ldn2LOnxs/93B1s3Vp+KU5JwzAMwzAMwzBexUxIY7wi6sS0SMhiUfseOQ3jWDE/30Ipzehonlja/PEx+NycYK0LKxLyEqaXBSemIFPWWDnJwC05Vpb61ANJ3xVYUsNaF130wBbIrI2IQwQJaJkmP2h0FCMKBbSwWOtB0YflDgRAT6UTYYIImg2w1yTh9gSdiekuRARhD01CaFvoZkh/tgtzkqDlQTeB9RWolSCXJVwVWGXIbJVQEagljRzTeEoRBIK4Lwm68MM/buMvS37zN0NWVhSFQhetYyzLZ37DpnxQ8zP/H4v6P9c8Nq9pT0jyHY3UEEWaZgjlLYL33QblzfHCE0h+KGtxpKtoBBYVqbGkwPcdWkmEFAm1doPdqo0EOp2AzNaIh0Zq7NOZS6HWG9+4lRdeWGZrXlHyEk69MIvud8jmM1i2RaIgDDQ5P+SOO0a50Mxz/oUZvEZE7657sQs+QQesjM3EA9s5/YVn2FLoMeSnaVKSKE6fXmd8vMjdd4+Ts+H/UbO40IvpasWoFKgw4cn/+hwnvzyNNZbn3h0VttqXK2bGxgr0+zHHj6/ym7/5OP/gH9zH5KRpWGMYhmEYhmF8FzCVNC+aV/Tq+Mtf/jK/9mu/xlNPPcXCwgJ/9Vd/xbve9a5L92ut+Rf/4l/wO7/zO9TrdR544AH+03/6T+zevfuV22njO7JBzGdp8Dxdemg8BPvI8FZKDF+5Nua7iNaar3xlhk9/+gzT0w201gwO5Ql372JuZDtbioI35+HZSNPS0O/D0gYEy5JqBuyRAfR9HVpN0EKC0sh2Ap0YOWSTzbn06wlRuw9JsrnmSUM+i7BtrCQmxKGuBN0cxAJUD8IlkAuQbI7w5bhA705g2CbqlSCIoRfCfAumBSxp6HUgaaTrl3pNmByDSpFkTbOBhf06m+grIc0zIf0gTJchFV0Ovs7mg+/OUHY1zzyzxqc+dZLz59fQjiL39hLln62x940lHt7t8Ibfc6n/b1lOLmRZLkmQ6TKr4gD87Osl79lxeXSNEIKfdDKcL3T5M61YTdJFUqLkoOuQXdkgzxIrToIA7BGLrKjwwtNj/EoiuWMI3rUD7rprjO3bK5w+vYrfj9BBB+Fl6cUSYhBC4yQ9JkZ8btlToNZyee7cAEsXFrDFcab33U5YBXsrNPwd2OttOmfP85WnmlT8dDnTxESRn/3ZOxgYSBvebLEkP+U7/HEQ8YLSLH7+POcenqa8vcT+gs+kdf2IHt+3ueWWQY4dW+EP/uBZ/tf/9Q1YN3icYRiGYRiGYRjfm17RkKbT6XDrrbfyMz/zM7znPe+57v5/+2//Lb/1W7/Ff/tv/43t27fzK7/yK/zAD/wAx44dw/f9G2zReDVrk/DHrHKCPgPYDGPRQ/EYbRYI+RkGGfguDGq+8IUL/N7vPQ3A6GgBIeDYhQ7PP/EUd7wloPrm/YCgYsHxDhypa7KJYDiT9pRRUiIG8/ilGKfRw9KCpOYQ2jaiaNOPFLHMgaXT9MV1wPXA99BhRCQTyELsuViAqyFuQTAFcQSOlxbfqLbGe1JgbU/oekuwUMe+sAGLPWJnF0QBxD2IFWgFIYjFJXQ1CzkbNjTWdo391gi1YCPrAk9FeNl1JpihPn+IrmtRLj/LLbc06fRyRD9SgAds3H4LZ12Ri0dYGw74/l8PeecnJM897tF2BHt2Sv7u90vumZBcm0mUheT/m8/xI17MnzUSpvog+pLOsot1DhytCUohS8KmIcrk7RKTgxY94G+nYaoF/+h2n1/4hTv5tV/7Kk88Mcdg0cXJaIIoIY5idBwxMOJx992j5HIuO3IwWLQ4ls/TC2ep3b6Xc/ksJR/GshbVH7uNs2e2EEwvc/tAzH0789x55yiVSuaqfb/HttkuJY93Qv7zV+fYXfA4XMpSFmkAdSNSCrZvL3PixConTqxy4IAZ72MYhmEYhmG8yplKmhfNKxrSvP3tb+ftb3/7De/TWvMf/sN/4J/9s3/Gj/zIjwDwh3/4hwwPD/ORj3yED3zgAy/nrhovgufocpI+O3BxSa/EfSRFLE4T8DhtfpDKK7yX35pOJ+SjHz2JbUu2b0/3XWuIKi5Os8XsU2fYc+ckuUoOH0G/Db4STGbT5rXngrQHzXBWsCAcdu1zKLgwNQerfchGivV+kjac8SQ4WQQCHccXB0FBnEASQmAjkUgL1AxYClQ5DWq8MCZwEoQlUOcl7qnj6OYqLK+jRg4gPButmmDbIBKESpBCoaMQ0WwjC3mUkoSLkN1rIWsSJSwO1lc53F5i+vklPvlJn0zGZm6uxZveNEx7yOLYGzzcnkYmgqWpJu2xIju2FDnnBWx7T4e/fE8ByY3Diiu5QvCQ6/DQoIPS8H98DZ4J4ZZDRYQocroOS8sw7EK9C90YBjNQ8eDoOnx5Dt67u8brXz/Js88uorUmCEI8oSkVbbZsGWTr1hKl0uXwt5CFuw/mePzZZZrLC7x+905q3sV7BQOHBjgxOUBYgocOg32TgpdBKRk9tU52tsWB7RX8b2KKUy7nEoYJjz46a0IawzAMwzAMw3gNedU2Azl//jyLi4u8+c1vvnRbqVTi3nvv5dFHH71pSBMEAUEQXPr3ZrN5w8cZL7/n6OIhLgU0F1kISlg8S5e3Ucb6Ji7aXy1OnVpjYaHF7t3VS7fFCtZ7UB3M051bYeXCCrlKjm4MK720YfDFIopYQajA2mw10wkh76S9ZDwJuqOxnwhguEOcDxCZDKDTJygFc00oCihZ0I9RjkvSAdUCmUsLYpSGRCmE0sSOhW5EWLGHCGMSIdCZAXQSpjsgZLpzElQUoW2J1etDLoeQCbqtGWm3KGQUdcenSEhJR4yNFXjmmQUARkZySCloDkoSB9wNDU76mS8sdpjYki5tmyZgkYgx3G/pmC904NQGTOQuH8e5dvoSORvaYdo0eTCTHqayB19dgPfsgl4vYvv2Mjt2VOh2IwAyGQffv/FXoZSCdiwIVtpUb7CbE1k434LpDuwo3Hyfl5c7JIm+6evcSKHgcuFC/Zt+vGEYhmEYhmG8YkwlzYvmVRvSLC4uAjA8PHzV7cPDw5fuu5F/82/+Db/6q7/6ku6b8e0JUDg3CWAcBBGaBP1dFdKEYYJSGvuKMorNjjFYUqDRqDj9Nkl0GphcOdL54mBuRfoPWl/qC5zerkHOx3jnlxHBecTwMEiBLmRItg+hFzvofH5ztJMg0mlRTRJvblOnG08iiUokGgkh6NwkstdCyT5CXGxGfJnYHGmdvqHNfxAaNNiJIqNj2igSkb5vx7EIwwQhwHXTahRlXf05WpYk3pytbSPS8drXjOH+po55ko4vd67I+qLNoOui5IovcFdCP0nfRhQppBR4no3nfXNff1oIdJxwo9VJrkxfO/wGfzCSRN/w+V+PEIIoSr61JxmGYRiGYRiG8V3tVRvSfLv+6T/9p/zyL//ypX9vNpts2bLlFdwj46KteJwluOF9DRIOkLlpiPOtCGJ4bhmeXYKNflqZcmgI7hiB/LdWtPENjY4WKBRcNjb6VKtpPxJHgmdBox1iOzb5Wh6ArJ3+dGNwN4f6WAJiuPSuPTstZpESogSKjiDyBXEvi1jtIDsrCNtB1SKSiQG0LdKNZXxgc6MZwNWoHmlI0weVSBAaHadBi7ayqIHD4I6CTkBmubR+SoPWCmHbaYTiOGhLoLSEvGJph8vcco/+s+eoH59hkZC8oziwr0Yu5zI726RSyeC3FEKDkiATiGJFpZKuF2oQU8L+tiZ75YkITi/yyKlFckmIm3VhYIRebYSC4yAEFFzQSrM2vcazX5tnOG7xW89azMw0aDZvfA7ejINC5zxClYYyV1oL0iVVI5kbP/eiXM5BKY1SGvlNLHcC6PdjyuVvsGHDMAzDMAzDeDUwlTQvmldtSDMyMgLA0tISo6Ojl25fWlritttuu+nzPM/D87yb3m+8cu4gx9foMEfIKA6StNJkhRgLwb3kEd9mSKO1ZmGhzcmlmI/PZ5kNPZROw5JIwRemYKIIP34Q7hqF1S40Ayh5UMteuZ10jHUngmoGyl+nP/ViG87rIgM7Rzn15HmiKMF1LfJ5l1Ffc+HEBnsPjzKwdQBIe5ZsK8Jzq9CL033zJfg21Ptg6QTZjwi1xM04qIag5knkLovlJyuQrZG4bUQxh252UWttGCuC7aZJjy8g0CSxQhc0zEiwRZrdWJsBzEaCsBK03YFuG7xSWjEjXdAWqIvTowTCsZG2TTKQh7wFG0CpwfLcItQyiHtqJPmI+l8dg3qbAIcffcdOjh5d5/z5DmN2hmzdplWVtJ8N6NZrLM0WKI71aBU1b4vLrMy1WBUwOppnfV3wxMkOWipGKxmGyx6ZIrRi6MTpUqaVqRX+x58+w+zROjMtyGVsIsshUMv0vVM07znE1j1DVFWfx/7sGc6+sEDYj6iO2Dy5DAsLbaam6jiOxeHDw98wMGm3Q0aKNvkDQ5xqwt7i5QqeVgQrAbx3K5S/Qfh3yy2DVCoZ1ta6DA7mvv6DSUd6B0HMPfeMfcPHGoZhGIZhGIbxveNVG9Js376dkZERPve5z10KZZrNJo8//jgf+tCHXtmdM74t2/B4FxU+Tp3TBJeW+RSxeAclDvHtVQ0cP77Cxz52kq89v8rzC4rY8zl41yQH37AXx0+nRcUKztfh1x+H7eU0iOnGaWXLXaPw7r1pcPKRk/D8MgRJWnVz3zi8a8/VYc16F/7VV+BzF6AVCEJxC/1uA/tvz+EGPVzXojacZ3z3Noqvvx0hL5df7CqmAdD5Jsx20uCh4sb0WhHJWpvTQYhtCTIZm2KSw7JyTBzI0vUEG5l7iL0GnDwPJ1dgQcHw/rQvTTwCGRcVadAyDWY8oK8hYLPfjACh0HEDOl1odyFuQ5iBnANuDdpNsGJ0BpJEQKkEOQ/me7CxDs88BkETyln0/lHC12/F913Knz3PqVnJb/zHaar5GsePb+B/rUn5ZJaTk4dpRQMkuBybAfdzAdtzc/QWvsqftSK6XZ9znVFmylk6UZH/P3v/HS3pdd53vt+995sq18mhT58OQHcjgyAAggApgsEMiqZH8rUlm5YsSxrbEseyrPFc2/eOwvJYVx7/4SvdZa0ljUfyeDlKskiLIqnAIUGKCSBA5NDoRucT+sTKb9p73z92dUAGqAYRuD9YhT5V9dZbb1W956xVv/Xs59GbVZTJaR6wtA5E2KaECsQU5GdTJrst3nlzRNGp8aSYIourYC221yd45DQLieZPHj3O9tFzTC62ecdSzKG2e/kHDrTZ3R3xwAMrhKHiuuumX3TKkrWWM2c6XHfdDD/ywSn+j+PwVHe8gsxCRcH75uGvLL/8OTo3V+f22xf57GePMzVVfdlwaGWlx9xcnVtv9SGN53me53me9yYwbrPwmj/Hd4DXNaTp9/scO3bs4vUTJ07w4IMPMjk5yfLyMj/7sz/LP//n/5xDhw5dHMG9uLjIRz/60dfvoL2/kHdQ5wAxjzOiQ0kNxTVUWCT8lqponnpqk1/7ta+zsTFkM2xim4pWOeKpLzxK3h1w+1++HRlIAgmLdfjUcbh/Fd6/H5YarsnsZ5+BRzdckLM+gD0NmK5CJ4VPHoVzPfiH74BqCGkJP/UZ+MpZaMRQJaOTaUbvvJPwqoPsW3uC6u4mIHjXO2bY3VPnkS3XxLY9LvCaiWFYcVU1d82WPPX5x2g8eobdyRnOiCqNMsOeWUWImM71d5Jf1UC+v0ol02SffJLikTPYegxNCUUOlQqcWIVZoO6WVpEDbUADvRQkkGawsg7bu26klNawrWGUQkPDdBsm2pAXoHPEVAJLs3B2hF1bh+4TEGaIegPZS7FfOQ7DnPL7r6dYVWSf2SY12zSvidl72/s4udXnWH+SopcgrCG0JRhJahMeO7uf9Uc3+PANAx5e28PJfQl60ESdqSIDQXZNwPnJgK0NS3LeMr0XtikoZAOzdDWfzwecqYeAolZmCCy6XSOrVDj3xHmWVla56+YplicCWpcV1oWh4rbbFrnnnlM8+OAqe/bUX3BJkbWWkyd3qdUi/vJfvoZDbcn/+yZ4YBvODNyyp2tacF2L540MfzEf/vDVPPLIeZ56apMjR6ZfNKjZ2BjQ7xf84A9eS/ulSrk8z/M8z/M8z3vLeV1Dmm984xu8733vu3j9Qi+ZH/3RH+V3fud3+Mf/+B8zGAz4qZ/6KXZ3d3n3u9/NZz/7WZLEf3F5M5sh5G7Cv/B+rLV86lNH2dgYsPfqWY6fFrTrUA1D4lrMmUfPsO+mfcwfckvnTnddQ9lAuhC2ErhLO4bPnnBfvD94AC58d67UYSKBb67BfStw9z7470fdzwt1qEeWU6dGqDJnohqzs7TM8cUFrsq3KdKCP3pixI8c6nP93gaPbMHZvuv60k7gJ2+Eu5fg4S+f5HP/90NEkSI8vUHQ1ewiadVDiqykvecYK9VbGJYCTu6iT6xgF2cQ9QSrgI0NaC7ARBWKPtgaaOFS7ABINAQF4nwfsdKDTgebZa7yJANyCzUFQsP6OaKpCnpyAS3aiGYVFUqCzgnywSl0fQgkoBR2KoR+inhqHXPHfnaOTNDctIyu2s+JhYjBnoR0tUV+RoI2yEBhjEIWGjoZoh6xu3wDDz58D6t7A6hEyCcCrNUEexRmQlIODTpQBMIwXANRHRJUE6DkmJyEIGeq6CLVhTMiZ6Bgy1ZYblS4cf6F/7zt2dPkne/cwz33nOLrXz/HrbcuMjVVQQiBMZatrSFra30mJir82I+9jbe9zZ0/9RDeM/eCu3xF9u9v83f/7m385m/ezyOPnGd2tsrsbA2lJNZaOp2M1dUeYaj4oR+6jo985NC3/mSe53me53me9+3ke9JcMa9rSPPe974Xa1+8ZkkIwS//8i/zy7/8y9/Go/LeLDY3hzz++AaLi026uSAtoTnuLxNXY3bLXdZPrF8Mac723PKmzLgeMPPj1iBCQD+DanQpoLkgDlyoc/+aC2m+cNpNFmpEkKaafmoo4xoDG1Ig2ZEVjtpJgggyU/Jv71e84xDcuQy3L7v+N7NVqIWWL3/5DL/6q1/m2LFtarWQKApoC8H5tGSjN0IUmp37nkDP19FXH0Sf3kCPCphJXB8ZDYwyOHsWJmsw24DJAoJw3ENYwFYKp7awA4Mb/WQJxqGALgwWCGsRVih0GjDT6BPO56xvpOh0h+mDLXbvfRJdi13CpUKsARTYWoxYGWGPbsKHr2XQasBAI7d2qak+vbMJGItINTJ0S7DKisKEMWKk0ZM1Tj7SJG8L1CboMsQmKUW9DgaEcMdZKrCpQEhFUjd0TUwmFFUJzy1iiSkxCLYrE0DxoufOwYOT7O5mGGNJ05LHHttACNeOZ3Iy4UMfuor3vnc/R45M/0VP02e54YZZ/uf/+S6+8IWTfPWrZ3nyyc2LU7RqtZDbb9/D3Xfv47bbFl90GZbneZ7neZ7neW9db9ieNJ73cvJcU5aGMJQY46pULv9eK6WkzErAtWQpx2OahXn2xGkzHnf9YnlhKGE4/r4/LC5NYuoX0FcVtAgBcXGcdiAskTUUWHq54r6z8Pg6nNiGj98FFWX43d99gk984knW1wfU69Gzmsk2NJxNJZt5TJEZ5J99k9pmB6FCeoHr72stLiwBN87q9BY8dRLunoK4CalwL+xUz/WeqURY9w5hpQSt3WsW7lYECClRoUIJCCnRRtPLCvLcQEO6UEiIi+PBpRRuzHYjgkaE3uwRdktEUWLtuP/wmGD8vhfGtYuuBVAKShkABnFhBjm4gMmKi1etsGDdSHAJFK8gvDDPi2+eb3Kywt69Tf7+37+dkyd3yXNNHAccONBmbq7+so//Vu3d2+JjH7uZ7//+Ixw9usVoVBCGioWFOvv3t30443me53me53nfwXxI471iOzsj7r9/lccf3yBNS6anq7z97Qtcf/0MYahefge4scIPPbTGgw+usbubUa+H3HjjHG9/+wL1Vzkfe2qqyvR0ja2tIZWZGCncJKdQuvHLRhtacy0slh1hyRPDasd9Ad5QliUkDSShAqUuTe25QFs4n8HjXdBN+LVTIBN3ey+HjZFyVR6AGkcg0liqpgQskSmYqSdEkZsk9cdPgzGWmwdH+b3//DhlUkVMTnL+1CYmsTRiQSWEQAqCpIESiiSCbDpi8MBxgtmJ8Rht42Z0CxDBuH/WaOimPNkYBhb6wiUacQw9i7AWlMBKRWks0iqMysEItBVgJRbDMKkSKhilBWK2gl5oEdZj9DB3476NvVhuZArtGvUst6GbY/slhbYoAUGgqFQsO0PxrP5eQuBKkQIBgSW5IaEvqhQTdVgJQFVhCLTA5hYCibKgArCBobABNQp6SMwLhGoWgcBSLUY8v87m2YbDnPn5OgsLDRYWGq/q3LsS2u2Ed7xjz7f9eT3P8zzP8zzvivPLna4YH9J4L8tayz33nOJ3f/cx1tb6hKEiCCRpWvInf3Kca6+d5id+4u3s2dN8yf0cPbrFb//2Nzl+fAdrLXEcUBSaL3zhJEtLTT72sZt5+9sXXnIfl+tniulDh3ng+FNcWx/RTip0M2hHhu1z2zRnm1QPLvClfsFWaBg2YbgTIoTlTFiyORLsU5K4CJieEiQlrA2gJmEntTy8nbE6ktg4oCok/U1IK5AreGILbCmQUmLKEiMFxgrC4YDClBSFIQoEIYbIFkxWQ9YH8ImvbPHJrzxJEFWAKrksSI3kzFZJ1KhQjyGsxuyWAVWbsTTdYNCIWQ/Aru8Q1CsMdkfYdhWRgB2UMBhAfwDXvQ1M4kqGhAUjXCPhbohNM6hWQUWAwITjxjwp6FRDZBGhxkw26HZzSmFRi9NEszXENUsU9x6lrEkISkQQujKZnQH2yBwcnIJjBYwUWmSYqIqK61QnLaIrsJWAMi8JpAQhsEq6oMeOGNx0CLMioBnCFLAZo3sWpiUkQA6mVNSmIY2Uq5wKLJNmRN9YUhQJGnB/s3eJqdouC9kWWk/R7+dY65YSXR4kpmmJEII77lh6lb8Nnud5nud5nud5rx0f0ngv60tfOs2//bcPoJTkuutmUJeNsxmNCh5+eJ1f//V7+Uf/6M5nLdu53MmTu/z6r9/L+nqPq66aJI4vnXpFoTlxYpff+I37+PjH7+Cmm166O2t/ZPkX/67k039asrs9w2DQ5omNHq3KCQb9VQYKWntnCe64lU+vJPSNJVGSWtVSCyyFEZw/GVLUBU9MQqVuuXkPlLngC0ctW0+XdAYarTVWZajJlE5coTHTYLkmWFyAJ592oYCQAo2AVENvRLrTYU0IlIRqYDh1fgMtFLbZgNlp+k+exZ7PmJuVqLVTdDsjstxQSM2gErBTbyFQJGHJbLtKsxnRANIwZi2TlNUG9tAR6Pawp07Ck0ddKBM1YKUHdgBJzRWRKKAewPweOL/mJjrNT0ISuaVE2sD5XXhmB4Y5zMRk50aoWkD9bXPQnGDUscgPXIfaSCmPn4Ksjw2UC1oOTsOH3oY4niBWI0xSBwkjqXhoLQQjkC2LziwmCMk1XJy7nhdQg1LVwfbhfB/2xW6Z05Z02ywLZCLIEsFmFZSso/pd6rrHu9UmDxR1zqg2AyEBiykN4XCXv3mD5tyXNZ/85FMwXoRWq4UcODDB1VdPYozl6ae3uPHGOW68cfZb/bXwPM/zPM/zPO8CX0lzxfiQxntJ/X7O7//+4wgh2L+//bz7K5WQa6+d4bHHzvOnf3qcH/mRm563jbWWT37ySc6d63LDDbPPGz0chopDhyZ58slNfu/3Hue662YIghdeqmKM5Wf+ZcGf/nFJtQ6zc5KijFk7G9AdNbj+9r10pgPOJQuMUklWWkIpGIwEvQ5ENUt7zlA2BJ2mQAiLKWG7D+VQsFHXZKqPWRtBvYHVMXZkKAer9IcZTy1NUwkFcQ10BmZ7gNnYxcoQkRdgSuwoQ4cho2YdU0/Q/R5mfZtgp4M+eQqM5fzTK4QYTBBhkgiR57C5AcZg5+axlYBhRZBrQaGh3wGqdcTWDvYwECaw7zBUW/DMOuyEcOYkdPuw/E5IE5iUbllRtQbL+4DSBTd63JRHWDjYhKk5MNvIuYhGTXHwmjabssJUKDi5bcmXI6L/6R3oZ/bTeeAk+U4X9k5g33ENnK0idgtMYSEMEEisUZhSEEYQVAQ6E+RDCxZkpiE2mCkJpuIa1UxXEUdLZNGDmT5UFdbWoRdRrVhuPpww0oK8kARWMPvkk+Rlj/fO1VmxLU7mCZ1eTr0c8SPvanPnjRP8fz6v6fdzlBLUahH9fs43vrHC2bNdpqYqXHfdDD/5k29/VljoeZ7neZ7neZ73evPfULyX9MADq6ys9Dh8eOpFtwkCycxMjS9/+Qzf+72HabWePSL97NkuDz64xtJS83kBzQVCCPbta/P001s88cQGN974wtU0n3vA8MUvlkzPCyYmLu2r0VAcfUpycm2B+rUx/S3IJJjCBa5SglAw7Ava02BnoSotFWsZWMu5s4ra0BKYAf15kKclpiIIswzbj1BJFVa2EK0mOypGNcF2DPYbD6Am56HRQJkSbS1aAEWO6fVJp6eIk4QoDhk8fRLb6UBeoLXBVqoX++AEQQWb5+huFyYmMWHCqISdHEapJTWWoBqhNzqI42ehWsM2m7DvAJyVEA8hrkB/AzZPw9QRaOJefAYEASQKSu1mjSNcbxijYTkh1IrKdIOi3aQjBbXAEgSW5bqguyPZ3xJs3TrP9i1z7OyM6PZTitMRYiNFGI2MasSRpMgVZe4+lziCQoORUGsL0gziWGKEIe0ZV+1TWkRdICsVQjEkUBmVacVuJaGiMggj9ijLtQeqGCN5aK3FjQffTnL8CY4d2ybOB9wYKa4+NMnddx/g1lsX+eVfvodWK+F7v/cQp051OHeui3YrotjaGvI3/+ZN/OAPXsvUVPVlz3/P8zzP8zzP814BX0lzxfiQxntJJ0/uYC0v2xh4ZqbKsWPbnDnTfV5Ic+pUh243Z3m59ZL7qFZDisJw6lTnRUOaz3/DkA5hafnSbcbC+YEgr8HqKcP8ecu+acFGqBEWAiFQ0rLVkZgc1joKlcFE5IICZQXZEIKyRO/mmLkKdlEi+wKJC11MXoWog+wPMJUYmwAnu9i8hEYDWeTuWMpyPPpIYYYDRDkBQYgoDdZYGKWgS0Qcc2GKNowHGUUhNh0ghgN0JQEDuzkUBaAsgRCkSGyng2g0XcAiFDRqrnGwkBDE0DsL+651Ic0WEFlXQZNasBJKXIVN3RJEGbQEdlBBdPsM6nVOP71FZdRBCRCBRMzNkBSKD+yvMggkJqrzsKrxjRyq0xqtJWkuCQJBOgSkK9LRGrDjop1xz+FSg7VmPE3LumOWbuyWyEO0yhnFNaSw2LzEqoi1nZJrl13QNluXdIIZ/l//eJrN8z3StCRJAhYXG0gpePLJTU6f7rB3b5NKJWRqqsq1104zGrlmzqdOdVhebvmAxvM8z/M8z/O8NyQf0ngvKc/Ni1a/XE4piTEWrZ8fb2ptXCXLKxwt/EL7uCDN3Bf+C8dkLKz1YDuFKHShhzIWJQVSuRNcjecLCQFhACKwFIWgl0paTcOF8UPWukRBCIEN3MhnbUBbiykkQgOZxUaAAmG0mxQt5KX53RY3X0iI8QRpe/F1iwtzvq11icNzCMSlWeDjMdfGusuFuxDCJR0XP5PxtCXBuP2KgrJ0QYwR7g2RFuatG9WtASWgCtQsaqTRKIwQDPsZptCIUl84IHReMtjo8uBDZxhOSm7+0M1UmhUqCJSCSkUyHI2PZPyxifGxXD7S3OKCG/fSBBdf4IW55cKOJzOBFfKyEIdnTXGKFOQaDIKlpec3qr4wlj2KLoWKlUpIpRICsLLSJ8/18x7neZ7neZ7ned5fgK+kuWJ8SOO9pKmpCmVpsNa+ZMjS62VUq+HzqmgAWq0EpeTFqocXo7V7nhfaxwVXLwuEgDy3RJFgc+gCmkRB2rXEdcH8nGSzdIOMNK6IBFxAk44gzoAU+plg1FHI0KI1aK0QsXJrdHYKtFVgQBgBUQ5CUIYRWoHKQdTqCGuxeeZmROsSoSSmKLHGgFRgDNnWLkVRYLISZAgGbFYg4giJO0YLbmKSENgocsOPBFQDSEsYaUEqwRoNtSq2LCFJ3JKhUT6ujgHKFBrLkArIgQhkhHvu7hqcWYEih0oCi9OYyQZaVbGDlKCSoIQgDgSBigilC0N0ElEJBae+eYphZ8gdP3gH9XoNFbpdqfEbLCQIqzH9FDq7aKkxKsDWajBZB6Hc65IWfTGdMS6F0SCkRgBhkZHHIVEgMUAtvhRobY3gpjkYZy7Ps7BQp9WK2doaMTv77CbWWVailGBhof6i55fneZ7neZ7ned7r6YW7s3re2C23LNBsxuzupi+53cpKj+uum2Hfvucvabr22mmWl1usrvZech/nzw+Yna1x881zGANnz8KJEzAcXtrmh96n2LNPcvoZyzCz7IxcixWdWrIuHLhZcc2yQAlBmAsy7QpPtHHbmBFkGwK9BjqEvLCkqaaMS0bAcKKGOTPCnOy5kg0bQ6BBdqHaxDaqBKHBbBSU3QwxP4/e3abIc8xggLDGBTRpCs0WIsspd3rkuwOsHfeCiRJsmmMzTVm66hJjDHqUIapNbFLnwpTqidiiA3coeaHdxpOTLgCSIegR5D0YWhh2XSVNaxkyCxsW2iDKHfjCF+Er98LJU3B+0/375/dSfOHr2GfOIEcG02hTyQqaIqMYF5vkYURc5EzKgun905x/5jwPfOoBKkVJVHUvUwnX8iY/uw4PfhM2t2A4RPcG2O0d7OmzDE+dR+VDkgjCQGGlcO+FsthCIMscG2RElISDDspoyrhKIg3X7E2wFtb7LtZ578Fxtc4LmJmpcddde1ld7dPv5xdvLwrN0aNbHDo09bLTwzzP8zzP8zzPe5XMt+nyHcBX0ngvad++FrfdtsjnPvcMlUr4gpUwq6s9wlDx/vcfeMFqmzgO+OAHD/Jbv/UA29sjJicrz9um18vY2BjyV//qdZw+XeEP/xCOHXMrd6am4H3vg+/5HphtC/7FP4r4f/7vOcefMgxySyhd1czyLYq7PxxSqcDBGbh3RdLLLV2AEkQGcWQpkejTBuoZLACRW56UFwXqTI75ZoDoBVCMsNMWOy+w4R5QMeIUsLmL7WuIE4qZRTi/jn3qSYrtbZdWTE3D0jISDVsdbC7BRBBXIdqCfAQ2xvZTtLQQKEiaUJ+FShX6kiKHwsAZA93IInWOXtt1FTtJ3T2mswtnTwADF7dmCdRvgGLWLXFSAB30Q/dBtwPTE1ALx8uLcE1jNnfhK49jlm+DXkxcGZJMhwwoKKRCK8V0ZxNlDASKieVZntoKOHf/iLLeICsgz4DNDco/vw8zSiFKYGoaG7jKGWEtttMhOHec8NZr0Y0pZCAwmYVYIs5nWDqIIkUUI6QSTNoBu6JCs6E40w843YdWAj94Pbxz70ufsz/0Q9exu5ty773nyDKXNkkpOHx4yk908jzP8zzP8zzvDc1/W/FekhCCj33sJgaDnHvvXaFaDZibqxMEksEgZ22tT5IE/LW/dj233bb4ovt5//sPsLbW59OfPsb6ep+FhQZJEpDnmrW1PmVp+MAHDnDw4LX8+q9Dtwt79kAYwtYW/Pt/D5ub8OM/Dh+4VfK7/9+YH/8tzblzhsm6YPkqyaHDrnntoIQNBdVJQVhYOqUl3RUYBaIikD2LbHcxcQ5pAH01blgj0K0hwU19qsemodIlrUi0rCOqASoyFGeHGF2DioCsB+c33EFecy2cO404fRpx+gSJTlH79tPPlQtoBK7kZGk/PPUQdCy0phBVhY0qEMYQBwglCCTYArKepWdAkMPpk8hBjjh8EJ31EBtd7Oo5ZKuOueUIUZSAmMOM2sjEYhcs8iCYe45i013s3Axajic6KdzSqgJIJmG4BeeegqTBsAgxukUxnVEqzVxvm8nuNuCWPm2099ItK9itITPTddZjge1assefILJD5FUzKLFJK++TU0coSSIKWq0e2yvnaKwLlu+8izKXPHh8xG5fUDE9Gs2CGAGyThZUkErxl6a2+Vvfs4eRgUYMN83D/okXr6K5oNGI+fjH7+Cxx87z1FNblKVhebnFLbfMU6tFV+pXw/M8z/M8z/O8C96APWl+5Vd+hf/23/4bTz75JJVKhbvuuotf/dVf5ciRI6/N8V0hPqTxXlarlfAzP/MOvvSl09xzz0nOnu1ijCVJAt71rmXe+9793Hzz3Ev2rFFK8jf+xk1cffUkX/ziKZ58cpPz5zVhKDl8eIq7797HO9+5zL/8l4peD6699tKX8WoV6nW45x54z3vg8GFIlWD+hoDb74LKc87iE0PYLmBPAySSnaHlxI5F1yArBFaU2D05woI4X4LVCOkmDZlagLlK0BoKqo1lzouSvKaREnQ3Jy8EBOOet8McRiPAQlhFzC8iBz0q2Qi9tUnRnMGWDYTCVbpUYqyMoVDANgwaiOkZrFLufusGMbWq0M8hzd0IcbISMSwJ21XiyZAy3aLQICaa2MEQtX8RPTtNPYekZikSqEcC0+mzcXaFYE+D0mi0Vm65lBYwspBLEAZaTdjaRqbrFPU5RFGhuiuIO+dopl1k6Dr3DoIa3bBJVQ/Q231qWZtKLSbNtjAbm0TTLZK6YKEOk5UUePYSueRgi9HmeQ43dmnunaB2qEJwokfvGynHd2IKkxAIy+Gm4S/fEvITH5mhXn3pqWIvJggkN988z803z39Lj/c8z/M8z/M8783tnnvu4ad/+qe5/fbbKcuSf/pP/ykf+tCHePzxx6nVai+/g9eJD2m8V6RWi/jIR67mAx84wMpKj6IwNBoRs7O1Vzy1SUrBnXfu5Z3vXGJ1tc9wWBDHisXFBkpJTp2C48dhaen51RLtNpw5A48/7kKaYen6tCTP+Q5vgbMjqMhLDZdsJoitoBJYzqegayNogNh97hEaGIZQDxlM7lCuNUmmc9pCI61iu5syEolrkKs1VgauGzEC0hSbVDDVGgKN7Q3I1zZhqoFVIBoVZKCwT57BDKugOqB3IWsiqnWEcnmJ0W5KUiOGUQH0C+hsIlotoqsmkUlEON5Ok2DzDpzbQDenqbaABCIxHgK12UEMMvRSg3zXcnGiUpG5yU8E7kmVa18cnT5JnJVMzTSpErNYH3G6sBgL9RAGYQ0rJHEgGA40MiuZq8ac2O5gdEEhYhZimHj+ajYA4kZMZ7XD1pldzk1PcKQq+LkPNpn57gYPPN2nM9DUK5Jbrq4Thb5dlud5nud5nue9abwBK2k++9nPPuv67/zO7zA7O8v999/Pe97znit4YFeWD2m8VyUMFfv2tf9C+xBCsLjYeN7tee560IQvMLlHjIOHfNwL9sWmdBsL2rqVRRdcmOAsEW6akrRudLZmPNf62ceGFVhpxtOqLQGWAIM0Fi4bre1mgYvxyGmDFQIrFbkGbQXGGGhWkdUAkeYEZ9bJggi+6zYo+nB2FTPqIfpDTLWODBKsFVhjCWyO7PfQhYF6C3FwCRH13VhrAWFlHNSUApFqaFom52C7Jy6+JGvMeIy3cO+BAMoCoQsgxDIObqx7g0VZILOUSCdYW+HqCUkrgqPbsD6EfiSx0aVR44W2kEGYuqlUSkEzft5bevm7S2EFJ4eGdyfwd+dhLnK333bk+eeD53me53me53nec3W73Wddj+OYOI5f9nGdTgeAycnJ1+S4rhQf0nhvGHNzMDHhetDs2fPs+4oCpISFBXc9Cdz0I20huCwVkAImQjgzhLKAQQaDgatKUQVuoFAaofMUEiB79vPYwCC0Rg0qYKDfjcjSkkooUIFEjAwWhRASYUvQJWEYYMOI0mhEWRCFikKCnWqhB7uIE+vQnqScnUKICJtlQAJX3YKUGrO+At1VbH8AwpCVgiIMEVMLcHAvIpaIzbNkvfHyJ+uGOKlQI2vAUp1SQdYHZWBYjCd9BzGlVchBgRISXYLQBkvk3kw1XmMFUGpkNURKgZEJ9bCgEeVMVWCxASs9eDDL6BrX8Le0goGWTAdw03zMowrC0DASkmEBVQmhuDhkm8zAMNdoBO9cSPi5RZj17WE8z/M8z/M8763B8tpX0oy/uuzd++xJIr/wC7/AL/7iL77kQ40x/OzP/izvete7uOGGG16jA7wyfEjjvSrWwtqaG708NQXN5qvfR6+Xsbk5JI4DFhbqF5dLNZuu58x/+S9Qr1uE6KG1JknqPPNMyP79cMstbh+zVVe1sZPCTPXSvgVQt7DRG1flAEjXL3erCzKAMI8pzwv0XhClhdwgsNgoxDYM4umU0bklalWLzSWDQpEiCGQNoYcYE4EQyMBgjHWXMCHsbNLsbyE2zxNoS7J+jt3N0+irD0GrAefPuQNJ6hBMQLNCMNmiqCxh0w7WDlEqQ1QjjKoQ1FrovRaxs4N+aB0bxQS1BCHAaINZ3SZZahN+3wz1qKTR71HrCYabdUajEDkzRbjcora9S14kdIoYKyO3xEleeLdCGPVdjVHYQokGpUnY3zxPpNxf2WbsLgtlj88Vk2zZkMm64a5rYqbrsNWc4+REnatUn6tbTc7ksJrDyLi/oxKIJUz0ehw81OCX3ztL1Qc0nud5nud5nud9C86cOUPzsi+ir6SK5qd/+qd59NFH+fM///PX8tCuCB/SeK/Y00/DJz8Jjz3mKluaTfiu74Lv/37X2PflDAY5f/iHT/GlL52m08kIQ8m1187w0Y9ew+HDUwD8wA/Agw+u8elPH2VnZxswRFGFG244wI/92CHqddeEZq4Oty3A5048O6RZ78LJc1CzkMZQAnrolkeZHGwG1krCUy0sm5hZoCHQQoCwcHQH8/WQcrSOaQcEUUxRjSkDgbYSEVYQRYYNFLrWgvmQcjiAsyvkj59gs1NAvEh7ehrVmEM2m5jRJjzwGIgRLCxDaxImp0DVyc9bWMshTqBaIwsMudGEoaLdsnRjQd5uopYXMWdOo7c6hIFAAXK2RXHdLewZbPG3bn2abrxDpzB0RjWObR3g9M5VNB+p0vn6ccpqC5VotNaQJBCP1yWlGez2scFB0t3D0JWE2zm9AvpJQL1eXnxvW0HBXXKFP+20qO9dYHOo2BhCLYr5yAcPsPLVh4lHEbe2EnIDuXXLzwIBw27KWp7z0Q9dS7X6AuvZPM/zPM/zPM978/o29qRpNpvPCmlezs/8zM/wqU99ii9+8YssLS29Rgd35fiQxntFjh+Hf/2vXRXN0pL7nr+zA//1v8LqKvzMz0D0EtURea75zd+8ny996TTT01X27GmQpiX33nuWU6d2+Yf/8E6uvnqSp55aY339XhYWMpaWGoDC2CG94AT/vz+OuLV3gLwU1GPQJRSlW95TDV0g8MQqZAXsr0MmYK0POx2oCiCCvIBCg95I4ckeLAaIxcSVCD19Bvulo8jmIkV7iv5Q0F6aJcksw1xiQmhUQ/JByGi7Q2GAoYAtAashiIPQqkFtmd1E0tCnkMUZzNrToBI4ciu0W1AOYfUcyHlXVTNVwloG/QSURMbnEfV1KvUp6s39rNcCsg/fglpZhnMbKKlR0w3KfQvsX1rjHQfuJ9Ga68IG56VgJxpwbeMhzp87y1ePdSkO7idd3yTOdihkyKiXIvIIWRTo/gjyJZCHqU/AvkoHZQwnnmnS64bcddd5KhV98XNMT6/wXck23333XsrYLTu7YQ72NQ7zf7WGfO5zz7C21md+vk4cB2RZyenVPgDf8z2H+MhHDr2Wp6nneZ7neZ7neR4A1lo+/vGP8wd/8Ad84Qtf4MCBA6/3Ib0iPqTxXpa18JnPuDDmxhsvTV6qVKDVgq9/3VXU3H77i+/j4YfX+drXznLVVRPUatH48SHtdsIjj5zn059+mr//92/jk598ksEg4447ptEozukGp3SLlVHM08fgZJTTasaUBkoDp3twagNuX3YtVrb6boS1EBAa15+3IlyoZAxkAQxTzWDQh6hG2JGIx8+Q7G5iRhmljDAbp5FTbbRWDDtD2vMJsbCMBiVlKkhFHVGdQz2aoc9oxFQD0ZjD1IybmiQCSA1Zs44+/yhIBfOL0JyEfAiRhF0DnAcj3SinCQudDDmICXdjKttH2RlMUq0tcn0rYa1QbC3Mki3MUCqIYtgXaN5z9VOEkeX8ygzXN+BgABCzk3foJE+y/8bDDMXbEGvnGJxdZbi5iypzZFFy5KYlSj3PU9+cRLVywkpEHBoSUVKtlmxsVDh1qs4113Sw1nL6dAdjLD/xI9fynjuqz/mEFT/+47dwzTXT3HPPSY4d26YoDFGkuOmmOd7znn3ceecSSvmpTZ7neZ7neZ7nvfZ++qd/mv/4H/8jn/zkJ2k0GqytrQHQarWoVF5kJO0bgA9pvJfV6cDDD7umvc8djV2rufDjoYdePqTR2l4MaC64MOnpkUfWeeyxDY4f32FpqUlKyAP5Aud0A4mlHWeIbpdWGXPNrFtzaC20EvjKGfjjx2Cx5YKbaDyWe5hBXkKoYJBCWrplT/naJvbRb8LEJEnRJTpznNmZGlv1Scpc0O+k1EbblK0Z8sGQvp5GKoWNQkY5ICEwlnzDQBXk+E0R1mJF4JqwCCizEdJk6ErbLW8yxpXo5QAx2CHoFHQE9QA6BbZaojcTVGWOrLNNutMlWU44YCR7c8tmITDAB/bD7NQWttGh6LXpp9DPXEAFMNiAXGbUr5PoJyVze6aYXJwk748oi4LdTs6R91/N05+rMLeYMygHdDPDZqpZqBmUkkSR5syZKs3mWTY3h0xNVfjRH72J7/qu5Rf8jINA8p737OPd715mZaVHmpZUKgELCw2kfGVj2j3P8zzP8zzPexN6A47g/o3f+A0A3vve9z7r9t/+7d/mx37sx67MMb0GfEjjvawLo7FrtRe+XykYjV56H2laEgQvXEURhpLBwDAcFpSlQQcx92d7WDd1JsWIULjfxg6gL5u9LQQcmIRGBf78FBw970Zvz9Td8KKidEEN47Hc2o7/dmgNoyGiWiPXliJpsz7QpIkgtC5MUNYQq5JYl8zJDlIFCCznBpJRJYZSupToeeHDeEa2uDACWyCkxCoF1lzaBDH+Ydy45cJ+LqRgIsAaC1pjx9O+AwTN8euYCABZgjQIqzDj3Vxg9PjwgksjsYUQxI0qMdAvB1gEZQ71esB0s8mZLU00CNjedqO+R6OS4VBz882SH/qha7nzzr0cODDx0h80IKVgaelb6CjteZ7neZ7neZ53hVhrX36jNyAf0ngva2ICZmdhZcUtb7qcMZBl8HLL+/bta5HnmrwUnC/rrGV1MhMQSo3eWuO6uYhDh6ZoTlT4am+aTq3OjByihPvFyjUMZY0zozo7Ry06z2HUR+ZDrJBU4yaBqbKTKR5ehekGdHuu/4yQLtiwwhW5qEYdHcWoYkQUSdJKHS1LjDYMC0AqdBiTZhIZxfR1lQqaqBxSF5JcSkRFIGsS0zWQXIxBAHtx/FzYqGCUAl24OeBTdde9OADX0jhws7IDAX0X4NgcLCW63EUkIUGjjrgs28o01CMIJJA1oUgo1IhQ1Ugu68dbbUiUUGQb7tguz4GyvCQMJY16xNSS4MRDUJtUNBuKO66fJ8ya5LnmxAnJ7bdb/tk/O0y7nbza08bzPM/zPM/zvO8Ub8BKmjcrH9J4LysM4f3vh9/6LdjagslJV9mhtWsovLj40kudAG6/fQ//6VOrfOLELDTcDhSGNDOMygNU23VO96vsv/Uwn/payFzZp0DTz0pGVFgvasg4YTuLOXV8yCDVWBOgZANjQGORcoiIYtIy4GwqsQYEhqJwVSRRIBAGyqSBnF8kOPcM1UqTFEvPJkQmwwz6mNY0O8EkqrQkE5NsDxRllmGSmOnFKnUhyFNBsi9g+FCOHmiQfbAaZALUIRTMTSRsdKYx22vYnR2YmHZTlcwARAaiDXHVJSi9ArSETkDJBp1uh/byPhqthKIURIELxPojmElgswfVvI5d3UvafpzpSLOTVjm+06Cfgk62kb0261/pkhzI6MuIpsgpS8P6VsrEXBNqFZavN5x9UrO2YpiaEyy2JErWOHfOsrwMH/tYTKulWO/CIIOJKky8SEWV53me53me53me9xfjQxrvFXn/+91kpz/7Mzh3zoU01sKePfB3/g7Mzb3044eihj50G8VjXcTuFgpLYS3VWHHNwQmSdot/82fQah2g3tjgmSdOMhwWaAJMkhPUNHv3h+T9HsUgp1kJyInYLWKQggBDaQxmqLFaEpYWPdCUhYEIbKhIpXDroIREHrqBcjRgfWXdpU0IUoELkPZchRUWE9coTq+B3oS3TcGBCc7UFaqWUSsSylzDyU04dQyy7XFIE0JtD8wcYGurRll5O6b+Teivw6qB+b0QVWC+7UKakYQzGayFkEeuGCeZxdT+CjtFyVSh2elDrODUJmQ5bGzB154GqXPkVsINd6QES1V++/79dCrT6DBE2OuR5woi8QTx46cp9yyzUaZ0Uk1Zq7PVmufUM4KFqmT2HRHPfLlAbhmeeNx9rlNTkr/xN0Jai5Jf+xw8fBayEmoxvPMgfPRtPqzxPM/zPM/zPG/MV9JcMT6k8V6RIICPfQzuvBMeecT1oJmdhVtvdZU1L8Va+OQDkAU1Pvq+iPW1hP4gJwwks7M1JicrgOCB0/Cl45rh5i5FYQijAKI6wlhEf5eV4zlBs8lEuwJCMChCrBUYKxBCkARACakB3Qcy40KZzIDGrXWiRFUCtAkxh+6C6XXobbiDrE3A1IzrHSNDePohGK7DD9wA8w3YHsHKJro1pDg8i5o5C1tfcU+a1EEL0DvQ3wXbJW3sQx7eC9e9GzbXobcNOoNaG+ptV0FjU2hI2AldQNMczxPXFewKPPO1nKW7NM/sKrSFauQelmYanWmE2sPXvvlelJzCLEXIYY4dFZRhEzkpyD9yC/beVerSsNGYpwwialMJcSTJSzi2IVhrBvy9fyC5VWl6PUu7LbjlFoWoSf7VH7scas8ETNehm8J/fxDO7cA//KALbTzP8zzP8zzP87wrw4c03ismBBw65C6vxtltePA0LLWhVg05ePCFm88mkWV10yB2c2q1EBkndMqYRBhMKRkNB5ggxrYSchtSaAVWILFYBBYIQuHavRiBCCTW4gIXXbrRT0YgRY62get4PLMAczPjdjLCpbO2gO11WD8L7z0Me1pwdjBObiuw1Sd7JMM0AjjQgm+eBYbuRcgGUId0BVNrYCYOuKY4U0vQXIK6RdYsBuHSlkkL/dA1mZkqXeMYKSC2EAv0RsTWcU1lUXGhNc0gA5UPocgw1Sp6Yhk7ZTAbGQwEwpSoVoa1CbYaY65t00nryHrCnBVkKegcQgEzbRhGIPdL/vLbn93Y+d9/FU5swY17XNYFUImgXYUHz8C9J+B917y6c8HzPM/zPM/zvLcgX0lzxbzwuB3Pu4JObUJ39PLLY7KsIM81RsbEcUBp3ekpwKUExmKLnKIwFEZiAIFFCrAIDG48NdpNV5JKumFJQiCEmzqEhCLHBTLjPVyaf2THVyVsnnW/HYenYFQiLjQGtwKCCLOxC6McDs+6ypsL94vq+FhLCHMIJRTWVeoICxlYMW4wrASoCNYERBohDMKC0MaN07IaMIxOwWwDrl2AVgWqkSU0OVEIVgpoBdhAwMhcnA5lswIkmBGUUxUGbYmoWPYfhANXwYEL/x6ARgu+sPrszyIvXQgzXb8U0FwQB26s+TdOvfJzwPM8z/M8z/M8z3t5vpLGe81p6/KOC9OlX3Q7bcdZhwtVLgYf7pbxVTv+j3HQYnnWbu2lx10+cE1cdrE8Z+c8Z0OLq7wJlJu8pJ8T2Rrr5nuXGrfGSlw2iltddgDm0vFYi0SABlm6/jkoBVkBpQL57OMR1r1SpIBSIHDFNlJAIC0FFiHc7VZeeMoL47u5NI9bA7HEStc0GSB5zqCmQMCwfPZtpXaTsSL1wm9TqGCYv/B9nud5nud5nud9h/GVNFeMD2m8K8YY+MIafOIUnOi5UObaFtyYuOKUtOBZY6Kfq5oEKKkRtqQsDUJajBVkVmIMWBFgwwpCKhQWIezlmczFFUsoXBiCRQPPCmQsCHUh5MFVvAwH7koQuuY70kKjDefPw3YKe+qIncItnbIGhl33ZNUYzp67FOwIgBxUDVQItaZLQAxQCkwJIgZbXjxaN7+8EcKWgOdNuRaQW6qLBmNDOiNX4TLMhWuonBcQWkjdYi8rrasistalKNY9nxrlRANXcfNCQdlQw13PWYFWiWB50jUMnmk8+z5roZ/BodkX/yw9z/M8z/M8z/O8V8+HNN5fSL9v2diwnDLwr04JHt0U5Bqi8RKZb2xCVUGtDmoXjsy8+L6yEqoVgd7J6Q0KitKQmwziKsKUEFUwtSn6JiRWGmks5Ui41U2xwViDtQIoxn1pCqyMQBiMdeGFEBDEkmKUAwEUGeQXSkIkpCUMN2F2GXF+FfvNUzB7HaYRQ7eEYgC6QBycg1GOfeScq1pRyo3XrodubVfUhuq0W+pUkdB3u7fKorWEEEgLwqxLsaBgt4odWEgMKOnKY4YSERfcfRc8NIT1jsVoQ1GCDWJKLVxT5JUedjGCiRDOpyAVthIjABlq1DMZ+6oRu5FkfQizFVeRYyysDKCi4K8eePZnIQS89wg8cg7WOjDXdLcZ6xoJT9XgzoNX9FTyPM/zPM/zPO/NylfSXDE+pPG+JWlq+dSnCj59n+aheclTe0IyY6lWYDYUzACRdV/qtzM4p6ALTHVhuvnsfRWl5qHHdnjsTElkNTuDEenRYzAaN+MNI+z0PMmNtyIjQVkY0tUQvSUglyDAJoZh20L3Gdg8DZUmZXsekhpEESRVF35EgkJr1xxYj9f4RFWXQCgF3TV48lHk3kXskZvhwSfg/i7cNg9TCkzbhT/DDO47C7vWPV4qCCPIM2jUoHYIUUxhT1mYtS6UCYWrdMmsC2/qGrG3glS7mLaCh0PYGTc+VhJqlokjmk1Vo9MpGOTmssoh5Roinz8Hp57Abu2H9+yBuRo2irChQmpDuJpzpAj4h+9NeDiA/3wcnu645WcGaEXwk9fC9+19/mf8zoOwsgt/9IgLay6MXZ9twMfuhAMvEbh5nud5nud5nud5r54PabxXTWvL//l/5vzRVzRnPxiyNi3JuiA1FKllI4RMCvZql0tMJ65yY9XCI0OY78NCC5II0tzypft3WFsfsj/ZZXd9ldWnN2DYhyhxS5DKAtbOUsQxrXe+m85Ri94QIA1EbmITXQunn4D0KahF7kCLEUpKdBARzs0SLixQEFLsbCK0xhrj1mHJAOIEBiNYPQ1RnXDlLHbyCPm1d8NWDl/KYa6ESgh5FY7ncKIByx8ARiA6EEu3vqg5j4gipBIIXWKMwhgJiYtX4twSzFv0Xkg3M+TKOkGySfmeediswNEObGywdHiKu9+3zBcezRl0BmAhpqS0kjLX0NtBdjYJ4irFmV3UH3aJb2lgagmViZj5wxH732H5+OEJPjwj+R8sfO8yfOoMbKYwV4GPLsPbpl74c5YSfvBWuHUfPHwO+ilM1tz12eYLP8bzPM/zPM/zvO9AvpLmivEhjfeqPfGE4c//XKPeqUj3SPItgbSuh26RA30YtixdKZga/yJNxrCVuSnUb7fw5Cqc70Gvm5JtrHPX9JCD9T6/8ZmjIBKYmkVkKVIpVL1GOUrR504xOnUEs7sIcQEmhVTj6kIG0DkFSQMigRrkyFpAvTIi76yhbR/mpjGdLrK/C3Gdi+2I4wDKFFbPoSyoOEKKCYalgDCAIERSwxzfddvFEsoaNBcQhNhMwv4lgtBico3RAqIKoqYRuUChCWSJLiwikRzYG7C2rMiHOWqjQxSEiCJFFSuUeybQ8wHq906gdkoefeYAW1sj5GhAWK9BKQg3d6hITTrsUgpBfc8eRo02otNj3+lnmNs/oHtixIc+fAvpjVWOsstfokIgBHfMwB2vogJGCFcx46tmPM/zPM/zPM/zXns+pPFetcce06QpbC1LVA6ZFgRu6jVKWcrcEhpBV8DlRRq1AM5p+Mc/AKu7bjrQp//wFF9IH+P6qVkef6rLqDNAVNwSJuIYk+coa1FJjB30MefWUWoPWo+wRQ5aIYXG9taxNoWiCmfOQTGkMnMAHUrCZhXT26HopejhAKkCjBCIOMIGASJNESur2OGIQLkMZlCZBi2R0qKkwBjGU54EaO2WNzWb2M0OohJgQ4nNXXNhId2YbZNJJAKpDEK4SMgY6MagQ4PuGVQYYVWCDCsIKxBDEDMJ7JtlZ+U8m80BWRkRyCaiKMllhAxC0DkXFj6N0hxRyxDVCp2NkD3XxBRpn5VHt7nuxhZnyVkhZ5n4232qeJ7neZ7neZ73ncDy2le6vMiA3rcaH9J4r1qWgZCWUgmksZcGGzFuLgswHjR0OTmeZm0tLI6nCdXEEDUeX53lGmstIsuQu9vo1gTECVYIpBinQFojAhDGXBy+bUOFDdX4ySVUJzA0sBraw5TKZMF5WwAFRb3mJkDJELIUsbGJ6HUJigKEC2gkYKUC4UZhSwvGWnfg4P6VF8ZuW+x4ZNLFSd3C/c9ad8wXJipd+NeMmypjQaMwRqCkIZIWYQ1GCFSgCKRGqZJhKbE2JMsjTGDce+GeGXGhUcz4Ccx4EpaUUGaaEIHGUn6n/EXzPM/zPM/zPM97E/Mhjfeq7dkjwQomdg2dOYkSFj0OJC4EBFZC9Tm5wEjDct3df8HevS20NmhtWFqsEoQBeVEgBgNUnmPaE9h6k8IYUIposkE6AIIYAgsqxCJh1IBSQFAABsKYNIjY0YLh2R6JyYg2tihSgxSSMpqE3CDKGIoKRalBGpSEwECY9ymtQY879VrEuERGuwbD1kKauaVSsnRpTiigtC7htRaUASNddc34vRFCEOcwNAIbKWxZEoWKQI7frFhBUcJ2F1mpUcY10KCNcYuzSoUxAUJIBBJrNUEQUAr3uEqlQCmDtdBeqrGLpoliyv+qe57neZ7neZ73WvE9aa4Y+fKbeN6z3XqrYnlZYr+hCTVUE4u2llJbtIGgJlBC0LospMkNlAa+d8ld3+7CsXOWvm2gooSHHt4gqlWYWprHCkVhwGYpcXeXcHcbs7KFiBZpHljGVCwmT0BEMCpgextMDRp7QPShzLCZoBiWDE3IdjhHv7KPcn0E0QFyO4cpqxhRRUdtdH0JU9uHyWoMN0K6uwK7eQZRuulP2oz/HqjQVc8I5aY4DTbdrHEDZAVEyv1GyQBsiU0sVhrKQlKMBKYUKGuZKCTsCkQ7hFAiiiHWWmwgse0ETm9Rnu+Tt6/CiAihFFYojApACHTUIjchxoKUgiQOsTZCDHZpL+ZsHOswsbfO1K2T7FDyduq0fEjjeZ7neZ7neZ73hue/uXmvWrst+KmfCvnN3yrof7Fk68YAGwpGFpJYEMcway5V0pQWTvVhbx0+MA3/9jPwe5/d5KF7n6K/uQFlRha3sRtzmKUjwFOwcxZdpoxKYHUG2At2jrU/FpiaBqFh7UlIz4DKIJDQmIDl66DfhU4fFq7G1KZAhfSDkEAJDBLUEEzpSluKkatc6dRhdx8Mh5APSKVCFvdjr78d4parokkqrlonHUFnBXpbkGv3vMNxk2Ep3XjvoAe2ii4iOC8vjgrXIWzmktoAzDIU+xcw/R0GRiO0QR5dJb73LGLpMGrhIEJCFEgyC7YssKXGypAsqBJFBZVaQt8qZLdDszhGp9ejsa/K4k8eYDAhuZ0af4n263eyeJ7neZ7neZ7nea+YD2m8b8m11yp+8Rck99+veXTT8H8n8LVckmfQ0IJqACOgU0CvgMUq/K83wO/9Cfzpl7Z44itfJx8OiOstutVrKOMWRBEiCqneeBvZ1lXo7U04PwnUCSogk4jcRLAD5JsgTkEdN9GpLKC7Bnkfrr4dltqgAWsIQkUpQkozXnekEtADt2xJ1uB8DpsGQgkRkBeg9mP6Bs73oZVBVIeoBlsrsHIUqlUIBBTrsNuFQQ1owVwNEg15CbsFbFVdcFM3kGtkEbJzFsI+HKlLlq+usiY0W6vbqK0eQSeif8O7mF+aZlQI0hLKqqCbSoZpgCmg1AJVmeaWQ9NkNiQh5d3TKxxauJp8MaL99gkm2gkHSbiKBHWxY5DneZ7neZ7ned5rwC93umJ8SON9y1otwfvfH/B+4H8CvrwO/+5p9+9a6vrnNiL4yBL85GFYPQEPHbdsPPM0xbDP5PwsI1nHxBMgFCKJEfmIOKkT75liZzAJhYCJApP30DYa933pQcdC8yA0N90vaxBBXIXuebe2qhaCEAjr+uMgACNcv5ggAFsHnbrHxgqCDigJUdV1ClYx5CGsbRBEA8xgh8q+ZdJ6A61LGHSh2XKZjxhhshHmzCZy1ID5FqZeg14F0KAyVGaoyJC4JRkMIBrAbXtgvqa4qdaGmTaFhs89DLKAKHCX1vi9ntGC3WFAJw3ICxiMQMbw8b8Ed19TYf/MVa/DGeB5nud5nud5nuddST6k8a6Yd825y6kenOi5ApLDTZivuvs/8RkoshGb6xtUmg2kEIxkDRCIKARjscbQOz9ASAM7CYgQKUqECtwYbIurlhEpFBOgt1zTXnBVMkkNVMVdVUCJa/4LLqi5+LMEWYVCQ81AffyziiAo3baRhFFCWY5QIscMBtjWPCzfBKcfhN42ttHGWHlx4BNbPeROD6PaoEOk7WMGA1qzbQ5eM4+UgmMrIHIY9oDpS+9fbwSDDJqV57+3kYLZBkzV3Aqr0+fhxgX40e+6gh+g53me53me53net8JX0lwxPqTxrrh9DXe53OoQHs7gySnF4H3Xo1RAnpaUGwrbddtYXbqgBgiTkMK6+dVCCGSgsEaOx3trV+1ihSuTEfrSE6lwPOv68sHgl3nuTYZxs18Fwlza5sLIb8aNgRWXRl1P7wcVwOojiN0NrAqxUR2CEAtIY1yljRyBGKFqk7QWZqklCnBhjrWXjeweuzDlW7zE6iQloSIhUL7rt+d5nud5nud53luND2m8F7S6Cfc9CcfPualMC5Nw+7VwaOnZI7RfjjbwidPwmTNwPIE0ChGtGnksKasJ+pDCdgU83oeuAl2gez1GK5vQrUFvDm1S15Q3jAALQcXtOEpBF5CNnywQkA1A54C4WDVzeQHNs7IbASQCdnFVNIzTk7J0oU1pQWaI0GANiCi+9MCpvTA9hx12ETsrsHEGO9hFCLBKQDAB1RoqbGPKlK3TK+yeA9usMQjrhEnIqYogNjAvIBIQh+5l5qVbvfVirHU9j2daL76N53me53me53net42vpLlifEjjPUtZwif+HD77dTcmO4lcZcfXcvjje+Hthy23XWWxBg4uC5bmn1/2sdEt+exDu/Q6GcPJNl8bVZmKBdfOwJmRxIg2upthcoGtWpgNoN2CR4dw71nYOAFWgZ0D24SuRYtNqBZQj6FsuiAmOwZ2PBa70G5KU27BloDAaldtEwiXtwCXEhtjXDVOKCA1MBIQA1rDqAQZQZlCC2wvRbSa6LCONeN9CBAqwtamEbVJ7PRBGHZQoiCoVBFRjfLUDkVHQtlh2Iww0y1MqGBQopsFx4KErVLRlnCzhLkE5ttwagOqLxHSdIbuc3nfjVf4w/c8z/M8z/M8z/NeVz6k8S6yFv7bF+F3vwBTDbjx4LOX3jzymOZX//eSoq+pV6HRELzvbsU/+3jI/IxglBn+3v+1wifu7TEoBKYWIxZSpmWHu29scX9ao3sW9NkIhgFWWDf1aF7DdADiLAw2oX4b5HNQBNBQoCUU86BLNy5KR9CswcwSRKFrGIwEk7mJTUUVdneh3gApKY24bB2RcVU4apy0FBrC3P0mDENIS0gnxqU3BYxqsNvEzMRkdeWCHAUE46ymHGF2N92xqZCyu03Z76CERqyewiZHkM0Z9EQNSolKNVEtpaiep/N0jfrVe+hNSu7TcAdwYBZWd1wQ06o+/zMqDWx04B1H4N3Xvbbng+d5nud5nud53iviK2muGB/SeBedXofPfN0to5mdePZ9J04avvQnOcOuRVUElTpobfn9/1Zy6qzlP/56xI/8H+f47KM5emQIdQ4LDcogZH1o+INnDKwYxJpEBAJRl9jCwLaAoXJddB9fgfpNsDHjuv1Gyl3KcX8YWUBtBHUNlTqUFZD6UuAS1t0Ep37uuvBWz8GeI+OOvkBauBBH4NIOCxgLrQSkgbUC+hZXLjPuUiwlFBYyDWmJikNiKdxqqHxEtnUWigwZxlihsM1pOPENGO4SN+dIGhvYG+qkUUw0yKg0SuJWTi8LGfS6bK03uareYhTB4wbe04Sb9sHDp+B81xUORYE7zEEGwwwm6/APfuDVLTvzPM/zPM/zPM/z3vh8SONd9PXHodOH5YPPvt1Yyze+VjDqWSYXBGkmGOWwb1FQb1i++YDmX/zOgM8fzbHDIVWbI2oJaT0hyjKKSkiRVVCrlrBhMVK4SdrCYJpAV8LRHPJlGMy78CSx41HZFqJxWFImUBlCNQdVBR24Br7msjVIxrplT9UGdM5BfRWiaRiWYHuQNCBWSJMjVIAWAfQ1bFjIhy7kEbjQRoTu+aWBKQWpYc+8JqoHVBM4dmwb8gxVrSEuVOnENWjPoHc3EUHI3qtizh8KqRVdwqK8+J5WhSIdCkadXbqDJtORYMvCFnBwDuoJnDwPa7swGrlDqkRQDeH774B3XfvtOSc8z/M8z/M8z/Nelq+kuWJ8SONd9NgJqFeeP12o24W1MwZVFXRTQVG6YpXsPDSrgryw/P6fjcjmJCodoqoxZRRglUSkBaIeY7ckpjRoOW4LYy3GCDcKO7CwVgPRgExCkF+a0GTHk5uUgkKAaQH5uJ8MLpS5wIznYAehC27iSRicgERDUkBRd31mhELmXRLdZ5QZ9GoTdAXKHHSEkBpEgL0w4CmQ2JoLaYpeydxcwCgzqGJEpRJhpbg4+MkYjYnqEFWIRIFsx2gVEA+zy99SQgX1Wkh3kLKxo5mbCCiBXQszAmZb7jLIICtgmMJGF24/BD/9fa7BsOd5nud5nud5nvfW4r/qeRcV2mUhl7MWjq1aBpmFQFy637pMZHsIwwLSXYudtcgLCY9wlS1uirWA0k3LvjBcyWgBjJcSdQECMMWlyhWecyBinILo0j25uJj2vMArGVfUCAF6CGEJJgUagEVYi7AGUWZIbTDGYG3pRno/j71sHPe437CCcrzKqlqRBJG7DlBISxZJUikRWOw4bHqhPcehoBJZQmXZ7EI/dG1xTDw+dAP9EZzfHTcKvgl+/EPQfIFeNZ7neZ7neZ7nea8bX0lzxfiQ5juAtZazZ7vcd98KJ07sYq1laanB7bfv4eDBifFSHZifhGNnL38cPLYCz+wKVCIpujn0+pSDAdYaxHZI0KxRphVEpCk1WCsJjEVoA9a6AGS3P54tLbElaKHcL5i2sCsgx/WbUSkEMRTjAIZxpQ12HKBYUOM0ROOWJiEvzde+MLlJly4YynturnWn616MzUDWXCuabERZFlgjIMggq4HogGhetiO3U2EMtrSgJNWaZJRCuyHoVRX9fkq7EhCoC4cliWxGWmZEUUBQlggLRgjkcwKlsixRQcDhJcWBPfDAOgx34InzLmNSEto1+J7b4c5r4cie54donud5nud5nud53luHD2ne4vJc81/+y6N8/vMn2dlJqVbdR/6Vr5zhM585xp137uVjH7uJWi3ijuvg8w/BqU2L0pbOAJ7cllQTS2RWGK6VYAeAAQXDkYGNDCLJ1KGcVEfoepveiSEMa+NykwL6PbgqgKiB2TTYlnSFMiMBKe7ndgDZAGp12ApAGFDjihkDZEBUQqUEmlAoXJAi3fFcqFUptfsx70F6CuptGKUQhlDuQq0FRYEZ9bHKoEuLrRrINbEy5HKENdXxBCjramCMhc0StS+mNRuSabhqj6Au23z9vhWGw4JKJcAKOW5cvEkkC5JEUu0OidOUPIlJRunFz8ViKfKSyuQUVy1Kogbc3YT/cS/Ywr2MJIL5CZhufXvOFc/zPM/zPM/zvG+Jr6S5YnxI8xZmjOU//IeH+aM/epq5uRp7985erJqx1rK7m/Inf3KcPNf8zb99G/etC77ZsWyctoQCih1DbaAxZ+6j88xDYPYBe0FUgXA8KSkFc5TNBwWt7Hp2Hp+A3jiEOaNgbwPq88htjako7EhBD4gF5OPFT03chKWBgHwLGlMwCGCIy2AQEGmY0hDUoVRu+dPFZUjC5TXWuPVIgxVY+7qrkFnvQb0J7SWozkLehyzFhhOkElwH4yGkT5PZOURZwkiDDVxQIwXWBKiRpjUp6WSCG/fDwQXYP99mezfjmRM7DAc5OmmhsvPsbVqWDy5w/OQOW9sztM/vsrZvniIMCIsSi2XUH2FkxPxik0YTVjP46wfg5uVv+2nieZ7neZ7neZ7nvUH4kOYt7KmnNvnc506wuNhgcrLyrPuEEExMVAhDxZe+dJrHWOSe9UXK1FCzrs9M1pJkJkNvVMBGEK+D7YOuuetJHZIcQUDZgZ37JtxSpYqASMDIwqkSZkLMRAKFgRkLBTAYH8i0hYZwgcv0PGydg8ppiFuQRm5JVGCgkoFNxo2Dgdy4aptAgtZuolOhQe8C2j1/0AQC2FqDnV1oJ5ArKLegaiCKEJGidn1Evt2lOLuOmJhjSiQ0i5g0VWhjqc8o7v5Im0O3TvDN0+5lbHShVRW8/91zzCxM8My5jLpK+eA1dX7iB99HJHJ+8X+7l3u+dh59f5/ESIZ7pxkIgdnuI0TE4sF5Fg7GnM/hg4vwA/u+baeG53me53me53nelWN57StdXqgd6VuQD2newr761bOMRuXzAprL1esROyl8409PoxbmODAtMFZwfA3y3ZwiKeDAIjy6iQxXkXJEmQ9BDxG2iZALGAIQB1xAEwtIpAtXLqxE2iphSrrKmV0Lsxa0hKGEBLecqGdhM4TGHkg7kHdB5JAaEMpNZgrq7meMW0ZVFGBj90L6mRuxbdYhbkMyC9mm63ETTMKwA1tPElT3Y1UVfXAekQQkvZjA5rTeFdN/+iwMMrLuDtdMG2abiuuvn+U979nH7bcvIoR7X+55FL7+NJzZcmHXkf0xP/W9Me+5rsl088I7W+VX//m7+P3/foo/+KNTPPPwUYpzG7A8T2XfLHv21Dm4J+b6Kbh7Ad41B6F8Lc8Gz/M8z/M8z/M8743OhzRvUdZaHnlknXY7ftlty7jK7jNb7FkoUDJGAWEElSCnHKbYuQienkcGq+OJ2AJUhC2HCFO66hbbdoFMeNkcIyncGZYa2DAwKyCXkGrX+FcDO7jlRem4mkYFUJ8BOwnksKvd8iZCN1bpwkhuPcI1qilBBBB1Id9xO7UawhqkG2AyUDUIG64njc2xtRZUIsRwhEgkxU4IJiFYnmF6aYYdlXDzkubj7wiZn69fXCIGcNW8u/yVd8J2373EuTZUX+Btnp2t8fd+4jr+1g9fzbGTA7a7lla7Sn02QYYQS1isugbBnud5nud5nud5nudDmrewojCoV5AAGATWWKS8VD9m7fh/2rqzRCmEGyo9Hn4kcSnLhaHa43+eNWt63PjXWrf0qWtc5UyZgg2BCHaB0rp51hcfZgAJUQKBdpU2zyudu/C8ejyO24xvG9fZCXnZdlw6DmvccwnXEFhIiy2FeymAlIJgukVtERYWXvw9m6i7yytRq0XcfH30yjb2PM/zPM/zPM/zvmP5kOYtSgjB5GTCV796lnPnumSZJkkC5ufrzM3VCMNLs5xDnRPVErIyvHibFGBtiLV12JHQqKHtMmK0CWXfjSASIdYEoAtco5lxD5lQjpv44q4L3JkmLJQG8hICBaGFC095Meu5MGobF85cyIGetdF4CRS5u26Nu0gFWrj79Pg+EbjKmnIEKsbKCJuWUJSYKKBIA6KqRiQahlCGIYGAI36ikud5nud5nud5nvdt5kOat6iHHlrjiSc2eeKJTer1CKEURWl44qkt2s2Ym9+2SKNZR0lLXGYcufUw56Rkp2Np1KHsSEbdCiLX2O4IYoFRBxHVvdA5C1tPQNTCFgHYEspTIK+BVEJkXeNebdxSp5qCqsUtRTJA4CZDYaGwrodNMQ5gLlTjBEB/HOogxwHO+GdrQQbjQEa6iU7WgoxBFm6/6bYLbVQCo00X2iQH0LKAKIZ0BJUWZaEQ031GRY6II7r1OtdV4PsOvD6fm+d5nud5nud5nved6w0d0vziL/4iv/RLv/Ss244cOcKTTz75Oh3Rm8Njj53n3/yb+7DWMjPfZGUzJQ9D8tyg85IzawMeP3aKyfk9RKpkabHJT37fMp84LnngacupY4JRR2AxiP42dvcsmCEAVlWhtgQkMOyPC1sGEPUgugpE4ipkjHDNVioCJoAgB1IXmlTrrqmwHo/OznDBTsDFQhlGBvrG3WAtyAvLmUJcaCMhqLr9ZSkEbTAFhHUYrkG+C1Ig9BCbd1wVz9UCrmpDpQIyRtgAljXFAdixFWqtOjc3Qv7VOyAJ8TzP8zzP8zzP87xvqzd0SANw/fXX82d/9mcXrwfBG/6QX1daG37/959gZydl8eAsR7Mmw60zZJtdpFJoW6GUIWXWZePsCRaWl4lm387936jzC38NPj1j+He/D8m85dTR83QH24T1hKKvXQiiR2AFNJegeAYGKyBDaF8HN0xA08D5AroDCA1MKwgLOHUSykloHAD1nGVMQrigZjjuTVMCI1zQgwQ1XsqEds8vE7CBe15rwJSgQghrCJNTi4HJGWzeRRY9RijKt18HB5ZhlENnExkHxAvTiAiCkxuEtSpvW5zn378PFhqvy0fneZ7neZ7neZ7nfYd7wyceQRAwPz//eh/Gm8bRo1s89dQmy8stntwRZEFMbbqFVJKim1EMc5QEG8VIJYnrs7ztpglOnIP7HoCGldy4DI1wxMkHdonjEBuGlGmJLcZrkWwONoOk6cKW2jLMLMGyclUwxTpUN1z1ioqgUYd9s7DWdiHMaOiqYOBin1+EcA2Ee9ptY3HVNmrcfLjU4+qccb8ZIV0wowEVoGxBZHvEQYUj+w5w99uv5+QzJ/nC57/IaGoScdUydAeoMieIwOoCc+4stUP72HdwL++/qsWZfsDZbR/SeJ7neZ7neZ7nea+PN3xI8/TTT7O4uEiSJNx55538yq/8CsvLyy+6fZZlZFl28Xq32/12HOYbxqlTHbJMEyYRq2dBmRKNoLU4RS8KMH1LFBpyGRIMt0mzkq2tIbNTEfc9CjaCiabliYd7lEXJZD2mkxtSXbrlS7ICKNfUt7XfLWdSCbQFxBa2LejheIrS+PTqDSDcA0nN9Y8Z9wZGXFjbhKvOiYRrKiyUC4LMyC2fUomrlpEhoKHMIakgig6TjQmmKoJaGBEFdUobUyAQClrtFtX2BL3Dh6DWpGIVrZbEWo3RhrSfMjtZxUw0sTGUHTi1Dbfvez0+Oc/zPM/zPM/zPO873Rs6pLnjjjv4nd/5HY4cOcLq6iq/9Eu/xHd913fx6KOP0mi8cLnDr/zKrzyvj813Eq0NQrihSsbihmbbcR4iAlSokJFBIbEIhLVobQik5Yknc85s5GxtWUbdIWmmscZVnlwsbRECRAiMwxQ1nt407gMM4zHXwMV53Ma4kd1i3F/mYkpz4cAu2/zCRQJaX9qXCsbTnnJEFEKeU0132b+3Tb0aX9xFmkNWuJ7FZaHJC9BBTBhImi3l+hUTQAD5KEdac2kI1fh98zzP8zzP8zzP87zXwxs6pPnu7/7uiz/fdNNN3HHHHezbt4//+l//K3/n7/ydF3zMP/kn/4Sf+7mfu3i92+2yd+/e1/xY3yharQSAwBrqkWQzDwiUpCwNQWTIRsqtMDKaAEMYRVit+IP/0qe/aTATAhFLlArBwigFkQkwCYh03MC3gLAKQeR6xVhxqYdMINyUJYZu9LWQECdgUyhjiCPQ5TicsZcqapSF3LjlTEK6psIydsulLkx2MpZQ5FTKDkaHtBuK0UgxHEKtCkEA3SFUY1hfhfX1BoEKqWZ9SGZQ+aX3SZcaqSQ6jmkoqMhxcVDybf7APM/zPM/zPM/z3vT0+PJaP8db3xs6pHmudrvN4cOHOXbs2ItuE8cxcRy/6P1vdTfdNMfcXJ2NjT5LjQlWd2NUYhn1tkniAqkC0lRCuoM1EY2JBb7wx7C9Llla6BO14VzRIFAV0qyG1oWrnBHRpd4xFBDWXG8YGYOMoK9hU8O0hFEbyp5rNBwAzRo8cw56KUR7IQxdRY0ZBzUXqnAGOWgJgXGhjZRgckiHMDDQ7zHdOIfJC8TMPg5fN8fBfYKVFVhdg/4IhjksT8LcHPzVv9Li/m8s8B/+7AxbgzmGjQrVdIQ1hlFvRDLbRlQS9legM4TJKtyy9Pp9dp7neZ7neZ7ned53tjdVSNPv9zl+/Dgf+9jHXu9DecNqNmPuvOsAv/ab26QskaUJg8xQmA5ZvkJR9Cg2MsiHlOEhHr1vEltELC72wXQ4c7rKoKowZRNMA2yKNTmIPqgSbAlRC7It2H0SCCBZhNpBOJrAroLFSWgnUO5AmcLpU/DQQ6DaEH8YGjPuzLPjkMYAHQ07AkwG2QpkT7ix37buRmyXBmixurlIECQsVBPyNGJty5I0BHtiSCJ499vhr30EJicgigTveufNrHQK/vChJxhedYC0VkWIgGixQWO+zVU1wUIMJ7fgB26Auebr+el5nud5nud5nue9GRku9ht9TZ/jre8NHdL8/M//PN///d/Pvn37WFlZ4Rd+4RdQSvHDP/zDr/ehvWGVJWyOjpAHu/R2dqmFOzQbCTuDNr3dCLYfJCyHVJvL1Br7WF8BbMbmusFUW5igihgKN2EpUEANyhBsFcI2BF3X0He45pr6FgPoPAF5Dq2bYdXAZuFGcVODtRVYOwm1RZjYC5UEigwK5frUaDEeo11A3oO+hWwI5bqb3iRbICouJGILqNJs7kPmASsP9dn3vhofuDui1RDceAiu3vfsNjeTkxV+7X+7i4O/v84ffGWHrilozreYW2gzGSuKFM5uw3uugv/HLa/DB+Z5nud5nud5nud5Y2/okObs2bP88A//MFtbW8zMzPDud7+br33ta8zMzLzeh/aG9dhR+NoDkrvf3WZ3R3Lq5C7bOyOqDNjO6gSVazi4nDI1NceZMxKJRMiMVMdIGRBgMBeb545/CGP3Y2Eh3QHRAwzIAKIaFBGMzkB1CeQ0ZBKe2YF0AMF+iGOYX3BNY4waT20S0K+AVSBLiCxUgWEHymNg64hoAisi97wyAfrANjJos7RUZ30945lHJH/3Y4p3v/vFT+UkUfwvP7LIB969yD3H4PE1SEuXCx2YhvcegncfhCR8DT8Yz/M8z/M8z/O8tyzfk+ZKeUOHNP/5P//n1/sQ3nQefcpNN2o3Je1mm+XlFv1ezhNPpmzvlJhomma7jxCGNHXLjYQw2EhhtEJLN/UJIaAcT2BCAgayXVdhpnJXaWMNIFxFje1CvgnRpGv8m7RhtAuihGrLNQRW4+YzAZCrcf8Z7XrPFAXEAfQ3wPZBzLt54MJcVtVWQ4gt0qxLWc5RrUq2tzUPPKBfMqQB93Ju3we3LcNa17W/iRQstsYFQ57neZ7neZ7neZ73OntDhzTeq5dmoOSl61IIms2YSsUipcBYcTHzsPY5/5oSrSVWalfhYo1LN8w4rLEaRDCenn3hQVyY733Z6O3xbWHVNRy2A6AEQsASCIO2F3ZhXW8ao8f7NyBDIEJJl5VenNot3QhvOz4epQTWWgaDVz43WwhYaL2ad9TzPM/zPM/zPM97ab4nzZXiQ5rX2fp6n298Y4WjR7cpS838fJ3bblvkyJFppBQvv4PnWF4EbUDrceEKLuRQUpKVIWUhWN9OqEQGZInFYk0BRQpBhDWBC2guVNMwDmqQbnlT3oHowrMJF6qg3c9BjfG8bChzEFWIJlxljLVQDiBoITCooKAUkQtorHYPS0tQi1BuAhmW8ZQuCWBdVY6VhGGEUorRyJAkEVdffVkq5Xme53me53me53lvUj6keZ1obfjUp47yqU8dZXNzSJIEKCX52tfO8sd/fJy3vW2eH//xW5icrLyq/d52kwtqjp6A/Xs0g2HO40/knDipKYoAkynObwjiWoSREaUsIMvcg+PauFJGuItQ7iINSOt6ygzXIKoAMa56RkC+5ZoKR/Nu6ZLOXT8aOeX2a1pAAiEIqTEEIHNEVGKLGBkbAhtieyWGSTQLIE66ljdRQCYMJgX0LlI1mZ5sMRwKskxy/fUB73ynP409z/M8z/M8z/NeP76S5krx325fJ3/4h0f5T//pEVqthBtvnHtW1Uyvl/HlL58hy0r+wT94J/V69BJ7eraJNnzsfyj4X3+1y3/68i6bq2fIBtsIU4BRWDNDYQ8y6iZY0QFzCuQulAWM9kHtBpDjipgLLWmMhKiEpAl6EbIzUHYuPWnUhtbbIEjc0qhR3z2mXIPBeZBNVP8AaiZG1qAwEqxAJiAzS0ULasOS1I5IA42tzmCDATrfIk8N1lqQIEyLQOyj05mgKBQ33hjx8z9fYe9eX0njeZ7neZ7neZ7nvfn5kOZ1sLLS41OfOkq7nbCw0Hje/Y1GzDXXTPHAA2t85Stn+NCHrnrF+y5Lw1e/+E2KzZPYboFJDYoqRZaAyJHyFEr20eURsAWgXbhCHVQbyp6btiSbl2ZZGwvbBUQdmLkJ4n2ueoYSwiYkc673jB5C/zjkI9c8uOhA2UHKMyTDbfbmO8zNXsOObpPpkFaUslTrEKaWvKPI9pQ8/vhRwmiTQ9dfxcrWPna2UzA51XBIIxbs21dlebnG294W8eEPx8zM+IDG8zzP8zzP8zzv9eWnO10pPqR5Hdx77zm2tkbcdNPsi24TxwHVasAXvnCS971vP2H4ykYQPfbYeb785dPEUUmWDlHhPPlIuJ4vIsbYGMwW2GfAzgLLwCrEExBMge6A3nHdhy9U1AgBUhGkJyiHAdRnoT53qdJGAGUJow7SrmKDyN0egFRNQrNGwz6M6Urq2ynvvW3xBY/97NkurZbgtttu5ty5LhPVFeySpVoNue22Je6+ez/XXDP9qt5rz/M8z/M8z/M8z3uz8CHN6+CJJzap1UKEeOnGwDMzNVZWemxsDFlcfH7FzQt59NEN0rTk9JkRvUGVQgusEQhpsVaNgxUJdgOYdj1oTAJBE9ckWIExYEdg626nwkIksDJx4U1UjnsFSzdxCcB0wYCpzJLk56mHhnw4YjQSBIGizDOUqrG62iPLSuL40qmnteH06Q7GWP72334bH/zgVaRpydpaH60NrVbC9HT1Vb/Pnud5nud5nud53reD70lzpfiQ5nVQFBqlXn5yk1ICYyxav/KTMU0L+gPJyprBWIWSFo0Lai6RQHlpOZMYNwm+UD4mADRCaSyXtrEiuHAnCgPWjMdjC4wpwFoUkMiUWiipNgOsgbIQpKkmTQvCUKK1wRhLmpasrvYYDAoWFur88A/fyLvetReAJAnYv7/9il+353me53me53me573Z+ZDmdTA3V+ORR9ZfdrteL6dSCWg241e872qtxYlTFhUkKDGiNM3xkiTrLgiwJVB3Y7GxQOH6yQSTQOp2JGKEGG+CQJicgC65KcGMhz/ZcWRjx2ubREooBgitsVYghCBOAupVQSgSrHUB1dNP76CUII4V+/a1ee9793PbbYuvepKV53me53me53me572V+JDmdfCOd+zh858/yWhUUKmEL7iNtZaNjQHf+72HabWSl92ntbDRgafPL5DaOmGwRiAhzUcgYzdtyVqQfYSIsSyDTUB2QWauD03QxPWgSUA2MGbcjwaL0l3i4CTa7ENnYBKJtAYBGGtB1BFRh7pZQSnFaFSSJCFGa4Kgx77lNpOTFX7gBw5z003zKCWo1SIOHGi/4n47nud5nud5nud53huR5bVfjmRf4/2/MfiQ5nVwww2zXHfdDA8+uMa1104/L6Sw1nLy5C4TExXe8559L7u/s5vwia/Bpz+3wtc/d5QsG2FHkGcCa7QLYqx1F5NgxSFQeyDaAHkGihFoC1kK0SEIFkGE498BjSwHCH2MSn1Eoz1kvV9gRIiRgSulsQYqlkMHZpgZzLNyZpWdnRG9nkBJweyEZc+eBt/3fYf563/9BpTyE5k8z/M8z/M8z/M877l8SPM6CEPFT/7k2/k3/+Y+Hn30PO12wsxMDSkF3W7G+nqfiYkKf/tvv42rr558yX2t7cC//u9w/zfOcfwr95Fvl1TbDdJkEmMXIJdIO8Jai7UxmGlQVdTeEqpLmLU6pOvYAqSqY3QDMgmyAGsQRYdQnaRWe5KwNkWt2qEoGwzzmCJUlBaUKrjhQIXvuauJNe9mbWWNo0c32d7WvONWy3f/pQpvf/siV1018bLNkj3P8zzP8zzP87w3G984+ErxIc3rZGGhwc/93J3cc88p7rnnFCsrPax146Y//OGrufvufRw58vLjpj//MDx91lCsPEU+Kqm0p6lWIMumsGqKONlFmAqa/RhCtBFQNQRzKWZLI1s11KG92J7CdANkoSjXchiAECPC8CTY+1hcaHHDzROcWR+h9Qp5NovtK5rVnFtvanHH7W4S1E43pJfuZf+hvfz898MPfORSf2LP8zzP8zzP8zzP816cD2leRxMTFT760Wv48IevYn19gNaGdjthauqVjZvWGr52FKJil+7GLkHSpByvairyFqCxQYBNBwjRQ4lJjDSEB1JkWGJtiZzSCCERTY2oF8hGjmxC/nAA9JieDZiYuZ4k7lMMN5mrQzOAQ3t2qDSvQoYLWFHh8acE1kKjDnfdDnffBW+70Qc0nud5nud5nud5b32ai9OCX9PneOvzIc0bQKUSfkvjpksDeQESjTEGK9XF89ZaiRBmPH5JAMZN2hag6gabSVctNm6HIxAIqVCRRLQ1pRKEYcievXu55pY5FmeH/JWPbJOmJVGk2Lu3xZ49DXZ2BU8/A2kKYQhLi7B3jw9nPM/zPM/zPM/zPO/V8iHNm1gUwIE5OHe2Rp7N0l+roW2FItYIU2JtBWlSrABE5IKTQqC3A5KbBmS7YIcSUbvQJdsiItCbCiEEQSCpN2NGIzi4v8oddzy/wmdyAu649dv4oj3P8zzP8zzP87w3GN+T5krxIc2bxGCQc/78gDBULC42kFIgBMyhOfolyc7WIfJ+F02ATiWlDbEqoRAjKlGCVDWyHBACvaP+/+3deXQV5f3H8c9dktzsISFkkQAxyBYg7DGiQSQ1tIUeWqrgsZxAKWgLRURtoRaC1l3hB8imHiWtIqJV0B8/RWyQQCqCgIlatqhhkSUhkBCyJ/fO7w/k1msSFiHMBd+vc+4f95mZZz6TzIHM9zzzPPLvUS1V+ahmh48MH5csfpI1zCmjXKo/4COLpVoBgT6KiAyUzSYN6GP2TwAAAAAAgKsbRRovV1vboP/7vwLl5OzT8ePVstut6tgxXMOHd5IUqU3v1apNgGRPCFadTunU8RNqqHPJMCyS0UYuv2BZAwNks1tldUk2w5Cr2KbqrQEKGHRKsrtU96VdrkpDzlKLnAetMmpqFBJqUXRsmKrr7bq+l5TYxeyfBAAAAADAOzGS5lKhSOPFXC5Df/97vt5//0uFhTkUGxus+nqnPvusSF9/fVI2W4pOnQrQzckWFRTWqqLYKmdNkJz19XJY69VQV6yqqgYZfjWKvKa17OEWWQxDtbUWVf47WEahQwHXVcsW7VTDN4ZcRw1ZayX/1kEKcDjk4++r3kkW/e43p+ebAQAAAAAALYcijRcrKDiuTZv2q23bELVq5f9tq49CQvy0eXONjhyp0NChgZKkQ/uPKUjVanVNgA4d8ZOPj5/swYaMY3WqLquXo61NrVqFfdvHmTlobNI3QaqqlsoqpTK7VOMwZLMaioy06Pd32TXyF5L7MAAAAAAAGmEkzaVCkcaL7dx5TJWV9UpICPdot1gs8vePUHl5g+rr61Rba6isrEYhIX7y8ZGqa6SSE5LF16KwcKuKi6Qjh8oVGBQqX7/Gyy4F+Et22+mRO5FhhpJ6WHTvVF/172+9XJcKAAAAAMCPHkUaL1ZX52x2KWubzSqXy5DLZcjpdMnlMmSznZ5MuE1ryTCkE6WSzWaRr68UEmboVLkhp9NQYJDkH3B6X5dTKi2TTpYZigiXBt1o1e9/76MuXWyX9VoBAAAAAFcq57eflj7H1Y8ijcmKiiq0bdth7d17Qg0NTkVHB6lfv1h17txaMTHBslgsamhwyW73HNVSW1slX99wORx2WSySn59N1dUNCgryldUqRbeRfH2kY8cNuVwWhUcHKaGjn44ddarosFNlpYZqak8XcwKDpBtvtGnCOJuSk20KDGymMgQAAAAAAFoMRRqTOJ0urVmzV2vW7NWxY5UyjNOvMRmGofff/0q9ekXr9tsTFR8fpj17StS5c2t3oaa8vFYWS6WuvTZBJ09aFRNjVbt2odq9u0Q+Plb5+Z0u3ISFulRWKsXHWzQ0PVKHi23y87cpPMql+lpDkeHSgL7SgL4WdU+0yGqlOAMAAAAAuFDMSXOpUKQxyf/+716tWPG56utdKiurVWlptQxDCgg4vYzSpk0HVFvboDvv7Knlyz/T7t0lMgxDhmHI399HP/tZOwUEhOuddwwFBxvq2rW1qqsb9M035Sorq5Ek1df7KjAwSH/+U5jGjPFV2UmppkYyZJXDTwoNkWy81QQAAAAAgFegSGOCw4dPac2avWpocKmwsFR1dU6FhPjJarWoqqpee/eeUHx8mLZvP6L+/a/RzJmDtGPHER06VC5fX5u6dYtUly6tVV9vUUVFnXJynPLxsahLlxjFxobp6NFqnThhVWCgj+64I1B33ukvi4VVmgAAAAAALcFQy490Mc69y1WAIo0Jtm49pJKSKp04Ua36epciIwPd20JDbfL1rdehQ+WKjw/Thg37NHhwB918c4dG/dhs0sSJvkpMdConp0GFhS7V1/srKspfgwbZlJp6eo4ZXmMCAAAAAMD7UaQxwa5dJZLkXjb7+xwOu8rLa2UYp0fdHDtWpdjY4Cb78vW1aPBgu1JTbTp82FBtrRQQIMXEWGRpbmkoAAAAAAAuGeakuVQo0pigvt4p6fTy2U2NcjlTXDEMuZfYPhebzaK4OIoyAAAAAABcqazn3gWXWlTU6debHA67qqvrG213Ol2yWE6vtuTvb29ytA0AAAAAALi6UKQxwYAB18jh8FFMTJCqqhpUW9vg3uZyGTp+vFqtWjlkGIaSk9sqNNRhYloAAAAAAM7GeZk+Vz+KNCbo3r2NunWLlMViUVxciCoq6lRUVKmiogqVlFQpLMyhqKhARUYGKjW1vdlxAQAAAADAZcCcNCbw8bFpwoQ+qq936vPPi5SQ0Mo9/4zNZpXL5VJ4eIDGjeuljh3DzY4LAAAAAMBZMHHwpUKRxiQxMcGaNi1FOTn7lZOzX8eOVcowDAUE+Kh//2s0aFB7de7c2uyYAAAAAADgMqFIY6JWrfw1YkQXpacnqKioUk6nS2FhDkVEBJgdDQAAAACA88RImkuFIo0X8Pf3UYcOYWbHAAAAAAAAJqJIAwAAAAAALsLlWH2J1Z0AAAAAAABwmTCSBgAAAAAAXATmpLlUGEkDAAAAAADgBRhJAwAAAAAALgIjaS4VRtIAAAAAAAB4AUbSAAAAAACAi2Co5Ue6GC3cv3dgJA0AAAAAAIAXYCQNAAAAAAC4CM5vPy19jqsfI2kAAAAAAAC8ACNpAAAAAADARWB1p0uFkTQAAAAAAABegCINAAAAAACAF6BIAwAAAAAALoLrMn0u3KJFi9ShQwc5HA4lJydr69atP+wSLxOKNAAAAAAA4KqzcuVKTZs2TZmZmdqxY4eSkpKUnp6u4uJis6M1iyINAAAAAAC4CN45kmbu3LmaMGGCxo0bp27dumnp0qUKCAjQSy+99MMvtYVd9as7GYYhSSovLzc5CQAAAADgx+DM8+eZ59GrXW1t1WU7x/ef7f38/OTn59do/7q6Om3fvl0zZsxwt1mtVqWlpWnz5s0tG/YiXPVFmlOnTkmS4uLiTE4CAAAAAPgxOXXqlEJDQ82O0WJ8fX0VHR2t//mf2y/L+YKCgho922dmZmr27NmN9i0pKZHT6VRUVJRHe1RUlHbv3t2SMS/KVV+kiY2N1cGDBxUcHCyLxWJajvLycsXFxengwYMKCQkxLQdwPrhfcaXhnsWVhPsVVxruWVxJvOV+NQxDp06dUmxsrGkZLgeHw6HCwkLV1dVdlvMZhtHoub6pUTRXsqu+SGO1WtW2bVuzY7iFhITwnxuuGNyvuNJwz+JKwv2KKw33LK4k3nC/Xs0jaL7L4XDI4XCYHaOR1q1by2azqaioyKO9qKhI0dHRJqU6NyYOBgAAAAAAVxVfX1/17dtX2dnZ7jaXy6Xs7GylpKSYmOzsrvqRNAAAAAAA4Mdn2rRpysjIUL9+/TRgwADNmzdPlZWVGjdunNnRmkWR5jLx8/NTZmbmVfe+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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", + "image/png": "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RCeJdrLXoZPJ8ft87StfIf3Y9N/R0hEKsezb3570n1P1pBmBDFx8iBKeoI6ddFBHF55jq0fDx5oK5+PhXLJZNkpVNBPd66+/zs6dOznjjDNobW3ltttuA+Cyyy5LcmQiIiIiIokXxEs6HjoI9UvSxI5V6GfhT0ZoIqdMPWkSR0maEQri4v0UU0+Y7bRRc2z5UzpOZpDGLNLxpkxB1sh897vfZdeuXbjdbk4//XSee+45cnNzT+mcb538dKLHHnssYU2IRURERESGw4uLeRSynv34cOHHDcQTNDW0kUc608hJcpQikmxK0pwCA4N8POSTN+bXHmgE9kSyZMkSNmzYkPDzbtq0adB9JSUlCb+eiIiIiMhQLaGUNnrYQS31dPbW2+eRzgXM6k3ciEw0BqNf6ZIq61OUpJFJZcaMGckOQURERERkQG4cXMBM5lLAYVqIECMLP9PIUYJGRAAlaQCY5L2TZYLS96WIiIjI5GNiUkompWQmOxSRhFFPmsRJlfsckMvlAqCrqyvJkYj0d/z78vj3qYiIiIiIiExuKV1J43A4yMzMpK6uDgC/349hpMpKNxmvbNumq6uLuro6MjMzcTjUgFpERERERMYvx7HHaF8jFaR0kgagsLAQoDdRIzJeZGZm9n5/ioiIiIiIyOSX8kkawzAoKioiPz+fSCSS7HBEgPgSJ1XQiIiIiIjIRKCeNImT8kma4xwOh14Ui4iIiIiIiEjSpEoySkRERERERERkXFMljYiIiIiIiIiMmJY7JU6q3KeIiIiIiIiIyLimShoRERERERERGTFV0iROqtyniIiIiIiIiMi4pkoaERERERERERkxx7HHaF8jFaiSRkRERERERERkHFAljYiIiIiIiIiMmHrSJE6q3KeIiIiIiIiIpIhnn32WNWvWUFxcjGEYPPjgg33227bN1772NYqKivD5fFx44YXs2bMnOcG+hZI0IiIiIiIiIjJiBm9W04zWwxhmTJ2dnSxatIj//u//HnD/d77zHX74wx/y05/+lJdffpm0tDQuvvhienp6hnmlxNJyJxERERERERGZVC655BIuueSSAffZts3atWv593//dy677DIA7rnnHgoKCnjwwQf50Ic+NJah9qFKGhEREREREREZMccYPQDa2tr6PEKh0LDjPXDgAEePHuXCCy/s3ZaRkcGZZ57Jiy++OOzzJZKSNCIiIiIiIiIyIZSVlZGRkdH7uP3224d9jqNHjwJQUFDQZ3tBQUHvvmTRcicRERERERERGbGxnO5UVVVFMBjs3e7xeEb5ymNLlTQiIiIiIiIiMiEEg8E+j5EkaQoLCwGora3ts722trZ3X7IoSSMiIiIiIiIiI2YaYJqj/BjueKeTmDp1KoWFhTz99NO929ra2nj55ZdZsWJF4i40AlruJCIiIiIiIiKTSkdHB3v37u39+MCBA2zatIns7GzKy8v53Oc+xze/+U1mzpzJ1KlTufnmmykuLubyyy9PXtAoSSMiIiIiIiIip+B4tctoX2M4XnvtNc4777zejz//+c8DcO211/KrX/2KL3/5y3R2dnL99dfT0tLCueeey+OPP47X601k2MOmJI2IiIiIiIiITCqrV6/Gtu1B9xuGwW233cZtt902hlG9PSVpRERERERERGTEHEb8MdrXSAVJbRx85513snDhwt6uzCtWrOCxxx7r3b969WoMw+jz+OQnP5nEiEVERERERERERkdSK2lKS0v59re/zcyZM7Ftm7vvvpvLLruM119/nfnz5wPw8Y9/vE/5kd/vT1a4IiIiIiIiIiKjJqlJmjVr1vT5+Fvf+hZ33nknL730Um+Sxu/3J31OuYiIiIiIiIgMbDw2Dp6oxs1txmIx7rvvPjo7O/vMJf/Nb35Dbm4up512Gl/96lfp6uo66XlCoRBtbW19HiIiIiIiIiIi413SGwdv2bKFFStW0NPTQ3p6Og888ADz5s0D4KqrrmLKlCkUFxezefNm/uVf/oVdu3bxpz/9adDz3X777dx6661jFb6IiIiIiIhISnOY8cdoXyMVGPbJZlKNgXA4TGVlJa2trfzhD3/gF7/4BevWretN1LzV3/72Ny644AL27t3L9OnTBzxfKBQiFAr1ftzW1kZZWRmtra0Eg8FRuw8RERERERERiL8OzcjImPSvQ3vv85avEPR6RvdaPSEybvn2pP+cJr2Sxu12M2PGDABOP/10Xn31VX7wgx/ws5/9rN+xZ555JsBJkzQejwePZ3S/OURERERERETkGJPRb6aSIpU04+42LcvqUwnzVps2bQKgqKhoDCMSERERERERERl9Sa2k+epXv8oll1xCeXk57e3t3HvvvTzzzDM88cQT7Nu3j3vvvZd3v/vd5OTksHnzZm666SZWrlzJwoULkxm2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", "text/plain": [ "
" ] @@ -1402,7 +1434,7 @@ }, { "data": { - "image/png": "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X34Zhw4dQmlpKSwWCwAgJycHvXv3bva4GRkZqK6uxoQJE5o8bzQaMWjQoHb4JkQkNwwmRGR3VVVVSEhIQEJCAr766isEBQUhJycHCQkJMBqNl/1cZWUlAGDTpk3o1KlTk9e0Wm271kxE8sBgQkQ269q1K9RqNfbu3YuoqCgAQGlpKdLT0zFmzBikpqaiuLgYS5YsQWRkJID6SzkX0mg0AACz2Wx9rnfv3tBqtcjJycGYMWMc9G2ISE4YTIjIZl5eXpg9ezaeeuopBAQEIDg4GM8//zwUivr19FFRUdBoNHj//ffxyCOP4OjRo3jttdeaHKNz584QBAE//vgjbrzxRri7u8Pb2xtPPvkk5s+fD4vFglGjRkGv12PXrl3w8fHBrFmzpPi6RORAvCuHiFrlX//6F6699lpMnjwZ48ePx6hRoxAXFwcACAoKwqeffoq1a9eid+/eWLJkCd58880mn+/UqRNeeeUVPPvsswgJCcHjjz8OAHjttdfw97//HYsXL0avXr1www03YNOmTejSpYvDvyMROR7vyiEiIiLZ4IwJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREcnG/wO2QTfrb4+kxgAAAABJRU5ErkJggg==", + "image/png": "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X34Zhw4dQmlpKSwWCwAgJycHvXv3bva4GRkZqK6uxoQJE5o8bzQaMWjQoHb4JkQkNwwmRGR3VVVVSEhIQEJCAr766isEBQUhJycHCQkJMBqNl/1cZWUlAGDTpk3o1KlTk9e0Wm271kxE8sBgQkQ269q1K9RqNfbu3YuoqCgAQGlpKdLT0zFmzBikpqaiuLgYS5YsQWRkJID6SzkX0mg0AACz2Wx9rnfv3tBqtcjJycGYMWMc9G2ISE4YTIjIZl5eXpg9ezaeeuopBAQEIDg4GM8//zwUivr19FFRUdBoNHj//ffxyCOP4OjRo3jttdeaHKNz584QBAE//vgjbrzxRri7u8Pb2xtPPvkk5s+fD4vFglGjRkGv12PXrl3w8fHBrFmzpPi6RORAvCuHiFrlX//6F6699lpMnjwZ48ePx6hRoxAXFwcACAoKwqeffoq1a9eid+/eWLJkCd58880mn+/UqRNeeeUVPPvsswgJCcHjjz8OAHjttdfw97//HYsXL0avXr1www03YNOmTejSpYvDvyMROR7vyiEiIiLZ4IwJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREckGgwkRERHJBoMJERERyQaDCREREcnG/wO2QTfrb4+kxgAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -1440,7 +1472,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.10.16" + "version": "3.12.10" } }, "nbformat": 4, diff --git a/noxfile.py b/noxfile.py index 839e6e0e115..cc38a3b8c04 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 @@ -28,14 +28,14 @@ import nox import nox.sessions -PROJECT_ID_OVERRIDE = os.getenv("BIGFRAMES_TEST_PROJECT") -ENV_OVERRIDES = ( - {"GOOGLE_CLOUD_PROJECT": PROJECT_ID_OVERRIDE} if PROJECT_ID_OVERRIDE else {} -) - -RUFF_VERSION = "ruff==0.14.14" +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" + # Notebook tests should match colab and BQ Studio. # Check with import sys; sys.version_info # on a fresh notebook runtime. @@ -46,7 +46,9 @@ "3.11", ] -PYTEST_VERSION = "pytest==8.4.2" +# pytest-retry is not yet compatible with pytest 8.x. +# https://github.com/str0zzapreti/pytest-retry/issues/32 +PYTEST_VERSION = "pytest<8.0.0dev" SPHINX_VERSION = "sphinx==4.5.0" LINT_PATHS = [ "docs", @@ -58,17 +60,23 @@ "setup.py", ] -DEFAULT_PYTHON_VERSION = "3.14" +DEFAULT_PYTHON_VERSION = "3.10" -ALL_PYTHON = ["3.10", "3.11", "3.12", "3.13", "3.14", "3.15"] +# 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", PYTEST_VERSION, + "pytest-asyncio", "pytest-cov", + "pytest-mock", "pytest-timeout", - "pluggy", ] -UNIT_TEST_EXTERNAL_DEPENDENCIES: List[str] = [] +UNIT_TEST_LOCAL_DEPENDENCIES: List[str] = [] UNIT_TEST_DEPENDENCIES: List[str] = [] UNIT_TEST_EXTRAS: List[str] = ["tests"] UNIT_TEST_EXTRAS_BY_PYTHON: Dict[str, List[str]] = { @@ -77,14 +85,13 @@ # Make sure we leave some versions without "extras" so we know those # dependencies are actually optional. "3.13": ["tests", "polars", "scikit-learn", "anywidget"], - "3.14": ["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: List[str] = ALL_PYTHON +SYSTEM_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.13"] SYSTEM_TEST_STANDARD_DEPENDENCIES = [ "jinja2", "mock", @@ -101,14 +108,15 @@ SYSTEM_TEST_EXTERNAL_DEPENDENCIES = [ "google-cloud-bigquery", ] -SYSTEM_TEST_EXTRAS: List[str] = [] +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]] = { # Make sure we leave some versions without "extras" so we know those # dependencies are actually optional. "3.10": ["tests", "scikit-learn", "anywidget"], - "3.12": ["tests", "scikit-learn", "polars", "anywidget"], + "3.11": ["tests", "scikit-learn", "polars", "anywidget"], "3.13": ["tests", "polars", "anywidget"], - "3.14": ["tests", "polars", "anywidget"], } LOGGING_NAME_ENV_VAR = "BIGFRAMES_PERFORMANCE_LOG_NAME" @@ -118,12 +126,8 @@ # Sessions are executed in the order so putting the smaller sessions # ahead to fail fast at presubmit running. nox.options.sessions = [ - # 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.12", # No extras. + "system-3.9", # No extras. + "system-3.11", "cover", # TODO(b/401609005): remove "cleanup", @@ -140,46 +144,26 @@ def lint(session): Returns a failure if the linters find linting errors or sufficiently serious code quality issues. """ - session.install(RUFF_VERSION) - - # Check imports + session.install(FLAKE8_VERSION, BLACK_VERSION, ISORT_VERSION) session.run( - "ruff", - "check", - "--select", - "I,F", - f"--target-version=py{ALL_PYTHON[0].replace('.', '')}", - "--line-length=88", # Standard Black line length + "isort", + "--check", *LINT_PATHS, ) - - # Check formatting session.run( - "ruff", - "format", + "black", "--check", - f"--target-version=py{ALL_PYTHON[0].replace('.', '')}", - "--line-length=88", *LINT_PATHS, ) + session.run("flake8", *LINT_PATHS) -# Use a python runtime which is available in the owlbot post processor here -# https://github.com/googleapis/synthtool/blob/master/docker/owlbot/python/Dockerfile @nox.session(python=DEFAULT_PYTHON_VERSION) def blacken(session): - """(Deprecated) Legacy session. Please use 'nox -s format'.""" - session.log( - "WARNING: The 'blacken' session is deprecated and will be removed in a future release. Please use 'nox -s format' in the future." - ) - - # Just run the ruff formatter (keeping legacy behavior of only formatting, not sorting imports) - session.install(RUFF_VERSION) + """Run black. Format code to uniform standard.""" + session.install(BLACK_VERSION) session.run( - "ruff", - "format", - f"--target-version=py{ALL_PYTHON[0].replace('.', '')}", - "--line-length=88", + "black", *LINT_PATHS, ) @@ -187,31 +171,18 @@ def blacken(session): @nox.session(python=DEFAULT_PYTHON_VERSION) def format(session): """ - Run ruff to sort imports and format code. + Run isort to sort imports. Then run black + to format code to uniform standard. """ - # 1. Install ruff (skipped automatically if you run with --no-venv) - session.install(RUFF_VERSION) - - # 2. Run Ruff to fix imports - # check --select I: Enables strict import sorting - # --fix: Applies the changes automatically + session.install(BLACK_VERSION, ISORT_VERSION) + # Use the --fss option to sort imports using strict alphabetical order. + # See https://pycqa.github.io/isort/docs/configuration/options.html#force-sort-within-sections session.run( - "ruff", - "check", - "--select", - "I,F", - "--fix", - f"--target-version=py{ALL_PYTHON[0].replace('.', '')}", - "--line-length=88", # Standard Black line length + "isort", *LINT_PATHS, ) - - # 3. Run Ruff to format code session.run( - "ruff", - "format", - f"--target-version=py{ALL_PYTHON[0].replace('.', '')}", - "--line-length=88", # Standard Black line length + "black", *LINT_PATHS, ) @@ -219,7 +190,7 @@ 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", "setuptools") + session.install("docutils", "pygments") session.run("python", "setup.py", "check", "--restructuredtext", "--strict") session.install("twine", "wheel") @@ -232,20 +203,20 @@ def lint_setup_py(session): def install_unittest_dependencies(session, install_test_extra, *constraints): - extras = [] + standard_deps = UNIT_TEST_STANDARD_DEPENDENCIES + UNIT_TEST_DEPENDENCIES + session.install(*standard_deps, *constraints) + + if UNIT_TEST_LOCAL_DEPENDENCIES: + session.install(*UNIT_TEST_LOCAL_DEPENDENCIES, *constraints) + 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( - *UNIT_TEST_STANDARD_DEPENDENCIES, - *UNIT_TEST_DEPENDENCIES, - "-e", - f".[{','.join(extras)}]" if extras else ".", - *constraints, - ) + session.install("-e", f".[{','.join(extras)}]", *constraints) + else: + session.install("-e", ".", *constraints) def run_unit(session, install_test_extra): @@ -280,25 +251,76 @@ def run_unit(session, install_test_extra): ) -@nox.session(python=ALL_PYTHON) -@nox.parametrize("test_extra", [True, False]) -def unit(session, test_extra): - if session.python == "3.15": - session.skip( - "Skipping 3.15 until wheels are available for pyarrow. Also pyproj wheels are needed for dependency geopandas." - ) - if test_extra: - run_unit(session, install_test_extra=test_extra) - else: - unit_noextras(session) +@nox.session(python=UNIT_TEST_PYTHON_VERSIONS) +def unit(session): + run_unit(session, install_test_extra=True) -@nox.session(python=ALL_PYTHON[-1]) +@nox.session(python=UNIT_TEST_PYTHON_VERSIONS[-1]) def unit_noextras(session): run_unit(session, install_test_extra=False) +@nox.session(python=DEFAULT_PYTHON_VERSION) +def mypy(session): + """Run type checks with mypy.""" + # Editable mode is not compatible with mypy when there are multiple + # package directories. See: + # https://github.com/python/mypy/issues/10564#issuecomment-851687749 + session.install(".") + + # Just install the dependencies' type info directly, since "mypy --install-types" + # might require an additional pass. + deps = ( + set( + [ + 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) + | set(UNIT_TEST_STANDARD_DEPENDENCIES) + ) + + session.install(*deps) + shutil.rmtree(".mypy_cache", ignore_errors=True) + session.run( + "mypy", + "bigframes", + os.path.join("tests", "system"), + os.path.join("tests", "unit"), + "--check-untyped-defs", + "--explicit-package-bases", + '--exclude="^third_party"', + ) + + def install_systemtest_dependencies(session, install_test_extra, *constraints): + # Use pre-release gRPC for system tests. + # Exclude version 1.49.0rc1 which has a known issue. + # See https://github.com/grpc/grpc/pull/30642 + session.install("--pre", "grpcio!=1.49.0rc1") + + session.install(*SYSTEM_TEST_STANDARD_DEPENDENCIES, *constraints) + + if SYSTEM_TEST_EXTERNAL_DEPENDENCIES: + session.install(*SYSTEM_TEST_EXTERNAL_DEPENDENCIES, *constraints) + + if SYSTEM_TEST_LOCAL_DEPENDENCIES: + session.install("-e", *SYSTEM_TEST_LOCAL_DEPENDENCIES, *constraints) + + if SYSTEM_TEST_DEPENDENCIES: + session.install("-e", *SYSTEM_TEST_DEPENDENCIES, *constraints) + if install_test_extra and SYSTEM_TEST_EXTRAS_BY_PYTHON: extras = SYSTEM_TEST_EXTRAS_BY_PYTHON.get(session.python, []) elif install_test_extra and SYSTEM_TEST_EXTRAS: @@ -306,19 +328,10 @@ def install_systemtest_dependencies(session, install_test_extra, *constraints): else: extras = [] - # Use pre-release gRPC for system tests. - # Exclude version 1.49.0rc1 which has a known issue. - # See https://github.com/grpc/grpc/pull/30642 - - session.install( - "--pre", - "grpcio!=1.49.0rc1", - *SYSTEM_TEST_STANDARD_DEPENDENCIES, - *SYSTEM_TEST_EXTERNAL_DEPENDENCIES, - "-e", - f".[{','.join(extras)}]" if extras else ".", - *constraints, - ) + if extras: + session.install("-e", f".[{','.join(extras)}]", *constraints) + else: + session.install("-e", ".", *constraints) def run_system( @@ -355,7 +368,6 @@ def run_system( "py.test", "-v", f"-n={num_workers}", - "--dist=worksteal", # Any individual test taking longer than 15 mins will be terminated. f"--timeout={timeout_seconds}", # Log 20 slowest tests @@ -381,10 +393,14 @@ def run_system( ) pytest_cmd.extend(extra_pytest_options) - session.run(*pytest_cmd, *session.posargs, test_folder, env=ENV_OVERRIDES) + session.run( + *pytest_cmd, + *session.posargs, + test_folder, + ) -@nox.session(python="3.12") +@nox.session(python=SYSTEM_TEST_PYTHON_VERSIONS) def system(session: nox.sessions.Session): """Run the system test suite.""" run_system( @@ -395,7 +411,7 @@ def system(session: nox.sessions.Session): ) -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python=LATEST_FULLY_SUPPORTED_PYTHON) def system_noextras(session: nox.sessions.Session): """Run the system test suite.""" run_system( @@ -406,10 +422,9 @@ def system_noextras(session: nox.sessions.Session): ) -@nox.session(python="3.12") +@nox.session(python=LATEST_FULLY_SUPPORTED_PYTHON) def doctest(session: nox.sessions.Session): """Run the system test suite.""" - run_system( session=session, prefix_name="doctest", @@ -419,25 +434,11 @@ def doctest(session: nox.sessions.Session): "--ignore", "third_party/bigframes_vendored/ibis", "--ignore", - "third_party/bigframes_vendored/sqlglot", - "--ignore", "bigframes/core/compile/polars", "--ignore", "bigframes/testing", "--ignore", "bigframes/display/anywidget.py", - "--ignore", - "bigframes/bigquery/_operations/ai.py", - "--ignore", - "bigframes/bigquery/ai.py", - "--ignore", - "bigframes/ml", - "--ignore", - "bigframes/operations/ai.py", - "--ignore", - "bigframes/operations/semantics.py", - "--ignore", - "third_party/bigframes_vendored/sklearn", ), test_folder="bigframes", check_cov=True, @@ -445,7 +446,7 @@ def doctest(session: nox.sessions.Session): ) -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python=E2E_TEST_PYTHON_VERSION) def e2e(session: nox.sessions.Session): """Run the large tests in system test suite.""" run_system( @@ -475,16 +476,13 @@ def cover(session): This outputs the coverage report aggregating coverage from the test runs (including system test runs), and then erases coverage data. """ - # TODO: Remove this skip when the issue is resolved. - # https://github.com/googleapis/google-cloud-python/issues/16635 - session.skip("Temporarily skip coverage 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 @@ -507,27 +505,34 @@ def cover(session): "report", "--show-missing", "--include=tests/system/small/*", - # Some tests only run under old pandas, some only under new pandas version - "--fail-under=98", + # TODO(b/353775058) resume coverage to 100 when the issue is fixed. + "--fail-under=99", ) session.run("coverage", "erase") -@nox.session(python="3.10") +@nox.session(python=DEFAULT_PYTHON_VERSION) def docs(session): """Build the docs for this library.""" session.install("-e", ".[scikit-learn]") session.install( - "sphinx", - "sphinx-sitemap", - "myst-parser", - "myst-nb", - "pydata-sphinx-theme", + # 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", + "anywidget", ) shutil.rmtree(os.path.join("docs", "_build"), ignore_errors=True) - session.run("python", "-m", "pip", "freeze") session.run( "python", @@ -548,18 +553,25 @@ def docs(session): ) -@nox.session(python="3.10") +@nox.session(python=DEFAULT_PYTHON_VERSION) def docfx(session): """Build the docfx yaml files for this library.""" 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, - "sphinx-sitemap==2.9.0", - "pydata-sphinx-theme==0.13.3", - "myst-parser==0.18.1", - "myst-nb", - "gcp-sphinx-docfx-yaml==3.2.4", + "alabaster", + "recommonmark", + "gcp-sphinx-docfx-yaml==3.0.1", "anywidget", ) @@ -584,7 +596,7 @@ def docfx(session): "sphinx.ext.napoleon," "sphinx.ext.todo," "sphinx.ext.viewcode," - "myst_parser" + "recommonmark" ), "-b", "html", @@ -599,36 +611,101 @@ def prerelease(session: nox.sessions.Session, tests_path, extra_pytest_options=( constraints_path = str( CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" ) - session.install( - *set(UNIT_TEST_STANDARD_DEPENDENCIES + SYSTEM_TEST_STANDARD_DEPENDENCIES), - "-c", - constraints_path, - "-e", - ".", - ) + + # Ignore officially released versions of certain packages specified in + # testing/constraints-*.txt and install a more recent, pre-release versions + # directly + already_installed = set() # PyArrow prerelease packages are published to an alternative PyPI host. # https://arrow.apache.org/docs/python/install.html#installing-nightly-packages session.install( - "--no-deps", - "--upgrade", "--extra-index-url", "https://pypi.fury.io/arrow-nightlies/", + "--prefer-binary", + "--pre", + "--upgrade", "pyarrow", + ) + already_installed.add("pyarrow") + + session.install( + "--prefer-binary", + "--pre", + "--upgrade", # We exclude each version individually so that we can continue to test # some prerelease packages. See: - # https://github.com/googleapis/google-cloud-python/pull/268#discussion_r1423205172 + # https://github.com/googleapis/python-bigquery-dataframes/pull/268#discussion_r1423205172 # "pandas!=2.1.4, !=2.2.0rc0, !=2.2.0, !=2.2.1", "pandas", - # Workaround https://github.com/googleapis/python-db-dtypes-pandas/issues/178 - "db-dtypes", - # Ensure we catch breaking changes in the client libraries early. - "git+https://github.com/googleapis/google-cloud-python.git#egg=google-cloud-bigquery&subdirectory=packages/google-cloud-bigquery", + ) + already_installed.add("pandas") + + # Try to avoid a cap on our SQLGlot so that bigframes + # can be integrated with SQLMesh. See: + # https://github.com/googleapis/python-bigquery-dataframes/issues/942 + # If SQLGlot introduces something that breaks us, lets file an issue + # upstream and/or make sure we fix bigframes to work with it. + session.install( + "--upgrade", + "git+https://github.com/tobymao/sqlglot.git#egg=sqlglot", + ) + already_installed.add("sqlglot") + + # Workaround https://github.com/googleapis/python-db-dtypes-pandas/issues/178 + session.install("--no-deps", "db-dtypes") + already_installed.add("db-dtypes") + + # Ensure we catch breaking changes in the client libraries early. + session.install( + "--upgrade", + "git+https://github.com/googleapis/python-bigquery.git#egg=google-cloud-bigquery", + ) + already_installed.add("google-cloud-bigquery") + session.install( "--upgrade", "-e", - "git+https://github.com/googleapis/google-cloud-python.git#egg=google-cloud-bigquery-storage&subdirectory=packages/google-cloud-bigquery-storage", - "git+https://github.com/googleapis/google-cloud-python.git#egg=pandas-gbq&subdirectory=packages/pandas-gbq", + "git+https://github.com/googleapis/python-bigquery-storage.git#egg=google-cloud-bigquery-storage", ) + already_installed.add("google-cloud-bigquery-storage") + session.install( + "--upgrade", + "git+https://github.com/googleapis/python-bigquery-pandas.git#egg=pandas-gbq", + ) + already_installed.add("pandas-gbq") + + session.install( + *set(UNIT_TEST_STANDARD_DEPENDENCIES + SYSTEM_TEST_STANDARD_DEPENDENCIES), + "-c", + constraints_path, + ) + + # Because we test minimum dependency versions on the minimum Python + # version, the first version we test with in the unit tests sessions has a + # constraints file containing all dependencies and extras. + with open( + CURRENT_DIRECTORY + / "testing" + / f"constraints-{UNIT_TEST_PYTHON_VERSIONS[0]}.txt", + encoding="utf-8", + ) as constraints_file: + constraints_text = constraints_file.read() + + # Ignore leading whitespace and comment lines. + deps = [ + match.group(1) + for match in re.finditer( + r"^\s*(\S+)(?===\S+)", constraints_text, flags=re.MULTILINE + ) + 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) + session.install("--no-deps", "-e", ".") # Print out prerelease package versions. session.run("python", "-m", "pip", "freeze") @@ -650,11 +727,10 @@ def prerelease(session: nox.sessions.Session, tests_path, extra_pytest_options=( tests_path, *extra_pytest_options, *session.posargs, - env=ENV_OVERRIDES, ) -@nox.session(python=ALL_PYTHON[-1]) +@nox.session(python=UNIT_TEST_PYTHON_VERSIONS[-1]) def unit_prerelease(session: nox.sessions.Session): """Run the unit test suite with prerelease dependencies.""" prerelease(session, os.path.join("tests", "unit")) @@ -685,7 +761,7 @@ def system_prerelease(session: nox.sessions.Session): @nox.session(python=COLAB_AND_BQ_STUDIO_PYTHON_VERSIONS) def notebook(session: nox.Session): - google_cloud_project = PROJECT_ID_OVERRIDE or os.getenv("GOOGLE_CLOUD_PROJECT") + google_cloud_project = os.getenv("GOOGLE_CLOUD_PROJECT") if not google_cloud_project: session.error( "Set GOOGLE_CLOUD_PROJECT environment variable to run notebook session." @@ -704,6 +780,7 @@ def notebook(session: nox.Session): ) notebooks_list = list(pathlib.Path("notebooks/").glob("*/*.ipynb")) + denylist = [ # Regionalized testing is manually added later. "notebooks/location/regionalized.ipynb", @@ -725,10 +802,11 @@ def notebook(session: nox.Session): # 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_vector_search.ipynb", # Limited quota for vector index ddl statements on table. + "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", # Needs DATASET_ID. "notebooks/generative_ai/bq_dataframes_ml_drug_name_generation.ipynb", # Needs CONNECTION. - "notebooks/generative_ai/ai_movie_poster.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. "notebooks/vertex_sdk/sdk2_bigframes_sklearn.ipynb", # Needs BUCKET_URI. "notebooks/vertex_sdk/sdk2_bigframes_tensorflow.ipynb", # Needs BUCKET_URI. @@ -744,10 +822,22 @@ def notebook(session: nox.Session): # This anywidget notebook uses deferred execution, so it won't # produce metrics for the performance benchmark script. "notebooks/dataframes/anywidget_mode.ipynb", - # Needs a connection - "notebooks/remote_functions/remote_function_vertex_claude_model.ipynb", ] + # TODO: remove exception for Python 3.13 cloud run adds a runtime for it (internal issue 333742751) + # TODO: remove exception for Python 3.13 if nbmake adds support for + # sys.exit(0) or pytest.skip(...). + # See: https://github.com/treebeardtech/nbmake/issues/134 + if session.python == "3.13": + denylist.extend( + [ + "notebooks/getting_started/getting_started_bq_dataframes.ipynb", + "notebooks/remote_functions/remote_function_usecases.ipynb", + "notebooks/remote_functions/remote_function_vertex_claude_model.ipynb", + "notebooks/remote_functions/remote_function.ipynb", + ] + ) + # 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] @@ -757,7 +847,11 @@ def notebook(session: nox.Session): notebooks_reg = { "regionalized.ipynb": [ "asia-southeast1", + "eu", + "europe-west4", + "southamerica-west1", "us", + "us-central1", ] } notebooks_reg = { @@ -784,7 +878,6 @@ def notebook(session: nox.Session): "python", CURRENT_DIRECTORY / "scripts" / "notebooks_fill_params.py", *notebooks, - env=ENV_OVERRIDES, ) processes = [] @@ -797,7 +890,8 @@ def notebook(session: nox.Session): ) if multi_process_mode: process = multiprocessing.Process( - target=session.run, args=args, kwargs={"env": ENV_OVERRIDES} + target=session.run, + args=args, ) process.start() processes.append(process) @@ -805,7 +899,7 @@ def notebook(session: nox.Session): # process to avoid potential race conditions。 time.sleep(1) else: - session.run(*args, env=ENV_OVERRIDES) + session.run(*args) for notebook, regions in notebooks_reg.items(): for region in regions: @@ -820,7 +914,6 @@ def notebook(session: nox.Session): process = multiprocessing.Process( target=session.run, args=region_args, - kwargs={"env": ENV_OVERRIDES}, ) process.start() processes.append(process) @@ -828,7 +921,7 @@ def notebook(session: nox.Session): # process to avoid potential race conditions。 time.sleep(1) else: - session.run(*region_args, env=ENV_OVERRIDES) + session.run(*region_args) for process in processes: process.join() @@ -845,7 +938,6 @@ def notebook(session: nox.Session): "scripts/run_and_publish_benchmark.py", "--notebook", "--publish-benchmarks=notebooks/", - env=ENV_OVERRIDES, ) @@ -909,7 +1001,6 @@ def benchmark(session: nox.Session): "scripts/run_and_publish_benchmark.py", f"--benchmark-path={benchmark}", f"--iterations={args.iterations}", - env=ENV_OVERRIDES, ) finally: session.run( @@ -918,11 +1009,10 @@ def benchmark(session: nox.Session): f"--publish-benchmarks={base_path}", f"--iterations={args.iterations}", f"--output-csv={args.output_csv}", - env=ENV_OVERRIDES, ) -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python="3.10") def release_dry_run(session): env = {} @@ -939,7 +1029,7 @@ def release_dry_run(session): @nox.session(python=DEFAULT_PYTHON_VERSION) def cleanup(session): """Clean up stale and/or temporary resources in the test project.""" - google_cloud_project = PROJECT_ID_OVERRIDE or os.getenv("GOOGLE_CLOUD_PROJECT") + google_cloud_project = os.getenv("GOOGLE_CLOUD_PROJECT") cleanup_options = [] if google_cloud_project: cleanup_options.append(f"--project-id={google_cloud_project}") @@ -961,132 +1051,3 @@ def cleanup(session): session.install("-e", ".") session.run("python", "scripts/manage_cloud_functions.py", *cleanup_options) - - -@nox.session(python=DEFAULT_PYTHON_VERSION) -@nox.parametrize( - "protobuf_implementation", - ["python", "upb"], -) -def core_deps_from_source(session, protobuf_implementation): - """Run all tests with core dependencies installed from source - rather than pulling the dependencies from PyPI. - """ - - # Install all dependencies - session.install("-e", ".") - - # Install dependencies for the unit test environment - unit_deps_all = UNIT_TEST_STANDARD_DEPENDENCIES + UNIT_TEST_EXTERNAL_DEPENDENCIES - session.install(*unit_deps_all) - - # Install dependencies for the system test environment - system_deps_all = ( - SYSTEM_TEST_STANDARD_DEPENDENCIES - + SYSTEM_TEST_EXTERNAL_DEPENDENCIES - + SYSTEM_TEST_EXTRAS - ) - session.install(*system_deps_all) - - # Because we test minimum dependency versions on the minimum Python - # version, the first version we test with in the unit tests sessions has a - # constraints file containing all dependencies and extras. - with open( - CURRENT_DIRECTORY / "testing" / "constraints-3.10.txt", - encoding="utf-8", - ) as constraints_file: - constraints_text = constraints_file.read() - - # Ignore leading whitespace and comment lines. - # Fiona fails to build on GitHub CI because gdal-config is missing and no Python 3.14 wheels are available. - constraints_deps = [ - match.group(1) - for match in re.finditer( - r"^\s*(\S+)(?===\S+)", constraints_text, flags=re.MULTILINE - ) - if match.group(1) != "fiona" - ] - - # Install dependencies specified in `testing/constraints-X.txt`. - session.install(*constraints_deps) - - # TODO(https://github.com/googleapis/gapic-generator-python/issues/2358): `grpcio` and - # `grpcio-status` should be added to the list below so that they are installed from source, - # rather than PyPI. - # TODO(https://github.com/googleapis/gapic-generator-python/issues/2357): `protobuf` should be - # added to the list below so that it is installed from source, rather than PyPI - # Note: If a dependency is added to the `core_dependencies_from_source` list, - # the `prerel_deps` list in the `prerelease_deps` nox session should also be updated. - core_dependencies_from_source = [ - "googleapis-common-protos @ git+https://github.com/googleapis/google-cloud-python#egg=googleapis-common-protos&subdirectory=packages/googleapis-common-protos", - "google-api-core @ git+https://github.com/googleapis/google-cloud-python#egg=google-api-core&subdirectory=packages/google-api-core", - "google-auth @ git+https://github.com/googleapis/google-cloud-python#egg=google-auth&subdirectory=packages/google-auth", - "grpc-google-iam-v1 @ git+https://github.com/googleapis/google-cloud-python#egg=grpc-google-iam-v1&subdirectory=packages/grpc-google-iam-v1", - "proto-plus @ git+https://github.com/googleapis/google-cloud-python#egg=proto-plus&subdirectory=packages/proto-plus", - ] - - for dep in core_dependencies_from_source: - session.install(dep, "--no-deps", "--ignore-installed") - print(f"Installed {dep}") - - session.run( - "py.test", - "tests/unit", - env={ - "PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION": protobuf_implementation, - }, - ) - - -@nox.session(python=ALL_PYTHON[-1]) -def prerelease_deps(session): - """Run all tests with prerelease versions of dependencies installed.""" - # TODO(https://github.com/googleapis/google-cloud-python/issues/16014): - # Add prerelease deps tests - unit_prerelease(session) - system_prerelease(session) - - -# NOTE: this is based on mypy session that came directly from the bigframes split repo -# the split repo used 3.10, the monorepo uses 3.14 -@nox.session(python="3.14") -def mypy(session): - """Run type checks with mypy.""" - # Editable mode is not compatible with mypy when there are multiple - # package directories. See: - # https://github.com/python/mypy/issues/10564#issuecomment-851687749 - session.install("--no-cache-dir", ".") - - # Just install the dependencies' type info directly, since "mypy --install-types" - # might require an additional pass. - deps = ( - set( - [ - 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) - | set(UNIT_TEST_STANDARD_DEPENDENCIES) - ) - - session.install(*deps) - shutil.rmtree(".mypy_cache", ignore_errors=True) - session.run( - "mypy", - "bigframes", - os.path.join("tests", "system"), - os.path.join("tests", "unit"), - "--check-untyped-defs", - "--explicit-package-bases", - '--exclude="^third_party"', - ) diff --git a/owlbot.py b/owlbot.py new file mode 100644 index 00000000000..b9145d43675 --- /dev/null +++ b/owlbot.py @@ -0,0 +1,143 @@ +# 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=[ + # Need a combined LICENSE for all vendored packages. + "LICENSE", + # 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", + ".github/release-please.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", +) + +# Include JavaScript files for display widgets +assert 1 == s.replace( # MANIFEST.in + ["MANIFEST.in"], + re.escape("recursive-include bigframes *.json *.proto py.typed"), + "recursive-include bigframes *.json *.proto *.js py.typed", +) + +# Include JavaScript and CSS files for display widgets +assert 1 == s.replace( # MANIFEST.in + ["MANIFEST.in"], + re.escape("recursive-include bigframes *.json *.proto *.js py.typed"), + "recursive-include bigframes *.json *.proto *.js *.css py.typed", +) + +# 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 deleted file mode 100644 index 064bdaf362d..00000000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "python-bigquery-dataframes", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/pyproject.toml b/pyproject.toml index e7d9c326a93..fed528d4a7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,3 @@ [build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" - -[tool.ruff.lint.isort] -known-first-party = ["bigframes"] diff --git a/pytest.ini b/pytest.ini index 512fd81a7e6..75b69ce435c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] doctest_optionflags = NORMALIZE_WHITESPACE +filterwarnings = + ignore::pandas.errors.SettingWithCopyWarning addopts = "--import-mode=importlib" diff --git a/release-procedure.md b/release-procedure.md deleted file mode 100644 index aeb87862fe6..00000000000 --- a/release-procedure.md +++ /dev/null @@ -1,51 +0,0 @@ -# BigQuery DataFrames (bigframes) release procedure - -*(Note: bigframes releases are marked with `skip_release: true` in `librarian.yaml` and must be kicked off manually using legacylibrarian.)* - -## Setup (First Time Only) - -* Install `legacylibrarian`: - - go install github.com/googleapis/librarian/cmd/legacylibrarian@latest - -* Authenticate with GitHub CLI: - - gh auth login - -## Release Steps - -* Obtain GitHub token: - - export LIBRARIAN_GITHUB_TOKEN=$(gh auth token) - -* Stash changes (repo must be clean): - - git stash -u - -* Fetch and checkout base: - - git fetch origin main - git fetch origin --tags - git checkout origin/main - -* Check image updates: - - legacylibrarian update-image --push - -* Create release PR: - - # Option A: Push directly - legacylibrarian release stage --repo=https://github.com/googleapis/google-cloud-python --library=bigframes --library-version=X.X.X --push - - # Option B: Manual edit first (omit --push, edit files in /tmp/librarian-*, commit/push from there) - legacylibrarian release stage --repo=https://github.com/googleapis/google-cloud-python --library=bigframes --library-version=X.X.X - # In /tmp repository: - git commit -a -m "chore: create release" --no-verify # keep librarian config pristine - git push origin HEAD - gh pr create --fill --label "release:pending" - -* Post-release restore: - - # Move back any stashed/relocated files (like .vscode) - git checkout main - git stash pop diff --git a/renovate.json b/renovate.json new file mode 100644 index 00000000000..c7875c469bd --- /dev/null +++ b/renovate.json @@ -0,0 +1,12 @@ +{ + "extends": [ + "config:base", + "group:all", + ":preserveSemverRanges", + ":disableDependencyDashboard" + ], + "ignorePaths": [".pre-commit-config.yaml", ".kokoro/requirements.txt", "setup.py", ".github/workflows/unittest.yml"], + "pip_requirements": { + "fileMatch": ["requirements-test.txt", "samples/[\\S/]*constraints.txt", "samples/[\\S/]*constraints-test.txt"] + } +} diff --git a/samples/polars/noxfile.py b/samples/polars/noxfile.py index 63e742993f9..494639d2fa5 100644 --- a/samples/polars/noxfile.py +++ b/samples/polars/noxfile.py @@ -86,8 +86,9 @@ def get_pytest_env_vars() -> Dict[str, str]: return ret +# DO NOT EDIT - automatically generated. # All versions used to test samples. -ALL_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] +ALL_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] # Any default versions that should be ignored. IGNORED_VERSIONS = TEST_CONFIG["ignored_versions"] diff --git a/samples/polars/requirements-test.txt b/samples/polars/requirements-test.txt index ce5e1b9e702..cbac5e3f126 100644 --- a/samples/polars/requirements-test.txt +++ b/samples/polars/requirements-test.txt @@ -1,3 +1,3 @@ # samples/snippets should be runnable with no "extras" -google-cloud-testutils==1.8.0 -pytest==9.0.3 +google-cloud-testutils==1.4.0 +pytest==8.3.2 diff --git a/samples/polars/requirements.txt b/samples/polars/requirements.txt index 218e674b9ed..a1d8fbcdac4 100644 --- a/samples/polars/requirements.txt +++ b/samples/polars/requirements.txt @@ -1,3 +1,3 @@ -bigframes==2.39.0 -polars==1.40.1 -pyarrow==24.0.0 +bigframes==1.11.1 +polars==1.3.0 +pyarrow==15.0.0 diff --git a/samples/snippets/conftest.py b/samples/snippets/conftest.py index e19cfbceb46..81595967ec9 100644 --- a/samples/snippets/conftest.py +++ b/samples/snippets/conftest.py @@ -63,17 +63,6 @@ def gcs_bucket(storage_client: storage.Client) -> Generator[str, None, None]: 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. diff --git a/samples/snippets/multimodal_test.py b/samples/snippets/multimodal_test.py index ce04d511346..1ea6a3f0a6d 100644 --- a/samples/snippets/multimodal_test.py +++ b/samples/snippets/multimodal_test.py @@ -13,9 +13,9 @@ # limitations under the License. -def test_multimodal_dataframe(gcs_bucket_snippets: str) -> None: +def test_multimodal_dataframe(gcs_bucket: 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}" + dst_bucket = f"gs://{gcs_bucket}" # [START bigquery_dataframes_multimodal_dataframe_create] import bigframes @@ -123,404 +123,3 @@ def test_multimodal_dataframe(gcs_bucket_snippets: str) -> None: assert answer_alt is not None assert embeddings is not None assert chunked is not None - - -def test_multimodal_example(gcs_bucket_snippets: str) -> None: - BUCKET = gcs_bucket_snippets - # [START bigquery_dataframes_multimodal_load] - import bigframes.bigquery as bbq - import bigframes.pandas as bpd - - bbq.load_data( - "cymbal_pets.products", - write_disposition="OVERWRITE", - from_files_options={ - "format": "avro", - "uris": [ - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/tables/products/products_*.avro" - ], - }, - ) - # [END bigquery_dataframes_multimodal_load] - - # [START bigquery_dataframes_multimodal_create_images] - bbq.create_external_table( - "cymbal_pets.product_images", - replace=True, - connection_name="us.cymbal_conn", - options={ - "object_metadata": "SIMPLE", - "uris": [ - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*.png" - ], - }, - ) - # [END bigquery_dataframes_multimodal_create_images] - - # [START bigquery_dataframes_multimodal_create_manuals] - bbq.create_external_table( - "cymbal_pets.product_manuals", - replace=True, - connection_name="us.cymbal_conn", - options={ - "object_metadata": "SIMPLE", - "uris": [ - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*.pdf" - ], - }, - ) - # [END bigquery_dataframes_multimodal_create_manuals] - - # [START bigquery_dataframes_multimodal_create_gemini] - gemini_model = bbq.ml.create_model( - "cymbal_pets.gemini", - replace=True, - connection_name="us.cymbal_conn", - options={"endpoint": "gemini-2.5-flash"}, - ) - # [END bigquery_dataframes_multimodal_create_gemini] - - # [START bigquery_dataframes_multimodal_create_embedding] - embedding_model = bbq.ml.create_model( - "cymbal_pets.embedding_model", - replace=True, - connection_name="us.cymbal_conn", - options={"endpoint": "multimodalembedding@001"}, - ) - # [END bigquery_dataframes_multimodal_create_embedding] - - # [START bigquery_dataframes_multimodal_create_df_products_mm] - df_images = bpd.read_gbq("SELECT * FROM cymbal_pets.product_images") - df_products = bpd.read_gbq("cymbal_pets.products") - - df_products_mm = df_images.merge(df_products, on="uri").drop(columns="uri") - df_products_mm = df_products_mm.rename(columns={"ref": "image"}) - # [END bigquery_dataframes_multimodal_create_df_products_mm] - - # [START bigquery_dataframes_multimodal_show_df_products_mm] - df_products_mm[["product_name", "image"]] - # [END bigquery_dataframes_multimodal_show_df_products_mm] - - # [START bigquery_dataframes_multimodal_image_description] - df_products_mm["url"] = bbq.obj.get_access_url( - df_products_mm["image"], "R" - ).to_frame() - df_products_mm["prompt0"] = "Can you describe the following image?" - - df_products_mm["prompt"] = bbq.struct(df_products_mm[["prompt0", "url"]]) - df_products_mm = bbq.ai.generate_table( - gemini_model, df_products_mm, output_schema={"image_description": "STRING"} - ) - - df_products_mm = df_products_mm[ - [ - "product_id", - "product_name", - "brand", - "category", - "subcategory", - "animal_type", - "search_keywords", - "price", - "description", - "inventory_level", - "supplier_id", - "average_rating", - "image", - "image_description", - ] - ] - # [END bigquery_dataframes_multimodal_image_description] - - # [START bigquery_dataframes_multimodal_generate_animal_type] - df_prompt = bbq.obj.get_access_url(df_products_mm["image"], "R").to_frame() - df_prompt[ - "prompt0" - ] = "For the image of a pet product, concisely generate the following metadata: 1) animal_type and 2) 5 SEO search keywords, and 3) product subcategory." - - df_products_mm["prompt"] = bbq.struct(df_prompt[["prompt0", "image"]]) - - df_products_mm = df_products_mm.drop( - columns=["animal_type", "search_keywords", "subcategory"] - ) - df_products_mm = bbq.ai.generate_table( - gemini_model, - df_products_mm, - output_schema="animal_type STRING, search_keywords ARRAY, subcategory STRING", - ) - # [END bigquery_dataframes_multimodal_generate_animal_type] - - # [START bigquery_dataframes_multimodal_show_animal_type] - df_products_mm[ - [ - "product_name", - "image_description", - "animal_type", - "search_keywords", - "subcategory", - ] - ] - # [END bigquery_dataframes_multimodal_show_animal_type] - - # [START bigquery_dataframes_multimodal_brand_description] - df_agg = df_products_mm[ - ["image", "description", "category", "subcategory", "brand"] - ] - df_agg["image"] = bbq.obj.get_access_url(df_products_mm["image"], "R") - df_agg = bbq.array_agg(df_agg.groupby(by=["brand"])) - - df_agg["cnt"] = bbq.array_length(df_agg["image"]) - - df_prompt = df_agg[["image", "description", "category", "subcategory"]] - df_prompt[ - "prompt0" - ] = "Use the images and text to give one concise brand description for a website brand page. Return the description only. " - - df_agg["prompt"] = bbq.struct( - df_prompt[["prompt0", "image", "description", "category", "subcategory"]] - ) - - df_agg = df_agg.reset_index() - - df_agg = bbq.ai.generate_table( - gemini_model, df_agg, output_schema={"brand_description": "STRING"} - ) - df_agg[["brand", "brand_description", "cnt"]] - # [END bigquery_dataframes_multimodal_brand_description] - - # [START bigquery_dataframes_multimodal_define_to_grayscale] - @bpd.udf( - dataset="cymbal_pets", - name="to_grayscale", - packages=["numpy", "opencv-python"], - bigquery_connection="us.cymbal_conn", - max_batching_rows=1, - ) - def to_grayscale(src_ref: str, dst_ref: str) -> str: - import json - from urllib.request import Request, urlopen - - import cv2 as cv - import numpy as np - - src_json = json.loads(src_ref) - srcUrl = src_json["access_urls"]["read_url"] - - dst_json = json.loads(dst_ref) - dstUrl = dst_json["access_urls"]["write_url"] - - req = urlopen(srcUrl) - arr = np.asarray(bytearray(req.read()), dtype=np.uint8) - img = cv.imdecode(arr, -1) # 'Load it as it is' - - # Convert the image to grayscale - gray_image = cv.cvtColor(img, cv.COLOR_BGR2GRAY) - - # Send POST request to the URL - _, img_encoded = cv.imencode(".png", gray_image) - - req = Request( - url=dstUrl, - data=img_encoded.tobytes(), - method="PUT", - headers={ - "Content-Type": "image/png", - }, - ) - with urlopen(req): - pass - return dst_ref - - # [END bigquery_dataframes_multimodal_define_to_grayscale] - - # [START bigquery_dataframes_multimodal_apply_to_grayscale] - df_grayscale = df_products_mm[["product_id", "product_name", "image"]] - df_grayscale[ - "gray_image_uri" - ] = f"gs://{BUCKET}/cymbal-pets-images/grayscale/" + df_grayscale[ - "image" - ].struct.field( - "uri" - ).str.extract( - r"([^/]+)$" - ) - - df_grayscale["gray_image"] = bbq.obj.make_ref( - df_grayscale["gray_image_uri"], "us.cymbal_conn" - ) - - df_grayscale["image_url"] = bbq.to_json_string( - bbq.obj.get_access_url(df_grayscale["image"], "r") - ) - df_grayscale["gray_image_url"] = bbq.to_json_string( - bbq.obj.get_access_url(df_grayscale["gray_image"], "rw") - ) - - df_grayscale[["image_url", "gray_image_url"]].apply(to_grayscale, axis=1) - # [END bigquery_dataframes_multimodal_apply_to_grayscale] - - # [START bigquery_dataframes_multimodal_define_chunk_pdf] - @bpd.udf( - dataset="cymbal_pets", - name="chunk_pdf", - packages=["pypdf"], - bigquery_connection="us.cymbal_conn", - max_batching_rows=1, - ) - def chunk_pdf(src_ref: str, chunk_size: int, overlap_size: int) -> list[str]: - import io - import json - from urllib.request import urlopen - - from pypdf import PdfReader # type: ignore - - src_json = json.loads(src_ref) - srcUrl = src_json["access_urls"]["read_url"] - - req = urlopen(srcUrl) - pdf_file = io.BytesIO(bytearray(req.read())) - 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) - - return all_text_chunks - - # [END bigquery_dataframes_multimodal_define_chunk_pdf] - - # [START bigquery_dataframes_multimodal_apply_chunk_pdf] - df_manuals = bpd.read_gbq("SELECT * FROM cymbal_pets.product_manuals") - df_manuals["url"] = bbq.to_json_string( - bbq.obj.get_access_url(df_manuals["ref"], "R") - ) - - df_manuals["chunk_size"] = 1000 - df_manuals["overlap_size"] = 100 - - df_manuals["chunked"] = df_manuals[["url", "chunk_size", "overlap_size"]].apply( - chunk_pdf, axis=1 - ) - # [END bigquery_dataframes_multimodal_apply_chunk_pdf] - - # [START bigquery_dataframes_multimodal_analyze_pdf] - df_chunked = df_manuals["chunked"].explode().to_frame() - df_chunked[ - "prompt0" - ] = "Can you summarize the product manual as bullet points? Highlight the legal clauses" - - df_chunked["prompt"] = bbq.struct(df_chunked[["prompt0", "chunked"]]) - - result = bbq.ai.generate_text(gemini_model, df_chunked["prompt"]) - result - # [END bigquery_dataframes_multimodal_analyze_pdf] - - # [START bigquery_dataframes_multimodal_create_embed_table] - df_products_mm["content"] = bbq.obj.get_access_url(df_products_mm["image"], "R") - df_embed = bbq.ai.generate_embedding( - embedding_model, df_products_mm[["content", "product_id"]] - ) - - df_embed.to_gbq("cymbal_pets.products_embedding", if_exists="replace") - # [END bigquery_dataframes_multimodal_create_embed_table] - - # [START bigquery_dataframes_multimodal_vector_search] - df_image = bpd.DataFrame( - { - "uri": [ - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/cozy-naps-cat-scratching-post-with-condo.png" - ] - } - ).cache() - df_image["image"] = bbq.obj.make_ref(df_image["uri"], "us.cymbal_conn") - df_search = bbq.ai.generate_embedding( - embedding_model, - bbq.obj.get_access_url(bbq.obj.fetch_metadata(df_image["image"]), "R"), - ) - - search_result = bbq.vector_search( - "cymbal_pets.products_embedding", "embedding", df_search["embedding"] - ) - search_result - # [END bigquery_dataframes_multimodal_vector_search] - - # [START bigquery_dataframes_create_external_table_all] - bbq.create_external_table( - "cymbal_pets.product_manuals_all", - replace=True, - connection_name="us.cymbal_conn", - options={ - "object_metadata": "SIMPLE", - "uris": [ - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/documents/*.pdf", - "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/document_chunks/*.pdf", - ], - }, - ) - # [END bigquery_dataframes_create_external_table_all] - - # [START bigquery_dataframes_create_manual_to_chunks] - df1 = bpd.read_gbq("SELECT * FROM cymbal_pets.product_manuals_all").sort_values( - "uri" - ) - df2 = df1.copy() - df1["name"] = df1["uri"].str.extract(r".*/([^.]*).[^/]+") - df2["name"] = df2["uri"].str.extract(r".*/([^.]*)_page[0-9]+.[^/]+") - df_manuals_all = df1.merge(df2, on="name") - df_manuals_agg = ( - bbq.array_agg(df_manuals_all[["ref_x", "uri_x"]].groupby("uri_x"))["ref_x"] - .str[0] - .to_frame() - ) - df_manuals_agg["chunks"] = bbq.array_agg( - df_manuals_all[["ref_y", "uri_x"]].groupby("uri_x") - )["ref_y"] - # [END bigquery_dataframes_create_manual_to_chunks] - - # [START bigquery_dataframes_show_manual_to_chunks] - df_manuals_agg - # [END bigquery_dataframes_show_manual_to_chunks] - - # [START bigquery_dataframes_generate_pages_summary] - df_manuals_agg["chunks_url"] = bbq.array_agg( - bbq.obj.get_access_url(df_manuals_agg.explode("chunks")["chunks"], "R").groupby( - "uri_x" - ) - ) - df_manuals_agg[ - "prompt0" - ] = "Can you provide a page by page summary for the first 3 pages of the attached manual? Only write one line for each page. The pages are provided in serial order" - df_manuals_agg["prompt"] = bbq.struct(df_manuals_agg[["prompt0", "chunks_url"]]) - - result = bbq.ai.generate_text(gemini_model, df_manuals_agg["prompt"])["result"] - result - # [END bigquery_dataframes_generate_pages_summary] - - # [START bigquery_dataframes_generate_each_page_summary] - result = bbq.ai.generate_table( - gemini_model, - df_manuals_agg["prompt"], - output_schema={ - "page1_summary": "STRING", - "page2_summary": "STRING", - "page3_summary": "STRING", - }, - )[["page1_summary", "page2_summary", "page3_summary"]] - result - # [END bigquery_dataframes_generate_each_page_summary] diff --git a/samples/snippets/noxfile.py b/samples/snippets/noxfile.py index 63e742993f9..494639d2fa5 100644 --- a/samples/snippets/noxfile.py +++ b/samples/snippets/noxfile.py @@ -86,8 +86,9 @@ def get_pytest_env_vars() -> Dict[str, str]: return ret +# DO NOT EDIT - automatically generated. # All versions used to test samples. -ALL_VERSIONS = ["3.10", "3.11", "3.12", "3.13", "3.14"] +ALL_VERSIONS = ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] # Any default versions that should be ignored. IGNORED_VERSIONS = TEST_CONFIG["ignored_versions"] diff --git a/samples/snippets/st_regionstats_test.py b/samples/snippets/st_regionstats_test.py deleted file mode 100644 index f0f4963a824..00000000000 --- a/samples/snippets/st_regionstats_test.py +++ /dev/null @@ -1,80 +0,0 @@ -# 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/scripts/bigquery_generator/__init__.py b/scripts/bigquery_generator/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/scripts/bigquery_generator/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/scripts/bigquery_generator/constants.py b/scripts/bigquery_generator/constants.py deleted file mode 100644 index 78c3fc60c2b..00000000000 --- a/scripts/bigquery_generator/constants.py +++ /dev/null @@ -1,142 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 - -SCRIPTS_DIRECTORY = pathlib.Path(__file__).parent.parent.absolute() -PACKAGE_ROOT = SCRIPTS_DIRECTORY.parent -CODE_ROOT = PACKAGE_ROOT / "bigframes" -SCRIPT_PATH_RELATIVE = ( - pathlib.Path(__file__).relative_to(PACKAGE_ROOT).parent.parent - / "generate_bigframes_bigquery.py" -) - - -# Directory containing the YAML files -DATA_DIR = SCRIPTS_DIRECTORY / "data" / "sql-functions" -# Directory where the generated Python files will be placed -OUTPUT_DIR = CODE_ROOT / "operations" / "googlesql" -# Directory where the generated test files will be placed -TEST_OUTPUT_DIR = PACKAGE_ROOT / "tests" / "unit" / "bigquery" / "generated" - -PYTHON_BUILTINS = { - "abs", - "all", - "any", - "ascii", - "bin", - "bool", - "breakpoint", - "bytearray", - "bytes", - "callable", - "chr", - "classmethod", - "compile", - "complex", - "delattr", - "dict", - "dir", - "divmod", - "enumerate", - "eval", - "exec", - "filter", - "float", - "format", - "frozenset", - "getattr", - "globals", - "hasattr", - "hash", - "help", - "hex", - "id", - "input", - "int", - "isinstance", - "issubclass", - "iter", - "len", - "list", - "locals", - "map", - "max", - "memoryview", - "min", - "next", - "object", - "oct", - "open", - "ord", - "pow", - "print", - "property", - "range", - "repr", - "reversed", - "round", - "set", - "setattr", - "slice", - "sorted", - "staticmethod", - "str", - "sum", - "super", - "tuple", - "type", - "vars", - "zip", -} - -DTYPE_MAP = { - "binary": "dtypes.BYTES_DTYPE", - "string": "dtypes.STRING_DTYPE", - "int64": "dtypes.INT_DTYPE", - "i64": "dtypes.INT_DTYPE", - "float64": "dtypes.FLOAT_DTYPE", - "fp64": "dtypes.FLOAT_DTYPE", - "bool": "dtypes.BOOL_DTYPE", - "boolean": "dtypes.BOOL_DTYPE", - "geography": "dtypes.GEO_DTYPE", - "json": "dtypes.JSON_DTYPE", - "date": "dtypes.DATE_DTYPE", - "time": "dtypes.TIME_DTYPE", - "datetime": "dtypes.DATETIME_DTYPE", - "timestamp": "dtypes.TIMESTAMP_DTYPE", - "decimal<38,9>": "dtypes.NUMERIC_DTYPE", - "decimal<76,38>": "dtypes.BIGNUMERIC_DTYPE", -} - -PY_TYPE_MAP = { - "binary": "bytes", - "string": "str", - "int64": "int", - "i64": "int", - "float64": "float", - "fp64": "float", - "bool": "bool", - "boolean": "bool", - "geography": "Any", - "json": "Any", - "date": "datetime.date", - "time": "datetime.time", - "datetime": "datetime.datetime", - "timestamp": "datetime.datetime", - "struct": "dict", - "decimal<38,9>": "decimal.Decimal", - "decimal<76,38>": "decimal.Decimal", - "interval_day": "datetime.timedelta", -} diff --git a/scripts/bigquery_generator/data_models.py b/scripts/bigquery_generator/data_models.py deleted file mode 100644 index e0cffd566a0..00000000000 --- a/scripts/bigquery_generator/data_models.py +++ /dev/null @@ -1,237 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -"""Data models for BigQuery code generator. - -`BQ*` models the Substrait YAML extension structure of BigQuery SQL functions, -while `BigFrames*` models the Jinja template outputs. - -BQ* Class Relations: -==================== -+-------------------+ -| BQModule | -+-------------------+ - | - | functions: list[BQFunc] - v -+-------------------+ -| BQFunc | -+-------------------+ - | - | impls: list[BQFuncImpl] - v -+-------------------+ -| BQFuncImpl | -+-------------------+ - | - | args: list[BQFuncArg] - v -+-------------------+ -| BQFuncArg | -+-------------------+ - -BigFrames* Class Relations: -================================= - +--------------------------------+ - | Accessor |<---+ children: list[Accessor] - +--------------------------------+----+ (nested namespace hierarchy) - | - | functions: list[BigFramesFunc] - v - +--------------------------------+ - | BigFramesFunc | - +--------------------------------+ - | - | args: list[BigFramesFuncArg] - v - +--------------------------------+ - | BigFramesFuncArg | - +--------------------------------+ - - ---------------------------------- - - +--------------------------------+ - | BigFramesOp | (Standalone data model for op defs) - +--------------------------------+ -""" - -from __future__ import annotations - -import dataclasses -import pathlib - -from . import constants - - -@dataclasses.dataclass(frozen=True) -class BQFuncArg: - """ - Represents an argument of a SQL function loaded from a yaml file. - """ - - name: str - value: str # The type of the arg - optional: bool - keyword_only: bool - - -@dataclasses.dataclass(frozen=True) -class BQFuncImpl: - """ - Represents an implementation (i.e. signature) for some SQL function loaded - from a yaml file. - """ - - args: tuple[BQFuncArg, ...] - return_type: str - - @property - def requires_generic_types(self) -> bool: - if "any1" in self.return_type: - return True - - return any("any1" in arg.value for arg in self.args) - - -@dataclasses.dataclass(frozen=True) -class BQFunc: - """ - Represents a SQL function loaded from a yaml file. - """ - - name: str - description: str - impls: tuple[BQFuncImpl, ...] - series_accessor_arg: str | None - - @property - def op_base_name(self) -> str: - return self.name.split(".")[-1] - - -@dataclasses.dataclass(frozen=True) -class BQModule: - """ - Represents the data loaded from a yaml file with SQL functions info. - """ - - yaml_file: pathlib.Path - functions: tuple[BQFunc, ...] - - @property - def module_path(self) -> pathlib.Path: - return self.yaml_file.relative_to(constants.DATA_DIR).with_suffix("") - - @property - def namespace(self) -> tuple[str, ...]: - parts = self.module_path.parts - if "global_namespace" in parts: - return tuple() - return parts - - @property - def is_global(self) -> bool: - return "global_namespace" in self.module_path.parts - - -@dataclasses.dataclass(frozen=True) -class BigFramesOp: - """ - Represents a BigFrames GoogleScalarOp impl to be defined in the code base. - """ - - internal_name: str - sql_name: str - arg_specs: str - signature: str - signature_definition: str | None - - -@dataclasses.dataclass(frozen=True) -class BigFramesFuncArg: - """ - Represents an argument of a BigFrames BigQuery function to be defined in the code base. - """ - - name: str - types: frozenset[str] - optional: bool - keyword_only: bool - - @property - def type_hint(self) -> str: - types = [constants.PY_TYPE_MAP.get(t, "Any") for t in sorted(self.types)] + [ - "Literal[sentinels.Sentinel.ARGUMENT_DEFAULT]" - ] - - if len(types) > 1: - return "Union[" + ", ".join(sorted(set(types))) + "]" - - return types[0] - - @property - def default(self) -> str | None: - if self.optional: - return "sentinels.Sentinel.ARGUMENT_DEFAULT" - return None - - -@dataclasses.dataclass -class BigFramesFuncArgBuilder: - name: str - types: set[str] - optional: bool - keyword_only: bool - - def build(self) -> BigFramesFuncArg: - return BigFramesFuncArg( - name=self.name, - types=frozenset(self.types), - optional=self.optional, - keyword_only=self.keyword_only, - ) - - -@dataclasses.dataclass(frozen=True) -class BigFramesFunc: - """ - Represents a BigFrames BigQuery function to be defined in the codebase. - """ - - name: str - op_name: str - description: str - args: tuple[BigFramesFuncArg, ...] - series_accessor_arg: str | None - import_module: str | None = None - - -@dataclasses.dataclass -class Accessor: - """ - Represents the accessor extensions to be defined for pandas and BigFrames. - It consists of multiple functions bundled under the different namespaces. - - This class is designed to be mutable because it has a recursive data structure. - Mutability makes it easier to build the data structure trees from the top. - """ - - class_name: str - bigframes_class_name: str - pandas_class_name: str - is_root: bool - description: str - children: list[Accessor] - functions: list[BigFramesFunc] - prop_name: str | None = None diff --git a/scripts/bigquery_generator/file_generator.py b/scripts/bigquery_generator/file_generator.py deleted file mode 100644 index 3109afc3f8b..00000000000 --- a/scripts/bigquery_generator/file_generator.py +++ /dev/null @@ -1,132 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 subprocess -import sys -from typing import Sequence - -from . import constants, data_models, template_renderer - - -def _ensure_init_py(directory: pathlib.Path, limit_dir: pathlib.Path) -> None: - """Ensures __init__.py exists in the directory and its parents up to limit_dir.""" - curr = directory - while curr != limit_dir and curr != curr.parent: - init_file = curr / "__init__.py" - if not init_file.exists(): - print(f" Creating {init_file}") - with open(init_file, "w", encoding="utf-8") as f: - f.write(template_renderer.render_license()) - curr = curr.parent - - -def _write_file( - content: str, output_file: pathlib.Path, limit_dir: pathlib.Path -) -> None: - output_file.parent.mkdir(parents=True, exist_ok=True) - _ensure_init_py(output_file.parent, limit_dir) - - with open(output_file, "w", encoding="utf-8") as f: - f.write(content) - print(f" Generated {output_file}") - - -def _run_ruff() -> None: - targets = [ - constants.OUTPUT_DIR, - constants.TEST_OUTPUT_DIR, - constants.CODE_ROOT / "extensions", - ] - ruff_common_args = [ - "--target-version=py310", - "--line-length=88", - ] - - ruff_check_args = [ - "check", - "--select", - "I,F", - "--fix", - ] + ruff_common_args - subprocess.run( - [sys.executable, "-m", "ruff"] + ruff_check_args + targets, - check=True, - ) - - ruff_format_args = [ - "format", - ] + ruff_common_args - subprocess.run( - [sys.executable, "-m", "ruff"] + ruff_format_args + targets, - check=True, - ) - - -def _generate_op_defs(bq_module: data_models.BQModule) -> None: - if not bq_module.functions: - # If there are no function definitions, do not generate file without Python code. - return - - content = template_renderer.render_operation(bq_module) - output_file = constants.OUTPUT_DIR.joinpath(bq_module.module_path).with_suffix( - ".py" - ) - - _write_file(content, output_file, constants.OUTPUT_DIR.parent) - - -def _generate_tests(bq_module: data_models.BQModule) -> None: - if not bq_module.functions: - # If there are no function definitions, do not generate file without Python code. - return - - content = template_renderer.render_tests(bq_module) - output_file = constants.TEST_OUTPUT_DIR.joinpath( - bq_module.module_path.with_name(f"test_{bq_module.module_path.name}") - ).with_suffix(".py") - - _write_file(content, output_file, constants.TEST_OUTPUT_DIR.parent) - - -def _generate_accesor(bq_modules: Sequence[data_models.BQModule]) -> None: - (core_content, pd_content, bf_content) = template_renderer.render_accessor( - bq_modules - ) - - core_output_file = ( - constants.CODE_ROOT / "extensions" / "core" / "series_accessor.py" - ) - _write_file(core_content, core_output_file, constants.CODE_ROOT) - - pd_output_file = ( - constants.CODE_ROOT / "extensions" / "pandas" / "series_accessor.py" - ) - _write_file(pd_content, pd_output_file, constants.CODE_ROOT) - - bf_output_file = ( - constants.CODE_ROOT / "extensions" / "bigframes" / "series_accessor.py" - ) - _write_file(bf_content, bf_output_file, constants.CODE_ROOT) - - -def generate(bq_modules: Sequence[data_models.BQModule]) -> None: - for bq_module in bq_modules: - _generate_op_defs(bq_module) - _generate_tests(bq_module) - - _generate_accesor(bq_modules) - - # Ruff format - _run_ruff() diff --git a/scripts/bigquery_generator/template_renderer.py b/scripts/bigquery_generator/template_renderer.py deleted file mode 100644 index a4e8461286d..00000000000 --- a/scripts/bigquery_generator/template_renderer.py +++ /dev/null @@ -1,334 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - - -""" -Renders jinja template with module data parsed from yaml. -""" - -from typing import Sequence - -import jinja2 - -from . import constants, data_models - - -def _load_templates() -> dict[str, jinja2.Template]: - env = jinja2.Environment( - loader=jinja2.FileSystemLoader(constants.SCRIPTS_DIRECTORY / "templates"), - trim_blocks=True, - lstrip_blocks=True, - ) - return { - "operation": env.get_template("operation.py.j2"), - "test_operation": env.get_template("test_operation.py.j2"), - "license": env.get_template("license.py.j2"), - "signature_def": env.get_template("signature_def.py.j2"), - "core_series_accessor": env.get_template("core_series_accessor.py.j2"), - "bigframes_series_accessor": env.get_template( - "bigframes_series_accessor.py.j2" - ), - "pandas_series_accessor": env.get_template("pandas_series_accessor.py.j2"), - } - - -TEMPLATES: dict[str, jinja2.Template] = _load_templates() - - -def _unwrap_list_type(yaml_type: str) -> str | None: - if yaml_type.startswith("list<") and yaml_type.endswith(">"): - return yaml_type[5:-1] - return None - - -def _try_get_concrete_type_expr(yaml_type: str) -> str | None: - if yaml_type in constants.DTYPE_MAP: - return constants.DTYPE_MAP[yaml_type] - inner = _unwrap_list_type(yaml_type) - if inner and inner in constants.DTYPE_MAP: - # TODO (b/540011825): Support recursive type parsing - return f"dtypes.list_type({constants.DTYPE_MAP[inner]})" - return None - - -def _get_concrete_type_expr(yaml_type: str) -> str: - expr = _try_get_concrete_type_expr(yaml_type) - if expr is None: - raise ValueError(f"Not a concrete type: {yaml_type}") - return expr - - -def _is_concrete_type(yaml_type: str) -> bool: - return _try_get_concrete_type_expr(yaml_type) is not None - - -def _validate_type(yaml_type: str) -> None: - if yaml_type in ("any1", "struct") or yaml_type in constants.DTYPE_MAP: - return - inner = _unwrap_list_type(yaml_type) - if inner is not None: - if inner == "any1" or inner in constants.DTYPE_MAP: - return - raise ValueError(f"Unsupported inner type: {inner}") - raise ValueError(f"Unsupported type: {yaml_type}") - - -def _validate_types(impls: Sequence[data_models.BQFuncImpl]) -> None: - for impl in impls: - for arg in impl.args: - _validate_type(arg.value) - _validate_type(impl.return_type) - - -def render_signature_def( - bq_func: data_models.BQFunc, -) -> tuple[str, str | None]: - """ - Returns the signature function name and its definition. - If the signature function can be inlined, the first return value is the lambda, - and the second value is None. - - Examples: - Inlined signature function: - ("lambda *args: dtypes.FLOAT64_DTYPE", None) - - Custom signature function definition: - ("_ABS_SIG", "def _ABS_SIG(*args): ...") - """ - return_types = {impl.return_type for impl in bq_func.impls} - # Optimization: if all impls return the same concrete type, - # inline the signature function as a lambda - if len(return_types) == 1: - ret_type = next(iter(return_types)) - if _is_concrete_type(ret_type): - sig_expr = f"lambda *args: {_get_concrete_type_expr(ret_type)}" - return sig_expr, None - - _validate_types(bq_func.impls) - - sig_func_name = f"_{bq_func.op_base_name.upper()}_SIG" - - max_args = max(len(impl.args) for impl in bq_func.impls) - - rendered = TEMPLATES["signature_def"].render( - func_name=sig_func_name, - max_args=max_args, - impls=bq_func.impls, - sql_name=bq_func.name, - dtype_map=constants.DTYPE_MAP, - ) - - return sig_func_name, rendered - - -def _get_bigframes_func_args( - bq_func: data_models.BQFunc, -) -> tuple[data_models.BigFramesFuncArg, ...]: - """ - Coalesces arguments from all the signatures of this function, - and return them in the order of appearance in the yaml file - """ - args_by_name: dict[str, data_models.BigFramesFuncArgBuilder] = {} - arg_order: list[str] = [] - arg_appearances: dict[str, int] = {} - for impl in bq_func.impls: - seen_in_impl = set() - for bq_func_arg in impl.args: - name = bq_func_arg.name - seen_in_impl.add(name) - if name not in args_by_name: - args_by_name[name] = data_models.BigFramesFuncArgBuilder( - name=name, - types=set(), - optional=bq_func_arg.optional, - keyword_only=bq_func_arg.keyword_only, - ) - arg_order.append(name) - else: - # If it was marked optional or keyword_only in any previous impl, keep it. - # Or if this signature marks it as optional/keyword_only, update it. - if bq_func_arg.optional: - args_by_name[name].optional = True - if bq_func_arg.keyword_only: - args_by_name[name].keyword_only = True - args_by_name[name].types.add(bq_func_arg.value) - for name in seen_in_impl: - arg_appearances[name] = arg_appearances.get(name, 0) + 1 - - # If an argument is not in all impls, it must be optional overall - num_impls = len(bq_func.impls) - for name, count in arg_appearances.items(): - if count < num_impls: - args_by_name[name].optional = True - - return tuple(args_by_name[name].build() for name in arg_order) - - -def _to_bigframes_op(bq_func: data_models.BQFunc) -> data_models.BigFramesOp: - arg_specs = [] - for bf_func_arg in _get_bigframes_func_args(bq_func): - spec = "googlesql.ArgSpec(" - if bf_func_arg.keyword_only: - spec += f'arg_name="{bf_func_arg.name}", ' - if bf_func_arg.optional: - spec += "optional=True, " - spec = spec.rstrip(", ") + ")" - arg_specs.append(spec) - - arg_specs_str = ", ".join(arg_specs) - if len(arg_specs) == 1: - arg_specs_str += "," - - (signature, signature_definition) = render_signature_def(bq_func) - - return data_models.BigFramesOp( - internal_name=f"_{bq_func.op_base_name.upper()}_OP", - sql_name=bq_func.name.upper(), - arg_specs=arg_specs_str, - signature=signature, - signature_definition=signature_definition, - ) - - -def _to_bigframes_func( - bq_func: data_models.BQFunc, import_module: str | None = None -) -> data_models.BigFramesFunc: - python_name = bq_func.op_base_name - if python_name in constants.PYTHON_BUILTINS: - python_name = python_name + "_" - - return data_models.BigFramesFunc( - name=python_name, - op_name=f"_{bq_func.op_base_name.upper()}_OP", - description=bq_func.description, - args=_get_bigframes_func_args(bq_func), - series_accessor_arg=bq_func.series_accessor_arg, - import_module=import_module, - ) - - -def render_license() -> str: - return TEMPLATES["license"].render() - - -def render_operation( - bq_module: data_models.BQModule, -) -> str: - ops: list[data_models.BigFramesOp] = [] - functions: list[data_models.BigFramesFunc] = [] - - for bq_func in bq_module.functions: - ops.append(_to_bigframes_op(bq_func)) - functions.append(_to_bigframes_func(bq_func)) - - return TEMPLATES["operation"].render( - yaml_path=bq_module.yaml_file.relative_to(constants.PACKAGE_ROOT), - script_path=constants.SCRIPT_PATH_RELATIVE, - ops=ops, - functions=functions, - ) - - -def render_tests(bq_module: data_models.BQModule) -> str: - import_path = "bigframes.operations.googlesql." + ".".join( - bq_module.module_path.parts - ) - functions: list[data_models.BigFramesFunc] = [] - for bq_func in bq_module.functions: - functions.append(_to_bigframes_func(bq_func)) - - return TEMPLATES["test_operation"].render( - yaml_path=bq_module.yaml_file.relative_to(constants.PACKAGE_ROOT), - script_path=constants.SCRIPT_PATH_RELATIVE, - import_path=import_path, - short_name=bq_module.module_path.name, - is_global=bq_module.is_global, - functions=functions, - ) - - -def _create_accessor_class_name(namespace: tuple[str, ...], prefix: str = "") -> str: - if not namespace: - return f"{prefix}BigQuerySeriesAccessor" - camel_parts = [part.capitalize() for part in namespace] - return f"{prefix}{''.join(camel_parts)}SeriesAccessor" - - -def render_accessor( - bq_modules: Sequence[data_models.BQModule], -) -> tuple[str, str, str]: - """ - Returns the content for core accessor, pandas accessor and BF accessor - """ - - namespaces: set[tuple[str, ...]] = set() - for bq_module in bq_modules: - for i in range(len(bq_module.namespace) + 1): - namespaces.add(bq_module.namespace[:i]) - - sorted_namespaces = sorted(list(namespaces), key=lambda ns: (len(ns), ns)) - - accessors: list[data_models.Accessor] = [] - accessor_lookup_table: dict[tuple[str, ...], data_models.Accessor] = {} - for namespace in sorted_namespaces: - accessor = data_models.Accessor( - class_name=_create_accessor_class_name(namespace), - bigframes_class_name=_create_accessor_class_name( - namespace, prefix="Bigframes" - ), - pandas_class_name=_create_accessor_class_name(namespace, prefix="Pandas"), - is_root=len(namespace) == 0, - description=( - f"Series accessor for BigQuery {'.'.join(namespace)} functions." - if namespace - else "Series accessor for BigQuery functions." - ), - children=[], - functions=[], - ) - accessors.append(accessor) - accessor_lookup_table[namespace] = accessor - - # Establish parent-child relations - if len(namespace) > 0: - accessor.prop_name = namespace[-1] - parent_namespace = namespace[:-1] - accessor_lookup_table[parent_namespace].children.append(accessor) - - # Arrange functions by namespaces - for bq_module in bq_modules: - module_parts = bq_module.module_path.parts - for bq_func in bq_module.functions: - if bq_func.series_accessor_arg is None: - continue - bf_func = _to_bigframes_func( - bq_func, - import_module=f"bigframes.operations.googlesql.{'.'.join(module_parts)}", - ) - accessor_lookup_table[bq_module.namespace].functions.append(bf_func) - - core_content = TEMPLATES["core_series_accessor"].render( - script_path=constants.SCRIPT_PATH_RELATIVE, - namespaces=accessors, - ) - - pandas_content = TEMPLATES["pandas_series_accessor"].render( - script_path=constants.SCRIPT_PATH_RELATIVE, namespaces=accessors - ) - - bigframes_content = TEMPLATES["bigframes_series_accessor"].render( - script_path=constants.SCRIPT_PATH_RELATIVE, namespaces=accessors - ) - - return core_content, pandas_content, bigframes_content diff --git a/scripts/bigquery_generator/yaml_parser.py b/scripts/bigquery_generator/yaml_parser.py deleted file mode 100644 index 6ef1d5d9236..00000000000 --- a/scripts/bigquery_generator/yaml_parser.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Any - -import yaml - -from . import data_models - - -def _build_func_arg_ir(arg_data: Any) -> data_models.BQFuncArg: - return data_models.BQFuncArg( - name=arg_data["name"], - value=arg_data["value"], - optional=arg_data["optional"], - keyword_only=arg_data["keyword_only"], - ) - - -def _build_func_impl_ir(impl_data: Any) -> data_models.BQFuncImpl: - return data_models.BQFuncImpl( - args=tuple(_build_func_arg_ir(arg) for arg in impl_data["args"]), - return_type=impl_data["return"], - ) - - -def _build_func_ir(func_data: Any) -> data_models.BQFunc: - return data_models.BQFunc( - name=func_data["name"], - description=func_data["description"], - impls=tuple(_build_func_impl_ir(impl) for impl in func_data["impls"]), - series_accessor_arg=func_data.get("series_accessor_arg", None), - ) - - -def parse_yaml(yaml_file: pathlib.Path) -> data_models.BQModule: - print(f"Parsing {yaml_file}...") - - with open(yaml_file, "r", encoding="utf-8") as f: - data = yaml.safe_load(f) - - functions: tuple[data_models.BQFunc, ...] = () - if isinstance(data, dict) and "scalar_functions" in data: - functions = tuple( - _build_func_ir(func_data) for func_data in data["scalar_functions"] - ) - - return data_models.BQModule( - yaml_file=yaml_file, - functions=functions, - ) diff --git a/scripts/conftest.py b/scripts/conftest.py index 0d55bd4b478..83fd2b19aff 100644 --- a/scripts/conftest.py +++ b/scripts/conftest.py @@ -1,5 +1,5 @@ -import sys from pathlib import Path +import sys # inserts scripts into path so that tests can import project_root = Path(__file__).parent.parent diff --git a/scripts/create_gcs.py b/scripts/create_gcs.py index bdb8a23ddc9..8a94bfd8865 100644 --- a/scripts/create_gcs.py +++ b/scripts/create_gcs.py @@ -16,12 +16,12 @@ # bigframes.streaming testing if they don't already exist import os -import sys from pathlib import Path +import sys import google.cloud.exceptions as exceptions -import google.cloud.storage as gcs from google.cloud.storage import transfer_manager +import google.cloud.storage as gcs PROJECT_ID = os.getenv("GOOGLE_CLOUD_PROJECT") diff --git a/scripts/create_read_gbq_colab_benchmark_tables.py b/scripts/create_read_gbq_colab_benchmark_tables.py index 727a1e116ac..63419bc6604 100644 --- a/scripts/create_read_gbq_colab_benchmark_tables.py +++ b/scripts/create_read_gbq_colab_benchmark_tables.py @@ -23,8 +23,8 @@ import time from typing import Any, Iterable, MutableSequence, Sequence -import numpy as np from google.cloud import bigquery +import numpy as np # --- Input Data --- # Generated by querying bigquery-magics usage. See internal issue b/420984164. @@ -463,7 +463,7 @@ def worker_initializer(project_id: str | None): def worker_process_item( - work_item: tuple[str, Sequence[tuple[str, str, int | None]], int], + work_item: tuple[str, Sequence[tuple[str, str, int | None]], int] ): global worker_client, worker_rng diff --git a/scripts/create_read_gbq_colab_benchmark_tables_test.py b/scripts/create_read_gbq_colab_benchmark_tables_test.py index 56c9cb2bc56..89c49e42435 100644 --- a/scripts/create_read_gbq_colab_benchmark_tables_test.py +++ b/scripts/create_read_gbq_colab_benchmark_tables_test.py @@ -20,9 +20,6 @@ import math import re -import numpy as np -import pytest - # 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, @@ -30,6 +27,8 @@ generate_work_items, get_bq_schema, ) +import numpy as np +import pytest # Helper function to calculate estimated row size from schema diff --git a/scripts/data/sql-functions/aead.yaml b/scripts/data/sql-functions/aead.yaml deleted file mode 100644 index 198248782d7..00000000000 --- a/scripts/data/sql-functions/aead.yaml +++ /dev/null @@ -1,134 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "aead.decrypt_bytes" - description: "Uses the matching key from keyset to decrypt ciphertext and verifies the integrity of the data using additional_data. Returns an error if decryption or verification fails." - series_accessor_arg: keyset - impls: - # Signature: aead.decrypt_bytes:vbin_vbin_vbin - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - # Signature: aead.decrypt_bytes:struct_vbin_vbin - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - - name: "aead.decrypt_string" - description: "Like AEAD.DECRYPT_BYTES, but where additional_data is of type STRING." - series_accessor_arg: keyset - impls: - # Signature: aead.decrypt_string:vbin_vbin_str - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: string - # Signature: aead.decrypt_string:struct_vbin_str - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: string - - name: "aead.encrypt" - description: "Encrypts plaintext using the primary cryptographic key in keyset. The algorithm of the primary key must be AEAD_AES_GCM_256. Binds the ciphertext to the context defined by additional_data. Returns NULL if any input is NULL." - series_accessor_arg: keyset - impls: - # Signature: aead.encrypt:vbin_str_str - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "plaintext" - value: string - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: binary - # Signature: aead.encrypt:vbin_vbin_vbin - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "plaintext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - # Signature: aead.encrypt:struct_str_str - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "plaintext" - value: string - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: binary - # Signature: aead.encrypt:struct_vbin_vbin - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "plaintext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary diff --git a/scripts/data/sql-functions/ai.yaml b/scripts/data/sql-functions/ai.yaml deleted file mode 100644 index f3238c8178b..00000000000 --- a/scripts/data/sql-functions/ai.yaml +++ /dev/null @@ -1 +0,0 @@ -urn: extension:google:bq_scalar_functions diff --git a/scripts/data/sql-functions/global_namespace/aead_encryption.yaml b/scripts/data/sql-functions/global_namespace/aead_encryption.yaml deleted file mode 100644 index 1e62de0f2a6..00000000000 --- a/scripts/data/sql-functions/global_namespace/aead_encryption.yaml +++ /dev/null @@ -1,134 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "deterministic_decrypt_bytes" - description: "Uses the matching key from `keyset` to decrypt `ciphertext` and verifies the integrity of the data using `additional_data`. Returns an error if decryption fails." - series_accessor_arg: keyset - impls: - # Signature: deterministic_decrypt_bytes:vbin_vbin_vbin - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - # Signature: deterministic_decrypt_bytes:struct_vbin_vbin - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - - name: "deterministic_decrypt_string" - description: "Like `DETERMINISTIC_DECRYPT_BYTES`, but where plaintext is of type STRING." - series_accessor_arg: keyset - impls: - # Signature: deterministic_decrypt_string:vbin_vbin_str - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: string - # Signature: deterministic_decrypt_string:struct_vbin_str - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "ciphertext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: string - - name: "deterministic_encrypt" - description: "Encrypts `plaintext` using the primary cryptographic key in `keyset` using deterministic AEAD. The algorithm of the primary key must be `DETERMINISTIC_AEAD_AES_SIV_CMAC_256`. Binds the ciphertext to the context defined by `additional_data`. Returns `NULL` if any input is `NULL`." - series_accessor_arg: keyset - impls: - # Signature: deterministic_encrypt:vbin_str_str - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "plaintext" - value: string - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: binary - # Signature: deterministic_encrypt:vbin_vbin_vbin - - args: - - name: "keyset" - value: binary - optional: false - keyword_only: false - - name: "plaintext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary - # Signature: deterministic_encrypt:struct_str_str - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "plaintext" - value: string - optional: false - keyword_only: false - - name: "additional_data" - value: string - optional: false - keyword_only: false - return: binary - # Signature: deterministic_encrypt:struct_vbin_vbin - - args: - - name: "keyset" - value: struct - optional: false - keyword_only: false - - name: "plaintext" - value: binary - optional: false - keyword_only: false - - name: "additional_data" - value: binary - optional: false - keyword_only: false - return: binary diff --git a/scripts/data/sql-functions/global_namespace/array.yaml b/scripts/data/sql-functions/global_namespace/array.yaml deleted file mode 100644 index aa9230c251b..00000000000 --- a/scripts/data/sql-functions/global_namespace/array.yaml +++ /dev/null @@ -1,341 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "array_concat" - description: "Concatenates one or more arrays with the same element type into a single array." - series_accessor_arg: array_expression_1 - impls: - # Signature: array_concat:list_list - - args: - - name: "array_expression_1" - value: list - optional: false - keyword_only: false - - name: "array_expression_2" - value: list - optional: false - keyword_only: false - return: list - - name: "array_first" - description: "Takes an array and returns the first element in the array." - series_accessor_arg: array_expression - impls: - # Signature: array_first:list - - args: - - name: "array_expression" - value: list - optional: false - keyword_only: false - return: any1 - - name: "array_first_n" - description: "Returns a prefix of `input_array` consisting of the first `n` elements." - series_accessor_arg: input_array - impls: - # Signature: array_first_n:list_i64 - - args: - - name: "input_array" - value: list - optional: false - keyword_only: false - - name: "n" - value: i64 - optional: false - keyword_only: false - return: list - - name: "array_includes" - description: "Takes an array and returns `TRUE` if there is an element in the array that is equal to the search_value." - series_accessor_arg: array_to_search - impls: - # Signature: array_includes:list_any - - args: - - name: "array_to_search" - value: list - optional: false - keyword_only: false - - name: "search_value" - value: any1 - optional: false - keyword_only: false - return: boolean - - name: "array_includes_all" - description: "Takes an array to search and an array of search values. Returns `TRUE` if all search values are in the array to search, otherwise returns `FALSE`." - series_accessor_arg: array_to_search - impls: - # Signature: array_includes_all:list_list - - args: - - name: "array_to_search" - value: list - optional: false - keyword_only: false - - name: "search_values" - value: list - optional: false - keyword_only: false - return: boolean - - name: "array_includes_any" - description: "Takes an array to search and an array of search values. Returns `TRUE` if any search values are in the array to search, otherwise returns `FALSE`." - series_accessor_arg: array_to_search - impls: - # Signature: array_includes_any:list_list - - args: - - name: "array_to_search" - value: list - optional: false - keyword_only: false - - name: "search_values" - value: list - optional: false - keyword_only: false - return: boolean - - name: "array_is_distinct" - description: "Returns `TRUE` if the array contains no repeated elements, using the same equality comparison logic as `SELECT DISTINCT`." - series_accessor_arg: array_expression - impls: - # Signature: array_is_distinct:list - - args: - - name: "array_expression" - value: list - optional: false - keyword_only: false - return: boolean - - name: "array_last" - description: "Takes an array and returns the last element in the array." - series_accessor_arg: array_expression - impls: - # Signature: array_last:list - - args: - - name: "array_expression" - value: list - optional: false - keyword_only: false - return: any1 - - name: "array_length" - description: | - Compute the length of each array element in the Series. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> bbq.array_length(s) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([[1, 2, 8, 3], [], [3, 4]]) - >>> ps.bigquery.array_length() - 0 4 - 1 0 - 2 2 - dtype: Int64 - - You can also apply this function directly to Series using `apply`. - - >>> s.apply(bbq.array_length, by_row=False) - 0 4 - 1 0 - 2 2 - dtype: Int64 - - Args: - series (bigframes.series.Series): A Series with array columns. - - Returns: - bigframes.series.Series: A Series of integer values indicating - the length of each element in the Series. - series_accessor_arg: series - impls: - # Signature: array_length:list - - args: - - name: "series" - value: list - optional: false - keyword_only: false - return: i64 - - name: "array_reverse" - description: "Returns the input `ARRAY` with elements in reverse order." - series_accessor_arg: value - impls: - # Signature: array_reverse:list - - args: - - name: "value" - value: list - optional: false - keyword_only: false - return: list - - name: "array_slice" - description: "Returns an array containing zero or more consecutive elements from the input array." - series_accessor_arg: array_to_slice - impls: - # Signature: array_slice:list_i64_i64 - - args: - - name: "array_to_slice" - value: list - optional: false - keyword_only: false - - name: "start_offset" - value: i64 - optional: false - keyword_only: false - - name: "end_offset" - value: i64 - optional: false - keyword_only: false - return: list - - name: "array_to_string" - description: | - Converts array elements within a Series into delimited strings. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> import bigframes.bigquery as bbq - - >>> s = bpd.Series([["H", "i", "!"], ["Hello", "World"], np.nan, [], ["Hi"]]) - >>> bbq.array_to_string(s, delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can call this function using the Series `bigquery` accessor. - - >>> s.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - You can also use this accessor on a pandas Series after importing bigframes. - - >>> import bigframes - >>> import pandas as pd - >>> ps = pd.Series([["H", "i", "!"], ["Hello", "World"], None, [], ["Hi"]]) - >>> ps.bigquery.array_to_string(delimiter=", ") - 0 H, i, ! - 1 Hello, World - 2 - 3 - 4 Hi - dtype: string - - Args: - series (bigframes.series.Series): A Series containing arrays. - delimiter (str): The string used to separate array elements. - null_text (str, optional): The string to replace any NULL values in the array with. - - Returns: - bigframes.series.Series: A Series containing delimited strings. - series_accessor_arg: series - impls: - # Signature: array_to_string:list_str_str - - args: - - name: "series" - value: list - optional: false - keyword_only: false - - name: "delimiter" - value: string - optional: false - keyword_only: false - - name: "null_text" - value: string - optional: true - keyword_only: false - return: string - # Signature: array_to_string:list_vbin_vbin - - args: - - name: "series" - value: list - optional: false - keyword_only: false - - name: "delimiter" - value: binary - optional: false - keyword_only: false - - name: "null_text" - value: binary - optional: true - keyword_only: false - return: binary - - name: "flatten" - description: "Takes an array of nested data and flattens a specific part of it into a single, flat array with the [array elements field access operator][array-el-field-operator]. Returns `NULL` if the input value is `NULL`." - series_accessor_arg: array_to_flatten - impls: - # Signature: flatten:list_i64 - - args: - - name: "array_to_flatten" - value: list - optional: false - keyword_only: false - - name: "depth" - value: i64 - optional: true - keyword_only: true - return: list - - name: "generate_array" - description: "Returns an array of values. The `start_expression` and `end_expression` parameters determine the inclusive start and end of the array." - impls: - # Signature: generate_array:i64_i64_i64 - - args: - - name: "start_expression" - value: i64 - optional: false - keyword_only: false - - name: "end_expression" - value: i64 - optional: false - keyword_only: false - - name: "step_expression" - value: i64 - optional: true - keyword_only: false - return: list - # Signature: generate_array:dec_dec_dec - - args: - - name: "start_expression" - value: decimal<38,9> - optional: false - keyword_only: false - - name: "end_expression" - value: decimal<38,9> - optional: false - keyword_only: false - - name: "step_expression" - value: decimal<38,9> - optional: true - keyword_only: false - return: list> - # Signature: generate_array:fp64_fp64_fp64 - - args: - - name: "start_expression" - value: fp64 - optional: false - keyword_only: false - - name: "end_expression" - value: fp64 - optional: false - keyword_only: false - - name: "step_expression" - value: fp64 - optional: true - keyword_only: false - return: list diff --git a/scripts/data/sql-functions/global_namespace/bit.yaml b/scripts/data/sql-functions/global_namespace/bit.yaml deleted file mode 100644 index fe14eae7b64..00000000000 --- a/scripts/data/sql-functions/global_namespace/bit.yaml +++ /dev/null @@ -1,27 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "bit_count" - description: "The input, `expression`, must be an integer or `BYTES`. Returns the number of bits that are set in the input expression. For signed integers, this is the number of bits in two's complement form." - series_accessor_arg: expression - impls: - # Signature: bit_count:i32 - - args: - - name: "expression" - value: i32 - optional: false - keyword_only: false - return: i64 - # Signature: bit_count:i64 - - args: - - name: "expression" - value: i64 - optional: false - keyword_only: false - return: i64 - # Signature: bit_count:vbin - - args: - - name: "expression" - value: binary - optional: false - keyword_only: false - return: i64 diff --git a/scripts/data/sql-functions/global_namespace/conversion.yaml b/scripts/data/sql-functions/global_namespace/conversion.yaml deleted file mode 100644 index c39724427de..00000000000 --- a/scripts/data/sql-functions/global_namespace/conversion.yaml +++ /dev/null @@ -1,119 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "bool" - description: "Converts a JSON boolean to a SQL BOOL value." - series_accessor_arg: json_string_expression - impls: - # Signature: bool:str - - args: - - name: "json_string_expression" - value: string - optional: false - keyword_only: false - return: boolean - - name: "double" - description: "Converts a JSON number to a SQL FLOAT64 value." - series_accessor_arg: json_string_expression - impls: - # Signature: double:str_str - - args: - - name: "json_string_expression" - value: string - optional: false - keyword_only: false - - name: "wide_number_mode" - value: string - optional: true - keyword_only: true - return: fp64 - - name: "float64" - description: "Converts a JSON number to a SQL FLOAT64 value." - series_accessor_arg: json_string_expression - impls: - # Signature: float64:str_str - - args: - - name: "json_string_expression" - value: string - optional: false - keyword_only: false - - name: "wide_number_mode" - value: string - optional: true - keyword_only: true - return: fp64 - - name: "int64" - description: "Converts a JSON number to a SQL INT64 value." - series_accessor_arg: json_string_expression - impls: - # Signature: int64:str - - args: - - name: "json_string_expression" - value: string - optional: false - keyword_only: false - return: i64 - - name: "parse_bignumeric" - description: "Converts a STRING to a BIGNUMERIC value." - series_accessor_arg: string_expression - impls: - # Signature: parse_bignumeric:str - - args: - - name: "string_expression" - value: string - optional: false - keyword_only: false - return: decimal<76,38> - - name: "parse_numeric" - description: "Converts a STRING to a NUMERIC value." - series_accessor_arg: string_expression - impls: - # Signature: parse_numeric:str - - args: - - name: "string_expression" - value: string - optional: false - keyword_only: false - return: decimal<38,9> - - name: "string" - description: "Converts a value to a STRING value." - series_accessor_arg: expression - impls: - # Signature: string:pts_str - - args: - - name: "expression" - value: timestamp - optional: false - keyword_only: false - - name: "timezone" - value: string - optional: true - keyword_only: false - return: string - # Signature: string:date - - args: - - name: "expression" - value: date - optional: false - keyword_only: false - return: string - # Signature: string:pt - - args: - - name: "expression" - value: time - optional: false - keyword_only: false - return: string - # Signature: string:pts - - args: - - name: "expression" - value: timestamp - optional: false - keyword_only: false - return: string - # Signature: string:str - - args: - - name: "expression" - value: string - optional: false - keyword_only: false - return: string diff --git a/scripts/data/sql-functions/global_namespace/date.yaml b/scripts/data/sql-functions/global_namespace/date.yaml deleted file mode 100644 index 8d1dfc95284..00000000000 --- a/scripts/data/sql-functions/global_namespace/date.yaml +++ /dev/null @@ -1,277 +0,0 @@ -urn: extension:google:bq_scalar_functions -scalar_functions: - - name: "current_date" - description: "Returns the current date as a DATE object. Parentheses are optional when called with no arguments." - impls: - # Signature: current_date:str - - args: - - name: "time_zone_expression" - value: string - optional: true - keyword_only: false - return: date - - name: "date" - description: "Constructs or extracts a date." - series_accessor_arg: expression - impls: - # Signature: date:pts_str - - args: - - name: "expression" - value: timestamp - optional: false - keyword_only: false - - name: "time_zone_expression" - value: string - optional: true - keyword_only: false - return: date - # Signature: date:pts - - args: - - name: "expression" - value: timestamp - optional: false - keyword_only: false - return: date - # Signature: date:i64_i64_i64 - - args: - - name: "year" - value: i64 - optional: false - keyword_only: false - - name: "month" - value: i64 - optional: false - keyword_only: false - - name: "day" - value: i64 - optional: false - keyword_only: false - return: date - # Signature: date:date - - args: - - name: "expression" - value: date - optional: false - keyword_only: false - return: date - # Signature: date:str - - args: - - name: "expression" - value: string - optional: false - keyword_only: false - return: date - - name: "date_add" - description: "Adds a specified time interval to a DATE." - series_accessor_arg: date_expression - impls: - # Signature: date_add:date_i64_any - - args: - - name: "date_expression" - value: date - optional: false - keyword_only: false - - name: "int64_expression" - value: i64 - optional: false - keyword_only: false - - name: "date_part" - value: any1 - optional: false - keyword_only: false - return: date - # TODO(b/527093666): add support for date_bucket when we add an INTERVAL dtype - - name: "date_diff" - description: "Gets the number of unit boundaries between two DATE values (end_date - start_date) at a particular time granularity." - series_accessor_arg: end_date - impls: - # Signature: date_diff:date_date_any - - args: - - name: "end_date" - value: date - optional: false - keyword_only: false - - name: "start_date" - value: date - optional: false - keyword_only: false - - name: "granularity" - value: any1 - optional: false - keyword_only: false - return: i64 - - name: "date_from_unix_date" - description: "Interprets an INT64 expression as the number of days since 1970-01-01." - series_accessor_arg: int64_expression - impls: - # Signature: date_from_unix_date:i64 - - args: - - name: "int64_expression" - value: i64 - optional: false - keyword_only: false - return: date - - name: "date_sub" - description: "Subtracts a specified time interval from a DATE." - series_accessor_arg: date_expression - impls: - # Signature: date_sub:date_i64_any - - args: - - name: "date_expression" - value: date - optional: false - keyword_only: false - - name: "int64_expression" - value: i64 - optional: false - keyword_only: false - - name: "date_part" - value: any1 - optional: false - keyword_only: false - return: date - - name: "date_trunc" - description: "Truncates a DATE, DATETIME, or TIMESTAMP value at a particular granularity." - series_accessor_arg: date_value - impls: - # Signature: date_trunc:date_any - - args: - - name: "date_value" - value: date - optional: false - keyword_only: false - - name: "granularity" - value: any1 - optional: false - keyword_only: false - return: date - - name: "extract" - description: "Returns the value corresponding to the specified date part." - series_accessor_arg: date_expression - impls: - # Signature: extract:date_any - - args: - - name: "date_expression" - value: date - optional: false - keyword_only: false - - name: "part" - value: any1 - optional: false - keyword_only: false - return: i64 - # Signature: extract:pts_any_str - - args: - - name: "date_expression" - value: timestamp - optional: false - keyword_only: false - - name: "part" - value: any1 - optional: false - keyword_only: false - - name: "time_zone" - value: string - optional: true - keyword_only: false - return: i64 - # Signature: extract:pts_any - - args: - - name: "date_expression" - value: timestamp - optional: false - keyword_only: false - - name: "part" - value: any1 - optional: false - keyword_only: false - return: i64 - # Signature: extract:pt_any - - args: - - name: "date_expression" - value: time - optional: false - keyword_only: false - - name: "part" - value: any1 - optional: false - keyword_only: false - return: i64 - - name: "format_date" - description: "Formats a DATE value according to a specified format string." - series_accessor_arg: date_expr - impls: - # Signature: format_date:str_date - - args: - - name: "format_string" - value: string - optional: false - keyword_only: false - - name: "date_expr" - value: date - optional: false - keyword_only: false - return: string - - name: "generate_date_array" - description: "Generates an array of dates in a range." - impls: - # Signature: generate_date_array:date_date_i64_any - - args: - - name: "start_date" - value: date - optional: false - keyword_only: false - - name: "end_date" - value: date - optional: false - keyword_only: false - - name: "int64_expression" - value: i64 - optional: true - keyword_only: false - - name: "date_part" - value: any1 - optional: true - keyword_only: false - return: list - - name: "last_day" - description: "Returns the last day from a date expression. This is commonly used to return the last day of the month." - series_accessor_arg: date_expression - impls: - # Signature: last_day:date_any - - args: - - name: "date_expression" - value: date - optional: false - keyword_only: false - - name: "date_part" - value: any1 - optional: true - keyword_only: false - return: date - - name: "parse_date" - description: "Converts a STRING value to a DATE value." - series_accessor_arg: date_string - impls: - # Signature: parse_date:str_str - - args: - - name: "format_string" - value: string - optional: false - keyword_only: false - - name: "date_string" - value: string - optional: false - keyword_only: false - return: date - - name: "unix_date" - description: "Returns the number of days since 1970-01-01." - series_accessor_arg: date_expression - impls: - # Signature: unix_date:date - - args: - - name: "date_expression" - value: date - optional: false - keyword_only: false - return: i64 diff --git a/scripts/decrypt-secrets.sh b/scripts/decrypt-secrets.sh new file mode 100755 index 00000000000..120b0ddc436 --- /dev/null +++ b/scripts/decrypt-secrets.sh @@ -0,0 +1,46 @@ +#!/bin/bash + +# Copyright 2024 Google LLC All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" +ROOT=$( dirname "$DIR" ) + +# Work from the project root. +cd $ROOT + +# Prevent it from overriding files. +# We recommend that sample authors use their own service account files and cloud project. +# In that case, they are supposed to prepare these files by themselves. +if [[ -f "testing/test-env.sh" ]] || \ + [[ -f "testing/service-account.json" ]] || \ + [[ -f "testing/client-secrets.json" ]]; then + echo "One or more target files exist, aborting." + exit 1 +fi + +# Use SECRET_MANAGER_PROJECT if set, fallback to cloud-devrel-kokoro-resources. +PROJECT_ID="${SECRET_MANAGER_PROJECT:-cloud-devrel-kokoro-resources}" + +gcloud secrets versions access latest --secret="python-docs-samples-test-env" \ + --project="${PROJECT_ID}" \ + > testing/test-env.sh +gcloud secrets versions access latest \ + --secret="python-docs-samples-service-account" \ + --project="${PROJECT_ID}" \ + > testing/service-account.json +gcloud secrets versions access latest \ + --secret="python-docs-samples-client-secrets" \ + --project="${PROJECT_ID}" \ + > testing/client-secrets.json diff --git a/scripts/dev-utils/tpcds_upload_helper.py b/scripts/dev-utils/tpcds_upload_helper.py index dec5b39768f..52bb553cd81 100644 --- a/scripts/dev-utils/tpcds_upload_helper.py +++ b/scripts/dev-utils/tpcds_upload_helper.py @@ -9,10 +9,11 @@ def preprocess_csv(input_file_path, output_file_path): try: - with ( - open(input_file_path, mode="r", newline="", encoding="utf-8") as infile, - open(output_file_path, mode="w", newline="", encoding="utf-8") as outfile, - ): + with open( + input_file_path, mode="r", newline="", encoding="utf-8" + ) as infile, open( + output_file_path, mode="w", newline="", encoding="utf-8" + ) as outfile: reader = csv.reader(infile, delimiter="|") writer = csv.writer(outfile, delimiter="|") diff --git a/scripts/generate_bigframes_bigquery.py b/scripts/generate_bigframes_bigquery.py deleted file mode 100755 index fe90dc43472..00000000000 --- a/scripts/generate_bigframes_bigquery.py +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env -S uv run --active --script -# -# /// script -# dependencies = [ -# "jinja2", -# "pyyaml", -# "ruff==0.14.14", -# ] -# /// -# -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 sys - -scripts_dir = pathlib.Path(__file__).parent -if str(scripts_dir) not in sys.path: - sys.path.insert(0, str(scripts_dir)) - -from bigquery_generator import constants, file_generator, yaml_parser # noqa: E402 - - -def main() -> None: - modules = [] - - for yaml_file in sorted(constants.DATA_DIR.glob("**/*.yaml")): - modules.append(yaml_parser.parse_yaml(yaml_file)) - - file_generator.generate(modules) - - -if __name__ == "__main__": - main() diff --git a/scripts/manage_cloud_functions.py b/scripts/manage_cloud_functions.py index c92be4ebadb..ccf588bde7c 100644 --- a/scripts/manage_cloud_functions.py +++ b/scripts/manage_cloud_functions.py @@ -67,7 +67,6 @@ def get_bigframes_functions(project, region): functions = GCF_CLIENT.list_functions( functions_v2.ListFunctionsRequest(parent=parent) ) - # Filter bigframes created functions functions = [ function diff --git a/scripts/publish_api_coverage.py b/scripts/publish_api_coverage.py index f94cd7e6d7f..8f305bcc0fa 100644 --- a/scripts/publish_api_coverage.py +++ b/scripts/publish_api_coverage.py @@ -30,21 +30,38 @@ 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 -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", +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_" + ), } @@ -122,7 +139,7 @@ def generate_pandas_api_coverage(): missing_parameters = "" # skip private functions and properties - if member[0] == "_": + if member[0] == "_" and member[1] != "_": continue # skip members that are also common python methods @@ -187,9 +204,6 @@ 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", @@ -290,19 +304,11 @@ 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 + "" - bigframes_object = BIGFRAMES_OBJECT.get(api_prefix) - if bigframes_object is None: + url_prefix = URL_PREFIX.get(api_prefix) + if url_prefix 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 new file mode 100644 index 00000000000..ceb1eada7c2 --- /dev/null +++ b/scripts/readme-gen/readme_gen.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +# 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. + +"""Generates READMEs using configuration defined in yaml.""" + +import argparse +import io +import os +import subprocess + +import jinja2 +import yaml + +jinja_env = jinja2.Environment( + trim_blocks=True, + loader=jinja2.FileSystemLoader( + os.path.abspath(os.path.join(os.path.dirname(__file__), "templates")) + ), + autoescape=True, +) + +README_TMPL = jinja_env.get_template("README.tmpl.rst") + + +def get_help(file): + return subprocess.check_output(["python", file, "--help"]).decode() + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("source") + parser.add_argument("--destination", default="README.rst") + + args = parser.parse_args() + + source = os.path.abspath(args.source) + root = os.path.dirname(source) + destination = os.path.join(root, args.destination) + + jinja_env.globals["get_help"] = get_help + + with io.open(source, "r") as f: + config = yaml.load(f) + + # This allows get_help to execute in the right directory. + os.chdir(root) + + output = README_TMPL.render(config) + + with io.open(destination, "w") as f: + f.write(output) + + +if __name__ == "__main__": + main() diff --git a/scripts/readme-gen/templates/README.tmpl.rst b/scripts/readme-gen/templates/README.tmpl.rst new file mode 100644 index 00000000000..4fd239765b0 --- /dev/null +++ b/scripts/readme-gen/templates/README.tmpl.rst @@ -0,0 +1,87 @@ +{# The following line is a lie. BUT! Once jinja2 is done with it, it will + become truth! #} +.. This file is automatically generated. Do not edit this file directly. + +{{product.name}} Python Samples +=============================================================================== + +.. image:: https://gstatic.com/cloudssh/images/open-btn.png + :target: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor={{folder}}/README.rst + + +This directory contains samples for {{product.name}}. {{product.description}} + +{{description}} + +.. _{{product.name}}: {{product.url}} + +{% if required_api_url %} +To run the sample, you need to enable the API at: {{required_api_url}} +{% endif %} + +{% if required_role %} +To run the sample, you need to have `{{required_role}}` role. +{% endif %} + +{{other_required_steps}} + +{% if setup %} +Setup +------------------------------------------------------------------------------- + +{% for section in setup %} + +{% include section + '.tmpl.rst' %} + +{% endfor %} +{% endif %} + +{% if samples %} +Samples +------------------------------------------------------------------------------- + +{% for sample in samples %} +{{sample.name}} ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ + +{% if not sample.hide_cloudshell_button %} +.. image:: https://gstatic.com/cloudssh/images/open-btn.png + :target: https://console.cloud.google.com/cloudshell/open?git_repo=https://github.com/GoogleCloudPlatform/python-docs-samples&page=editor&open_in_editor={{folder}}/{{sample.file}},{{folder}}/README.rst +{% endif %} + + +{{sample.description}} + +To run this sample: + +.. code-block:: bash + + $ python {{sample.file}} +{% if sample.show_help %} + + {{get_help(sample.file)|indent}} +{% endif %} + + +{% endfor %} +{% endif %} + +{% if cloud_client_library %} + +The client library +------------------------------------------------------------------------------- + +This sample uses the `Google Cloud Client Library for Python`_. +You can read the documentation for more details on API usage and use GitHub +to `browse the source`_ and `report issues`_. + +.. _Google Cloud Client Library for Python: + https://googlecloudplatform.github.io/google-cloud-python/ +.. _browse the source: + https://github.com/GoogleCloudPlatform/google-cloud-python +.. _report issues: + https://github.com/GoogleCloudPlatform/google-cloud-python/issues + +{% endif %} + +.. _Google Cloud SDK: https://cloud.google.com/sdk/ \ No newline at end of file diff --git a/scripts/readme-gen/templates/auth.tmpl.rst b/scripts/readme-gen/templates/auth.tmpl.rst new file mode 100644 index 00000000000..1446b94a5e3 --- /dev/null +++ b/scripts/readme-gen/templates/auth.tmpl.rst @@ -0,0 +1,9 @@ +Authentication +++++++++++++++ + +This sample requires you to have authentication setup. Refer to the +`Authentication Getting Started Guide`_ for instructions on setting up +credentials for applications. + +.. _Authentication Getting Started Guide: + https://cloud.google.com/docs/authentication/getting-started diff --git a/scripts/readme-gen/templates/auth_api_key.tmpl.rst b/scripts/readme-gen/templates/auth_api_key.tmpl.rst new file mode 100644 index 00000000000..11957ce2714 --- /dev/null +++ b/scripts/readme-gen/templates/auth_api_key.tmpl.rst @@ -0,0 +1,14 @@ +Authentication +++++++++++++++ + +Authentication for this service is done via an `API Key`_. To obtain an API +Key: + +1. Open the `Cloud Platform Console`_ +2. Make sure that billing is enabled for your project. +3. From the **Credentials** page, create a new **API Key** or use an existing + one for your project. + +.. _API Key: + https://developers.google.com/api-client-library/python/guide/aaa_apikeys +.. _Cloud Console: https://console.cloud.google.com/project?_ diff --git a/scripts/readme-gen/templates/install_deps.tmpl.rst b/scripts/readme-gen/templates/install_deps.tmpl.rst new file mode 100644 index 00000000000..6f069c6c87a --- /dev/null +++ b/scripts/readme-gen/templates/install_deps.tmpl.rst @@ -0,0 +1,29 @@ +Install Dependencies +++++++++++++++++++++ + +#. Clone python-docs-samples and change directory to the sample directory you want to use. + + .. code-block:: bash + + $ git clone https://github.com/GoogleCloudPlatform/python-docs-samples.git + +#. Install `pip`_ and `virtualenv`_ if you do not already have them. You may want to refer to the `Python Development Environment Setup Guide`_ for Google Cloud Platform for instructions. + + .. _Python Development Environment Setup Guide: + https://cloud.google.com/python/setup + +#. Create a virtualenv. Samples are compatible with Python 3.7+. + + .. code-block:: bash + + $ virtualenv env + $ source env/bin/activate + +#. Install the dependencies needed to run the samples. + + .. code-block:: bash + + $ pip install -r requirements.txt + +.. _pip: https://pip.pypa.io/ +.. _virtualenv: https://virtualenv.pypa.io/ diff --git a/scripts/readme-gen/templates/install_portaudio.tmpl.rst b/scripts/readme-gen/templates/install_portaudio.tmpl.rst new file mode 100644 index 00000000000..5ea33d18c00 --- /dev/null +++ b/scripts/readme-gen/templates/install_portaudio.tmpl.rst @@ -0,0 +1,35 @@ +Install PortAudio ++++++++++++++++++ + +Install `PortAudio`_. This is required by the `PyAudio`_ library to stream +audio from your computer's microphone. PyAudio depends on PortAudio for cross-platform compatibility, and is installed differently depending on the +platform. + +* For Mac OS X, you can use `Homebrew`_:: + + brew install portaudio + + **Note**: if you encounter an error when running `pip install` that indicates + it can't find `portaudio.h`, try running `pip install` with the following + flags:: + + pip install --global-option='build_ext' \ + --global-option='-I/usr/local/include' \ + --global-option='-L/usr/local/lib' \ + pyaudio + +* For Debian / Ubuntu Linux:: + + apt-get install portaudio19-dev python-all-dev + +* Windows may work without having to install PortAudio explicitly (it will get + installed with PyAudio). + +For more details, see the `PyAudio installation`_ page. + + +.. _PyAudio: https://people.csail.mit.edu/hubert/pyaudio/ +.. _PortAudio: http://www.portaudio.com/ +.. _PyAudio installation: + https://people.csail.mit.edu/hubert/pyaudio/#downloads +.. _Homebrew: http://brew.sh diff --git a/scripts/run_doctest.sh b/scripts/run_doctest.sh deleted file mode 100755 index d5fd7256ece..00000000000 --- a/scripts/run_doctest.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/bin/bash -set -eo pipefail - -# Disable buffering, so that the logs stream through. -export PYTHONUNBUFFERED=1 - -# Assume we are running from the repo root or we need to find it. -# If this script is in packages/bigframes/scripts/run_doctest.sh, -# then repo root is 3 levels up. -export PROJECT_ROOT=$(realpath "$(dirname "${BASH_SOURCE[0]}")/../../..") -cd "$PROJECT_ROOT" - -git config --global --add safe.directory "$(realpath .)" - -package_name="bigframes" -package_path="packages/${package_name}" -files_to_check="${package_path}" - -# Use the IF block to handle the case where KOKORO vars are missing -# (e.g. local testing) -if [[ -n "${KOKORO_GITHUB_PULL_REQUEST_TARGET_BRANCH}" && -n "${KOKORO_GITHUB_PULL_REQUEST_COMMIT}" ]]; then - echo "checking changes with 'git diff ${KOKORO_GITHUB_PULL_REQUEST_TARGET_BRANCH}...${KOKORO_GITHUB_PULL_REQUEST_COMMIT} -- ${files_to_check}'" - - package_modified=$(git diff "${KOKORO_GITHUB_PULL_REQUEST_TARGET_BRANCH}...${KOKORO_GITHUB_PULL_REQUEST_COMMIT}" -- "${files_to_check}" | wc -l) -else - # If not a PR (like a local run or a different CI trigger), - # we treat it as 0 so it falls through to the "continuous" check. - package_modified=0 -fi - -# Check if modified OR if it's a continuous build -if [[ "${package_modified}" -gt 0 || "$KOKORO_BUILD_ARTIFACTS_SUBDIR" == *"continuous"* ]]; then - echo "------------------------------------------------------------" - echo "Running doctest for: ${package_name}" - echo "------------------------------------------------------------" - - # Ensure credentials are set for system tests in Kokoro - if [[ -z "${GOOGLE_APPLICATION_CREDENTIALS}" && -f "${KOKORO_GFILE_DIR}/service-account.json" ]]; then - export GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/service-account.json" - fi - - export GOOGLE_CLOUD_PROJECT="bigframes-testing" - NOX_SESSION=("cleanup" "doctest") - - cd "${package_path}" - python3 -m nox -s "${NOX_SESSION[@]}" -else - echo "No changes in ${package_name} and not a continuous build, skipping." -fi \ No newline at end of file diff --git a/scripts/templates/bigframes_series_accessor.py.j2 b/scripts/templates/bigframes_series_accessor.py.j2 deleted file mode 100644 index 8ce37d67321..00000000000 --- a/scripts/templates/bigframes_series_accessor.py.j2 +++ /dev/null @@ -1,44 +0,0 @@ -{% include 'license.py.j2' %} - -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: {{ script_path }} -# - -from __future__ import annotations - -from typing import cast, Optional, TypeVar - -from bigframes.core.logging import log_adapter -from bigframes.extensions.core import series_accessor as core_accessor -from bigframes import series, dataframe, session - -T = TypeVar("T", bound="dataframe.DataFrame") -S = TypeVar("S", bound="series.Series") - - -{% for ns in namespaces %} -@log_adapter.class_logger -class {{ ns.bigframes_class_name }}(core_accessor.{{ ns.class_name }}[T, S]): - def __init__(self, bf_obj: S): - super().__init__(bf_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - return self._obj - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series) - - {% for child in ns.children %} - @property - def {{ child.prop_name }}(self) -> {{ child.bigframes_class_name }}[T, S]: - return {{ child.bigframes_class_name }}(self._obj) - - {% endfor %} - -{% endfor %} diff --git a/scripts/templates/core_series_accessor.py.j2 b/scripts/templates/core_series_accessor.py.j2 deleted file mode 100644 index 89decdcbe1e..00000000000 --- a/scripts/templates/core_series_accessor.py.j2 +++ /dev/null @@ -1,81 +0,0 @@ -{% include 'license.py.j2' %} - -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: {{ script_path }} -# - -from __future__ import annotations - -import abc -import datetime -from typing import ( - Any, - Literal, - Optional, - TypeVar, - Union, - cast, -) - -from bigframes import series, session -from bigframes.core import col, sentinels -from bigframes.extensions.core import abstract_series_accessor, series_mixins - -T = TypeVar("T") -S = TypeVar("S") - - -{% for ns in namespaces %} -{% if ns.class_name == "AiSeriesAccessor" %} -class {{ ns.class_name }}(series_mixins.AIMixin[T, S]): -{% else %} -class {{ ns.class_name }}(abstract_series_accessor.AbstractBigQuerySeriesAccessor[T, S]): -{% endif %} - """{{ ns.description }}""" - - {% for child in ns.children %} - @property - @abc.abstractmethod - def {{ child.prop_name }}(self) -> {{ child.class_name }}[T, S]: - """Accessor for BigQuery {{ child.prop_name }} functions.""" - - {% endfor %} - {% for func in ns.functions %} - def {{ func.name }}( - self, - {% for arg in func.args if arg.name != func.series_accessor_arg %} - {{ arg.name }}: Union[series.Series, col.Expression, {{ arg.type_hint }}]{% if arg.default %} = {{ arg.default }}{% endif %}, - {% endfor %} - *, - session: Optional[session.Session] = None, - ) -> S: - """{{ func.description | indent(8) }}""" - from {{ func.import_module }} import {{ func.name }} as {{ func.name }}_impl - {% if func.args | length > 1 %} - - # Resolve session from other arguments if not passed - if session is None: - from bigframes.core import googlesql - session = googlesql._find_session( - {% for arg in func.args if arg.name != func.series_accessor_arg %} - {{ arg.name }}, - {% endfor %} - ) - {% endif %} - - bf_series = self._bf_from_series(session) - result = {{ func.name }}_impl( - {% for arg in func.args %} - {% if arg.name == func.series_accessor_arg %} - bf_series, - {% else %} - {{ arg.name }}, - {% endif %} - {% endfor %} - ) - return self._to_series(cast(series.Series, result)) - - {% endfor %} - -{% endfor %} diff --git a/scripts/templates/license.py.j2 b/scripts/templates/license.py.j2 deleted file mode 100644 index 58d482ea386..00000000000 --- a/scripts/templates/license.py.j2 +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/scripts/templates/operation.py.j2 b/scripts/templates/operation.py.j2 deleted file mode 100644 index 720d867986e..00000000000 --- a/scripts/templates/operation.py.j2 +++ /dev/null @@ -1,50 +0,0 @@ -{% include 'license.py.j2' %} - -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: {{ yaml_path }} -# by the script: {{ script_path }} - -from __future__ import annotations - -import datetime -import decimal -from typing import Any, Literal, Optional, TypeVar, Union - -from bigframes import dtypes -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.core.googlesql -import bigframes.core.sentinels as sentinels -from bigframes.operations import googlesql -import bigframes.operations as ops -import bigframes.series as series - -{% for op in ops %} -{% if op.signature_definition %} -{{ op.signature_definition }} - - -{% endif %} -{{ op.internal_name }} = googlesql.GoogleSqlScalarOp( - "{{ op.sql_name }}", - args=({{ op.arg_specs }}), - signature={{ op.signature }}, -) -{% endfor %} -{% for func in functions %} - - -def {{ func.name }}( -{% for arg in func.args %} - {{ arg.name }}: Union[series.Series, bigframes.core.col.Expression, {{ arg.type_hint }}]{% if arg.default %} = {{ arg.default }}{% endif %}, -{% endfor %} -) -> Union[series.Series, bigframes.core.col.Expression]: - """{{ func.description | indent(4) }}""" - return bigframes.core.googlesql.apply_googlesql_scalar_op( - {{ func.op_name }}, -{% for arg in func.args %} - {{ arg.name }}, -{% endfor %} - ) -{% endfor %} diff --git a/scripts/templates/pandas_series_accessor.py.j2 b/scripts/templates/pandas_series_accessor.py.j2 deleted file mode 100644 index 15054665561..00000000000 --- a/scripts/templates/pandas_series_accessor.py.j2 +++ /dev/null @@ -1,53 +0,0 @@ -{% include 'license.py.j2' %} - -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated by the script: {{ script_path }} -# - -from __future__ import annotations - -from typing import cast, Optional, TypeVar - -import pandas -import pandas.api.extensions - -from bigframes import dataframe, series, session -from bigframes.core import global_session as bf_session -from bigframes.core.logging import log_adapter -from bigframes.extensions.core import series_accessor as core_accessor - -T = TypeVar("T", bound="pandas.DataFrame") -S = TypeVar("S", bound="pandas.Series") - - -{% for ns in namespaces %} -{% if ns.is_root %} -@pandas.api.extensions.register_series_accessor("bigquery") -{% endif %} -@log_adapter.class_logger -class {{ ns.pandas_class_name }}(core_accessor.{{ ns.class_name }}[T, S]): - def __init__(self, pandas_obj: S): - super().__init__(pandas_obj) - - def _bf_from_series( - self, session: Optional[session.Session] = None - ) -> series.Series: - if session is None: - session = bf_session.get_global_session() - return cast(series.Series, session.read_pandas(self._obj)) - - def _to_dataframe(self, bf_df: dataframe.DataFrame) -> T: - return cast(T, bf_df.to_pandas(ordered=True)) - - def _to_series(self, bf_series: series.Series) -> S: - return cast(S, bf_series.to_pandas(ordered=True)) - - {% for child in ns.children %} - @property - def {{ child.prop_name }}(self) -> {{ child.pandas_class_name }}[T, S]: - return {{ child.pandas_class_name }}(self._obj) - - {% endfor %} - -{% endfor %} diff --git a/scripts/templates/signature_def.py.j2 b/scripts/templates/signature_def.py.j2 deleted file mode 100644 index b00c95e3383..00000000000 --- a/scripts/templates/signature_def.py.j2 +++ /dev/null @@ -1,76 +0,0 @@ -def {{ func_name }}(*args): - # Pad args with None to match max expected args - args = args + (None,) * ({{ max_args }} - len(args)) - {% for impl in impls %} - # Try matching impl {{ loop.index0 }} - {% if impl.requires_generic_types %} - any1_val = None - {% endif %} - match_ok = True - {% for arg in impl.args %} - {% set idx = loop.index0 %} - if match_ok and args[{{ idx }}] is not None: - {% if arg.value == "any1" %} - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, args[{{ idx }}]) - except TypeError: - match_ok = False - else: - any1_val = args[{{ idx }}] - {% elif arg.value.startswith("list<") and arg.value.endswith(">") %} - {% set inner_type = arg.value[5:-1] %} - if not dtypes.is_array_like(args[{{ idx }}]): - match_ok = False - else: - inner = dtypes.get_array_inner_type(args[{{ idx }}]) - {% if inner_type == "any1" %} - if any1_val is not None: - try: - any1_val = dtypes.coerce_to_common(any1_val, inner) - except TypeError: - match_ok = False - else: - any1_val = inner - {% else %} - {% set dtype_expr = dtype_map[inner_type] %} - try: - if dtypes.coerce_to_common(inner, {{ dtype_expr }}) != {{ dtype_expr }}: - match_ok = False - except TypeError: - match_ok = False - {% endif %} - {% elif arg.value == "struct" %} - if not dtypes.is_struct_like(args[{{ idx }}]): - match_ok = False - {% else %} - {% set dtype_expr = dtype_map[arg.value] %} - try: - if dtypes.coerce_to_common(args[{{ idx }}], {{ dtype_expr }}) != {{ dtype_expr }}: - match_ok = False - except TypeError: - match_ok = False - {% endif %} - {% endfor %} - if match_ok: - {% set return_type_yaml = impl.return_type %} - {% if return_type_yaml == "any1" %} - return any1_val - {% elif return_type_yaml.startswith("list<") and return_type_yaml.endswith(">") %} - {% set inner_type = return_type_yaml[5:-1] %} - {% if inner_type == "any1" %} - if any1_val is not None: - return dtypes.list_type(any1_val) - else: - return None - {% else %} - {% set dtype_expr = dtype_map[inner_type] %} - return dtypes.list_type({{ dtype_expr }}) - {% endif %} - {% else %} - {% set dtype_expr = dtype_map[return_type_yaml] %} - return {{ dtype_expr }} - {% endif %} - - {% endfor %} - raise TypeError(f"Could not find matching signature for {{ sql_name }} with argument types: {[str(t) for t in args]}") diff --git a/scripts/templates/test_operation.py.j2 b/scripts/templates/test_operation.py.j2 deleted file mode 100644 index 6aee365cded..00000000000 --- a/scripts/templates/test_operation.py.j2 +++ /dev/null @@ -1,44 +0,0 @@ -{% include 'license.py.j2' %} - -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: {{ yaml_path }} -# by the script: {{ script_path }} - -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.pandas as bpd -import {{ import_path }} as {{ short_name }}_op -import bigframes.bigquery as bbq - - -{% for func in functions %} -def test_{{ func.name }}_expression(): - # Call the function with col() expressions -{% if is_global %} - result = bbq.{{ func.name }}( -{% else %} - result = bbq.{{ short_name }}.{{ func.name }}( -{% endif %} - {% for arg in func.args %} - bpd.col("{{ arg.name }}"), - {% endfor %} - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == {{ short_name }}_op.{{ func.op_name }} - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == {{ func.args | length }} - {% for arg in func.args %} - assert isinstance(expr.inputs[{{ loop.index0 }}], ex.UnboundVariableExpression) - assert expr.inputs[{{ loop.index0 }}].id == "{{ arg.name }}" - {% endfor %} - - -{% endfor %} diff --git a/scripts/test_publish_api_coverage.py b/scripts/test_publish_api_coverage.py index 167cf5917b0..6e366b6854e 100644 --- a/scripts/test_publish_api_coverage.py +++ b/scripts/test_publish_api_coverage.py @@ -15,8 +15,8 @@ import sys import pandas -import pytest from publish_api_coverage import build_api_coverage_table +import pytest pytest.importorskip("sklearn") @@ -31,8 +31,10 @@ def api_coverage_df(): reason="Issues with installing sklearn for this test in python 3.13", ) def test_api_coverage_produces_expected_schema(api_coverage_df): - # Older pandas has different timestamp default precision - pytest.importorskip("pandas", minversion="2.0.0") + if sys.version.split(".")[:2] == ["3", "9"]: + pytest.skip( + "Python 3.9 uses older pandas without good microsecond timestamp support." + ) pandas.testing.assert_series_equal( api_coverage_df.dtypes, @@ -54,8 +56,6 @@ def test_api_coverage_produces_expected_schema(api_coverage_df): "release_version": "string", }, ), - # String dtype behavior not consistent across pandas versions - check_dtype=False, ) diff --git a/scripts/tpch_result_verify.py b/scripts/tpch_result_verify.py new file mode 100644 index 00000000000..0c932f6eac8 --- /dev/null +++ b/scripts/tpch_result_verify.py @@ -0,0 +1,128 @@ +# 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 argparse +import os +import re + +from google.cloud import bigquery +import pandas as pd +from tqdm import tqdm + +import bigframes + +project_id = "bigframes-dev-perf" +dataset_id = "tpch_0001g" +dataset = { + "line_item_ds": f"bigframes-dev-perf.{dataset_id}.LINEITEM", + "region_ds": f"bigframes-dev-perf.{dataset_id}.REGION", + "nation_ds": f"bigframes-dev-perf.{dataset_id}.NATION", + "supplier_ds": f"bigframes-dev-perf.{dataset_id}.SUPPLIER", + "part_ds": f"bigframes-dev-perf.{dataset_id}.PART", + "part_supp_ds": f"bigframes-dev-perf.{dataset_id}.PARTSUPP", + "customer_ds": f"bigframes-dev-perf.{dataset_id}.CUSTOMER", + "orders_ds": f"bigframes-dev-perf.{dataset_id}.ORDERS", +} + + +def _execute_query(query): + client = bigquery.Client() + job_config = bigquery.QueryJobConfig(use_query_cache=False) + query_job = client.query(query, job_config=job_config) + query_job.result() + df = query_job.to_dataframe() + df.columns = df.columns.str.upper() + return df + + +def _initialize_session(ordered: bool): + context = bigframes.BigQueryOptions( + location="US", ordering_mode="strict" if ordered else "partial" + ) + session = bigframes.Session(context=context) + return session + + +def _verify_result(bigframes_query, sql_result): + exec_globals = {"_initialize_session": _initialize_session} + exec(bigframes_query, exec_globals) + bigframes_result = exec_globals.get("result") + if isinstance(bigframes_result, pd.DataFrame): + pd.testing.assert_frame_equal( + sql_result.reset_index(drop=True), + bigframes_result.reset_index(drop=True), + check_dtype=False, + ) + else: + assert sql_result.shape == (1, 1) + sql_scalar = sql_result.iloc[0, 0] + assert sql_scalar == bigframes_result + + +def verify(query_num=None): + range_iter = range(1, 23) if query_num is None else [query_num] + for i in tqdm(range_iter, desc="Processing queries"): + if query_num is not None and i != query_num: + continue + + # Execute SQL: + sql_file_path = f"third_party/bigframes_vendored/tpch/sql_queries/q{i}.sql" + with open(sql_file_path, "r") as f: + sql_query = f.read() + sql_query = sql_query.format(**dataset) + file_path = f"third_party/bigframes_vendored/tpch/queries/q{i}.py" + if os.path.exists(file_path): + with open(file_path, "r") as file: + file_content = file.read() + + file_content = re.sub( + r"next\((\w+)\.to_pandas_batches\((.*?)\)\)", + r"return \1.to_pandas()", + file_content, + ) + file_content = re.sub(r"_\s*=\s*(\w+)", r"return \1", file_content) + sql_result = _execute_query(sql_query) + + print(f"Checking {file_path} in ordered session") + bigframes_query = ( + file_content + + f"\nresult = q('{project_id}', '{dataset_id}', _initialize_session(ordered=True))" + ) + _verify_result(bigframes_query, sql_result) + + print(f"Checking {file_path} in unordered session") + bigframes_query = ( + file_content + + f"\nresult = q('{project_id}', '{dataset_id}', _initialize_session(ordered=False))" + ) + _verify_result(bigframes_query, sql_result) + + else: + raise FileNotFoundError(f"File {file_path} not found.") + + +if __name__ == "__main__": + """ + Runs verification of TPCH benchmark script outputs to ensure correctness for a specified query or all queries + with 1GB dataset. + + Example: + python scripts/tpch_result_verify.py -q 15 # Verifies TPCH query number 15 + python scripts/tpch_result_verify.py # Verifies all TPCH queries from 1 to 22 + """ + parser = argparse.ArgumentParser() + parser.add_argument("-q", "--query_number", type=int, default=None) + args = parser.parse_args() + + verify(args.query_number) diff --git a/setup.py b/setup.py index e2717fbe5e4..2aef5147493 100644 --- a/setup.py +++ b/setup.py @@ -33,33 +33,33 @@ # 'Development Status :: 5 - Production/Stable' release_status = "Development Status :: 5 - Production/Stable" dependencies = [ - # please keep these in sync with the minimum versions in testing/constraints-3.10.txt + # 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, !=2025.5.0, !=2026.2.0, !=2026.3.0", + "gcsfs >=2023.3.0, !=2025.5.0", "geopandas >=0.12.2", - "google-auth[pyopenssl] >=2.15.0,<3.0", - "google-cloud-bigquery[bqstorage,pandas] >=3.36.0", + "google-auth >=2.15.0,<3.0", + "google-cloud-bigquery[bqstorage,pandas] >=3.31.0", # 2.30 needed for arrow support. "google-cloud-bigquery-storage >= 2.30.0, < 3.0.0", - "google-cloud-functions >=1.20.2", - "google-cloud-bigquery-connection >=1.18.2", - "google-cloud-resource-manager >=1.14.2", + "google-cloud-functions >=1.12.0", + "google-cloud-bigquery-connection >=1.12.0", + "google-cloud-resource-manager >=1.10.3", "google-cloud-storage >=2.0.0", - "google-crc32c >=1.0.0,<2.0.0", "grpc-google-iam-v1 >= 0.14.2", "numpy >=1.24.0", "pandas >=1.5.3", "pandas-gbq >=0.26.1", - "pyarrow >=23.0.1", + "pyarrow >=15.0.2", "pydata-google-auth >=1.8.2", "requests >=2.27.1", "shapely >=1.8.5", + "sqlglot >=23.6.3", "tabulate >=0.9", + "ipywidgets >=7.7.1", "humanize >=4.6.0", "matplotlib >=3.7.1", "db-dtypes >=1.4.2", - "pyiceberg >= 0.7.1", # For vendored ibis-framework. "atpublic>=2.3,<6", "python-dateutil>=2.8.2,<3", @@ -73,9 +73,8 @@ "tests": [ "freezegun", "pytest-snapshot", - "google-cloud-bigtable >=2.30.0", - "google-cloud-pubsub >=2.29.0", - "tzdata", + "google-cloud-bigtable >=2.24.0", + "google-cloud-pubsub >=2.21.4", ], # used for local engine "polars": ["polars >= 1.21.0"], @@ -124,29 +123,23 @@ name=name, version=version_id, description=description, - download_url="https://github.com/googleapis/google-cloud-python/tree/main/packages/bigframes/releases", long_description=readme, long_description_content_type="text/x-rst", author="Google LLC", author_email="bigframes-feedback@google.com", license="Apache 2.0", - url="https://dataframes.bigquery.dev", - project_urls={ - "Source": "https://github.com/googleapis/google-cloud-python/tree/main/packages/bigframes", - "Changelog": "https://dataframes.bigquery.dev/changelog.html", - "Issues": "https://github.com/googleapis/google-cloud-python/tree/main/packages/bigframes/issues", - }, + url="https://github.com/googleapis/python-bigquery-dataframes", classifiers=[ release_status, "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", - "Programming Language :: Python :: 3.14", "Operating System :: OS Independent", "Topic :: Internet", ], @@ -158,7 +151,7 @@ "bigframes_vendored": "third_party/bigframes_vendored", }, packages=packages, - python_requires=">=3.10", + python_requires=">=3.9", include_package_data=True, zip_safe=False, ) diff --git a/specs/2025-08-04-geoseries-scalars.md b/specs/2025-08-04-geoseries-scalars.md index e7bc6c61e19..38dc77c4cfb 100644 --- a/specs/2025-08-04-geoseries-scalars.md +++ b/specs/2025-08-04-geoseries-scalars.md @@ -261,23 +261,17 @@ Raster functions: Functions for analyzing geospatial rasters using geographies. ### 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. + - [ ] In `bigframes/operations/geo_ops.py`, create a new dataclass inheriting from `base_ops.UnaryOp` or `base_ops.BinaryOp`. - [ ] 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`. + - [ ] 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`. - [ ] **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`). @@ -286,17 +280,13 @@ Raster functions: Functions for analyzing geospatial rasters using geographies. - [ ] 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. + - [ ] In `bigframes/geopandas/geoseries.py`, create the property or method. - [ ] 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`. + `NotImplementedError` with a helpful message. - [ ] 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 a comprehensive docstring with examples. - [ ] **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. diff --git a/specs/bigframes-bigquery-contributing.md b/specs/bigframes-bigquery-contributing.md deleted file mode 100644 index 10931af0755..00000000000 --- a/specs/bigframes-bigquery-contributing.md +++ /dev/null @@ -1,501 +0,0 @@ -# bigframes.bigquery inputs and outputs policies - -The goal of the [bigframes.bigquery -APIs](https://dataframes.bigquery.dev/reference/api/bigframes.bigquery.html#module-bigframes.bigquery) -is to provide the simplest possible mapping from BigQuery (GoogleSQL) -[functions](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/functions-all) -and -[operations](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax) -to Python. "Simplest" is somewhat ambiguous though, when it comes to the types -involved and behaviors, so this document aims to expand on that vision with -specific examples. - -## SQL and BigFrames expression types - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
SQL expression type(s) - Python type(s) - Notes - Examples -
Column expression (usable in a SELECT clause) - - - Both Python Series and column expression should be supported as inputs, - with the output reflecting the users input. Use a TypeVar - rather than directly using union types to make type checking easier. -

-Special considerations for Series inputs: -

-If an input and output are both a Series with the same number of rows, make sure -the output Series is implicitly (row identity) alignable with the original -input. In other words, don't generate a table expression. -

-If there are multiple Series inputs, they should be implicitly aligned if -possible so as not to generate unnecessary table expressions. -

Most scalar functions accept one or more column expressions as input. -
Scalar values - - - Theoretically, we could try to get the type system to help the user - disambiguate between this case and the "Column expression" case, but I think - that's more trouble than it it's worth with regards to the expectations of - Python users. - - -
Table expression - bpd.DataFrame -

-All columns are included as normal columns in the input table expression, -including named index columns. If column names aren't unique or contain -characters not compatible with BigQuery flexible column names, raise an error. -

-Outputs are unordered and unindexed to allow for cleaner mapping with SQL. -

Most APIs that take a table expression as input, also output a table - expression with the same number of rows and passing through all unused - columns. - -

This should be used to pass through any index or ordering columns (as well - as all other columns, if that's the SQL behavior), to allow for easy joining - with the original input DataFrame. -

Same number of rows as the input, so we should preserve index and ordering: - - - -

- Different number of rows in output, so no need to preserve index or ordering. - Default index / ordering should be specified with the Session's - configuration: - -

- -

- Possible to have the same number of rows as the input, but joining with the original goes against the purpose of the feature: - -

- -
Table name - string (referring to fully-qualified table ID, e.g. project.dataset.table / project.catalog.namespace.table) - Some SQL APIs do not support or have limitations with arbitrary table expressions, instead taking in a table ID, such as TABLESAMPLE expression. -

-Also, SEARCH and VECTOR_SEARCH, if you want the indexes attached to the table to actually apply. -

-For outputs, it might be preferable to output a table ID instead of a DataFrame, if the user is explicitly creating a table. For example, to_gbq() returns a string with the table name, which is useful for the case where BigFrame generates the table ID for the user. -

All of the items from the "Table expression" row above. APIs that require a table expression, but don't take a table ID can trivially take a table ID through a (SELECT * FROM table) subquery. -

-Some APIs only take a table ID and not an arbitrary table expression:

- -
Aggregated table expression - DataFrameGroupBy - - - -
Analytic table expression -
    - -
  • DataFrameGroupBy - feasibility TBD -
  • Deferred column Expression with a Window applied.
- -
- - -
Column name (unqualified*) \ - \ -*I've only encountered examples where the table name / table expression is passed in separately. - string, -

-For cases where the column name is used as an alias and we aren't using named Series: -

-dict[str, Expression] -

Often a table expression input is paired with a column name input, as is the case with the CREATE MODEL and VECTOR_SEARCH APIs -

-If SQL expects a column name rather than a column expression, do not attempt to change this in Python. For example, don't allow a Series as a substitute for DataFrames + Column name. \ - \ -If the associated table expression is input as a DataFrame, validate that these map cleanly to SQL and raise a ValueError if not. For example: \ -

    - -
  • Duplicate column names (excluding unnamed index columns). -
  • Column names that are some hashable value other than integer (which maps cleanly to a column name) or string. -
  • Any column name containing a punctuation mark that is not allowed by BigQuery flexible column names, such as ! or $.
- -
- -
Literal values - corresponding literal Python value (e.g. int, float, string) - For cases where scalar values are also supported, it should be safe to start with this and then expand to support expressions without a breaking change, as is done in https://github.com/googleapis/google-cloud-python/pull/16606. - Most scalar functions accept one or more literal values as input. -
Scalar subqueries - Not supported yet, except implicitly in some aggregation use cases. -

-Would need some sort of bigframes deferred expression that can be tied to a table expression. -

-(Possibly DataFrame with 1 column?) -

- -
- -## Python policies - -### Naming - -Take the SQL function name, keyword name (used as a function name in Python), or argument name and transform them to lower_snake_case to reflect Python conventions. - -### Internal expressions - -Prefer creating deferred BigFrames expression objects where feasible. For -example, all scalar outputting functions should return a -`bigframes.pandas.Series` or `bigframes.core.col.Expression` that wraps a -`bigframes.core.expression.Expression`. - -Prefer returning a `bigframes.pandas.DataFrame` that wraps a -`bigframes.bigframes.core.bigframe_node.BigFrameNode`. See `from_bq_data_source` in -`bigframes.core.array_value.ArrayValue`, as an example. - -Exceptions to this are cases where the output schema is likely to evolve or -differ in ways that are difficult to model, such as the `ML.PREDICT` SQL -function, where output columns differ based on the model type and support for -model types are frequently added to BigQuery. In these exceptional cases, the -generated query should run immediately and the returned value should wrap the -results. - -### Argument syntax details - -Arguments in Python can be one of: - -* Positional - * Supported by `*args` in Python, but not recommended. Positional arguments in SQL should map to named positional or keyword arguments in Python. -* Positional or keyword - * Required positional arguments should be positional, just like they are in SQL. -* Keyword-only - * All other arguments should be keyword-only. Use `, * ,` Python syntax to achieve this. - -For optional parameters, use an optional sentinel (see: ) and omit the value from the generated SQL if the user doesn't explicitly provide one. This ensures that an explicit NULL / None value can be passed in. - -``` - -from enum import Enum - -class Default(Enum): - token = 0 - -DEFAULT = Default.token - -def spam(*, ham: list[str] | None | Default = DEFAULT): - op_kwargs = {} - - if ham is not DEFAULT: - op_kwargs['ham'] = "prosciutto" - - ... - -``` - -### Scalar operations types policies - -Many operations output a table expression. For these, the output type is always a DataFrame, regardless of the input types. - -For scalar operations, there are three cases to consider when determining the output types: - - - - - - - - - - - - - - - - - - -
Scalar ops - Input type(s) - Scalar ops - Output type -
Expression - Expression -
Series / DataFrame - Series / DataFrame -

-Preserve ordering and index(es). Join inputs as needed before applying the operation. -

Mix of Expression and Series / DataFrame - Series / DataFrame -

-Preserve ordering and index(es). Join inputs as needed before applying the operation. -

- -## Examples - -### PIVOT SQL operator - -SQL syntax ([docs](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#pivot_operator)): - -``` -FROM from_item[, ...] pivot_operator - -pivot_operator: - PIVOT( - aggregate_function_call [as_alias][, ...] - FOR input_column - IN ( pivot_column [as_alias][, ...] ) - ) [AS alias] - -as_alias: - [AS] alias - -``` - -SQL example: - -``` -WITH Produce AS ( - SELECT 'Kale' as product, 51 as sales, 'Q1' as quarter, 2020 as year UNION ALL - SELECT 'Kale', 23, 'Q2', 2020 UNION ALL - SELECT 'Kale', 45, 'Q3', 2020 UNION ALL - SELECT 'Kale', 3, 'Q4', 2020 UNION ALL - SELECT 'Kale', 70, 'Q1', 2021 UNION ALL - SELECT 'Kale', 85, 'Q2', 2021 UNION ALL - SELECT 'Apple', 77, 'Q1', 2020 UNION ALL - SELECT 'Apple', 0, 'Q2', 2020 UNION ALL - SELECT 'Apple', 1, 'Q1', 2021) -SELECT * FROM Produce - -/*---------+-------+---------+------+ - | product | sales | quarter | year | - +---------+-------+---------+------| - | Kale | 51 | Q1 | 2020 | - | Kale | 23 | Q2 | 2020 | - | Kale | 45 | Q3 | 2020 | - | Kale | 3 | Q4 | 2020 | - | Kale | 70 | Q1 | 2021 | - | Kale | 85 | Q2 | 2021 | - | Apple | 77 | Q1 | 2020 | - | Apple | 0 | Q2 | 2020 | - | Apple | 1 | Q1 | 2021 | - +---------+-------+---------+------*/ - - -SELECT * FROM - Produce - PIVOT(SUM(sales) FOR quarter IN ('Q1', 'Q2', 'Q3', 'Q4')) - -/*---------+------+----+------+------+------+ - | product | year | Q1 | Q2 | Q3 | Q4 | - +---------+------+----+------+------+------+ - | Apple | 2020 | 77 | 0 | NULL | NULL | - | Apple | 2021 | 1 | NULL | NULL | NULL | - | Kale | 2020 | 51 | 23 | 45 | 3 | - | Kale | 2021 | 70 | 85 | NULL | NULL | - +---------+------+----+------+------+------*/ - -``` - -Python definition: - -``` -def pivot( - table_expression: bpd.DataFrame, - *, - aggregation: Expression | dict[str, Expression], - input_column: str, - pivot_columns: dict[str, float | str | ...] | Sequence[float | str | ...], -) -> bpd.DataFrame: - ... -``` - -Since pivot creates a table expression, we run immediately. - - \ -Python usage: - -``` -pivotted = bbq.pivot( - my_produce_dataframe, - aggregation=bpd.col("sales").sum(), - input_column="quarter", - pivot_columns=["Q1", "Q2", "Q3", "Q4"], -) -``` - -### UNPIVOT SQL operator - -SQL syntax ([docs](https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#unpivot_operator)): - -``` -FROM from_item[, ...] unpivot_operator - -unpivot_operator: - UNPIVOT [ { INCLUDE NULLS | EXCLUDE NULLS } ] ( - { single_column_unpivot | multi_column_unpivot } - ) [unpivot_alias] - -single_column_unpivot: - values_column - FOR name_column - IN (columns_to_unpivot) - -multi_column_unpivot: - values_column_set - FOR name_column - IN (column_sets_to_unpivot) - -values_column_set: - (values_column[, ...]) - -columns_to_unpivot: - unpivot_column [row_value_alias][, ...] - -column_sets_to_unpivot: - (unpivot_column [row_value_alias][, ...]) - -unpivot_alias and row_value_alias: - [AS] alias -``` - -SQL example: - -``` -WITH Produce AS ( - SELECT 'Kale' as product, 51 as Q1, 23 as Q2, 45 as Q3, 3 as Q4 UNION ALL - SELECT 'Apple', 77, 0, 25, 2) - --- SELECT * FROM Produce -/*---------+----+----+----+----+ - | product | Q1 | Q2 | Q3 | Q4 | - +---------+----+----+----+----+ - | Kale | 51 | 23 | 45 | 3 | - | Apple | 77 | 0 | 25 | 2 | - +---------+----+----+----+----*/ - -SELECT * FROM Produce -UNPIVOT(sales FOR quarter IN (Q1, Q2, Q3, Q4)) -- single_column_unpivot - -/*---------+-------+---------+ - | product | sales | quarter | - +---------+-------+---------+ - | Kale | 51 | Q1 | - | Kale | 23 | Q2 | - | Kale | 45 | Q3 | - | Kale | 3 | Q4 | - | Apple | 77 | Q1 | - | Apple | 0 | Q2 | - | Apple | 25 | Q3 | - | Apple | 2 | Q4 | - +---------+-------+---------*/ -``` - -Python definition: - -``` -def unpivot( - table_expression: bpd.DataFrame, - *, - exclude_nulls: bool = True, - values_column: str | Sequence[str], - name_column: str, - columns_to_unpivot: dict[str, str | int] | Sequence[str], -) -> bpd.DataFrame: - ... -``` - -Since unpivot creates a table expression, we run immediately. - - \ -Python usage: - -``` -unpivotted = bbq.unpivot( - my_produce_dataframe, - values_column="sales", - name_column="quarter", - columns_to_unpivot=["Q1", "Q2", "Q3", "Q4"], -) -``` diff --git a/specs/bigframes-bigquery-generator.md b/specs/bigframes-bigquery-generator.md deleted file mode 100644 index 1078bbd05a3..00000000000 --- a/specs/bigframes-bigquery-generator.md +++ /dev/null @@ -1,100 +0,0 @@ -# Code generation for bigframes.bigquery - -This document describes code generation for the `bigframes.bigquery` modules. -For detailed specifications on input and output types, refer to -[Contributing to bigframes.bigquery](./bigframes-bigquery-contributing.md). - -## Overview - -The script at `packages/bigframes/scripts/generate_bigframes_bigquery.py` -generates python submodules for the `bigframes.bigquery` module. When run -without any arguments, it iterates through all yaml files at -`packages/bigframes/scripts/data/sql-functions/**/*.yaml` to generate the code. - -The script also generates a unit test that verifies that the functions have been -included in the `bigframes.bigquery` module, which is important to check, as the -`__init__.py` file requires manual updates. - -## Running the generator - -Since the dependencies for the script differ from that of bigframes -and its test suite, use the self-contained Python script technique described at -https://docs.astral.sh/uv/guides/scripts/ -to automatically manage dependencies using `uv`. Therefore, the header of the -script will look something like: - -```python -#!/usr/bin/env -S uv run --script -# -# /// script -# dependencies = [ -# "jinja2", -# "pyyaml", -# ] -# /// -# -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# ... -``` - -To run the script: - -```bash -cd packages/bigframes -uv run scripts/generate_bigframes_bigquery.py -``` - -To improve reproducibility, we also check in the uv lock file generated by -running `uv lock --script scripts/generate_bigframes_bigquery.py`. - -## Generated code organization - -The `generate_bigframes_bigquery.py` script generates submodules of -`bigframes.bigquery._operations`, with the full path reflecting the organization -of the YAML files. For example, a YAML file at -`packages/bigframes/scripts/data/sql-functions/aead.yaml` corresponds to a -generated Python module at `bigframes.bigquery._operations.aead`. Likewise, -`packages/bigframes/scripts/data/sql-functions/builtins/bit.yaml` corresponds -to the `bigframes.bigquery._operations.builtins.bit` submodule. - -## Generated module implementation - -Each generated module has all functions defined in the YAML file converted to -the equivalent Python definition, including keyword arguments and docstrings. - -### Code generation - -The code will be templated using the jinja2 template engine. This allows -proposed changes to the templated code to be reviewed more easily. - -### Handling optional arguments - -When the user calls a Python function without specifying the optional -argument, that argument is omitted from the SQL text. To allow for explicit -NULL values to be passed in (None in Python), the default value is specified -to be a default sentinel value enum `bigframes.core.sentinels.DEFAULT`. For -example: - -```python -import bigframes.core.sentinels - -def current_date( - time_zone_expression: str | bigframes.core.sentinels.Default = bigframes.core.sentinels.DEFAULT, -): - ... -``` - -### Input and output types - -Refer to the table in -[Contributing to bigframes.bigquery](./bigframes-bigquery-contributing.md). - -### Internal bigframes operator - -Scalar functions should generate an expression using the `GoogleSqlScalarOp`. -This keeps the implementation as scalar SQL functions consistent. - -Aggregate, analytic, and table-valued functions currently require custom ops. As -such, those functions are currently out of scope for this generator. diff --git a/testing/constraints-3.10.txt b/testing/constraints-3.10.txt index 1dcdd64baa0..1695a4806b8 100644 --- a/testing/constraints-3.10.txt +++ b/testing/constraints-3.10.txt @@ -1,124 +1,19 @@ -# Please keep these in sync with the minimum versions in setup.py -cloudpickle==2.0.0 -fsspec==2023.3.0 -gcsfs==2023.3.0 -geopandas==0.12.2 -google-auth==2.15.0 -google-cloud-bigtable==2.30.0 -google-cloud-pubsub==2.29.0 -google-cloud-bigquery==3.36.0 -google-cloud-functions==1.20.2 -google-cloud-bigquery-connection==1.18.2 -google-cloud-iam==2.18.2 -google-cloud-resource-manager==1.14.2 -google-cloud-storage==2.0.0 -grpc-google-iam-v1==0.14.2 -numpy==1.24.0 -pandas==1.5.3 -pandas-gbq==0.26.1 -pyarrow==23.0.1 -pydata-google-auth==1.8.2 -pyiceberg==0.7.1 -requests==2.27.1 -scikit-learn==1.2.2 -shapely==1.8.5 -tabulate==0.9 -humanize==4.6.0 +# 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 +notebook==6.5.5 +pandas==2.1.4 +pandas-stubs==2.1.4.231227 +portpicker==1.5.2 +requests==2.32.3 +tornado==6.3.3 +absl-py==1.4.0 +debugpy==1.6.6 +ipywidgets==7.7.1 matplotlib==3.7.1 -db-dtypes==1.4.2 -# For vendored ibis-framework. -atpublic==2.3 -python-dateutil==2.8.2 -pytz==2022.7 -toolz==0.11 -typing-extensions==4.6.1 -rich==12.4.4 -# For anywidget mode -anywidget>=0.9.18 -traitlets==5.0.0 -# constrained dependencies to give pip a helping hand -aiohappyeyeballs==2.6.1 -aiohttp==3.13.3 -aiosignal==1.4.0 -anywidget==0.9.21 -asttokens==3.0.1 -async-timeout==5.0.1 -attrs==25.4.0 -cachetools==5.5.2 -certifi==2026.1.4 -charset-normalizer==2.0.12 -click==8.3.1 -click-plugins==1.1.1.2 -cligj==0.7.2 -comm==0.2.3 -commonmark==0.9.1 -contourpy==1.3.2 -coverage==7.13.3 -cycler==0.12.1 -db-dtypes==1.4.2 -decorator==5.2.1 -exceptiongroup==1.2.2 -executing==2.2.1 -fiona==1.10.1 -fonttools==4.61.1 -freezegun==1.5.5 -frozenlist==1.8.0 -google-api-core==2.29.0 -google-auth-oauthlib==1.2.4 -google-cloud-bigquery-storage==2.36.0 -google-cloud-core==2.5.0 -google-crc32c==1.8.0 -google-resumable-media==2.8.0 -googleapis-common-protos==1.72.0 -grpc-google-iam-v1==0.14.2 -grpcio==1.74.0 -grpcio-status==1.62.3 -idna==3.11 -iniconfig2.3.0 -ipython==8.21.0 -ipython-genutils==0.2.0 -ipywidgets==8.1.8 -jedi==0.19.2 -joblib==1.5.3 -jupyterlab_widgets==3.0.16 -kiwisolver==1.4.9 -matplotlib-inline==0.2.1 -mock==5.2.0 -moc==5.2.0 -multidict==6.7.1 -oauthlib==3.3.1 -packaging==26.0 -parso==0.8.5 -pexpect==4.9.0 -pillow==12.1.0 -pluggy==1.6.0 -prompt_toolkit==3.0.52 -propcache==0.4.1 -proto-plus==1.27.1 -protobuf==6.33.5 -psygnal==0.15.1 -ptyprocess==0.7.0 -pure_eval==0.2.3 -pyasn1==0.6.2 -pyasn1_modules==0.4.2 -Pygments==2.19.2 -pyparsing==3.3.2 -pyproj==3.7.1 -pytest==8.4.2 -pytest-cov==7.0.0 -pytest-snapshot==0.9.0 -pytest-timeout==2.4.0 -python-dateutil==2.8.2 -requests-oauthlib==2.0.0 -rsa==4.9.1 -scipy==1.15.3 -setuptools==80.9.0 -six==1.17.0 -stack-data==0.6.3 -threadpoolctl==3.6.0 -tomli==2.4.0 -urllib3==1.26.20 -wcwidth==0.6.0 -wheel==0.45.1 -widgetsnbextension==4.0.15 -yarl==1.22.0 +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 17854fda96f..8c274bd9fbf 100644 --- a/testing/constraints-3.11.txt +++ b/testing/constraints-3.11.txt @@ -133,7 +133,7 @@ fsspec==2025.3.0 future==1.0.0 gast==0.6.0 gcsfs==2025.3.0 -GDAL==3.13.1 +GDAL==3.8.4 gdown==5.2.0 geemap==0.35.3 geocoder==1.38.1 @@ -145,7 +145,7 @@ gitdb==4.0.12 GitPython==3.1.45 glob2==0.7 google==2.0.3 -google-ai-generativelanguage==0.6.17 +google-ai-generativelanguage==0.6.15 google-api-core==2.25.1 google-api-python-client==2.177.0 google-auth==2.38.0 @@ -172,7 +172,7 @@ google-pasta==0.2.0 google-resumable-media==2.7.2 googleapis-common-protos==1.70.0 googledrivedownloader==1.1.0 -gradio==6.15.1 +gradio==5.39.0 gradio_client==1.11.0 graphviz==0.21 greenlet==3.2.3 @@ -180,7 +180,7 @@ groovy==0.1.2 grpc-google-iam-v1==0.14.2 grpc-interceptor==0.15.4 grpcio==1.74.0 -grpcio-status==1.72.1 +grpcio-status==1.71.2 grpclib==0.4.8 gspread==6.2.1 gspread-dataframe==4.0.0 @@ -269,7 +269,7 @@ langchain==0.3.27 langchain-core==0.3.72 langchain-text-splitters==0.3.9 langcodes==3.5.0 -langsmith==0.8.18 +langsmith==0.4.10 language_data==1.3.0 launchpadlib==1.10.16 lazr.restfulclient==0.14.4 @@ -303,7 +303,7 @@ mdit-py-plugins==0.4.2 mdurl==0.1.2 miniKanren==1.0.5 missingno==0.5.2 -mistune==3.3.0 +mistune==3.1.3 mizani==0.13.5 mkl==2025.2.0 ml_dtypes==0.5.3 @@ -311,7 +311,7 @@ mlxtend==0.23.4 more-itertools==10.7.0 moviepy==1.0.3 mpmath==1.3.0 -msgpack==1.2.1 +msgpack==1.1.1 multidict==6.6.3 multipledispatch==1.0.0 multiprocess==0.70.16 @@ -401,14 +401,14 @@ prompt_toolkit==3.0.51 propcache==0.3.2 prophet==1.1.7 proto-plus==1.26.1 -protobuf==6.33.5 +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==23.0.1 +pyarrow==18.1.0 pyasn1==0.6.1 pyasn1_modules==0.4.2 pycairo==1.28.0 @@ -444,7 +444,7 @@ pyproj==3.7.1 pyproject_hooks==1.2.0 pyshp==2.3.1 PySocks==1.7.1 -pyspark==3.5.2 +pyspark==3.5.1 pytensor==2.31.7 python-apt==0.0.0 python-box==7.3.2 @@ -506,7 +506,7 @@ sniffio==1.3.1 snowballstemmer==3.0.1 sortedcontainers==2.4.0 soundfile==0.13.1 -soupsieve==2.8.4 +soupsieve==2.7 soxr==0.5.0.post1 spacy==3.8.7 spacy-legacy==3.0.12 @@ -520,6 +520,7 @@ 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 @@ -569,7 +570,7 @@ tornado==6.4.2 tqdm==4.67.1 traitlets==5.7.1 traittypes==0.2.1 -transformers==5.5.0 +transformers==4.54.1 treelite==4.4.1 treescope==0.1.9 triton==3.2.0 diff --git a/testing/constraints-3.9.txt b/testing/constraints-3.9.txt new file mode 100644 index 00000000000..8df3a3a2c36 --- /dev/null +++ b/testing/constraints-3.9.txt @@ -0,0 +1,39 @@ +# please keep these in sync with the minimum versions in setup.py +cloudpickle==2.0.0 +fsspec==2023.3.0 +gcsfs==2023.3.0 +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.31.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 +grpc-google-iam-v1==0.14.2 +numpy==1.24.0 +pandas==1.5.3 +pandas-gbq==0.26.1 +pyarrow==15.0.2 +pydata-google-auth==1.8.2 +requests==2.27.1 +scikit-learn==1.2.2 +shapely==1.8.5 +sqlglot==23.6.3 +tabulate==0.9 +ipywidgets==7.7.1 +humanize==4.6.0 +matplotlib==3.7.1 +db-dtypes==1.4.2 +# For vendored ibis-framework. +atpublic==2.3 +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/read_gbq_colab/aggregate_output.py b/tests/benchmark/read_gbq_colab/aggregate_output.py index e5620d8e16c..cd33ed2640d 100644 --- a/tests/benchmark/read_gbq_colab/aggregate_output.py +++ b/tests/benchmark/read_gbq_colab/aggregate_output.py @@ -26,9 +26,8 @@ def aggregate_output(*, project_id, dataset_id, table_id): 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)) + df.shape + next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) # 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, @@ -44,9 +43,8 @@ def aggregate_output(*, project_id, dataset_id, table_id): .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)) + df_aggregated.shape + next(iter(df_aggregated.to_pandas_batches(page_size=PAGE_SIZE))) if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/filter_output.py b/tests/benchmark/read_gbq_colab/filter_output.py index dc88d313662..b3c91817705 100644 --- a/tests/benchmark/read_gbq_colab/filter_output.py +++ b/tests/benchmark/read_gbq_colab/filter_output.py @@ -31,19 +31,17 @@ def filter_output( 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)) + df.shape + next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) # 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)) + rows, _ = df_filtered.shape # It's possible we don't have any pages at all, since we filtered out all # matching rows. - assert len(first_page.index) <= tr + first_page = next(iter(df_filtered.to_pandas_batches(page_size=PAGE_SIZE))) + assert len(first_page.index) <= rows if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/first_page.py b/tests/benchmark/read_gbq_colab/first_page.py index 33e2a24bd7b..7f8cdb0d51e 100644 --- a/tests/benchmark/read_gbq_colab/first_page.py +++ b/tests/benchmark/read_gbq_colab/first_page.py @@ -28,9 +28,8 @@ def first_page(*, 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)) + df.shape + next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) if __name__ == "__main__": diff --git a/tests/benchmark/read_gbq_colab/last_page.py b/tests/benchmark/read_gbq_colab/last_page.py index 2e485a070a8..7786e2f8bdf 100644 --- a/tests/benchmark/read_gbq_colab/last_page.py +++ b/tests/benchmark/read_gbq_colab/last_page.py @@ -28,9 +28,8 @@ def last_page(*, 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: + df.shape + for _ in df.to_pandas_batches(page_size=PAGE_SIZE): pass diff --git a/tests/benchmark/read_gbq_colab/sort_output.py b/tests/benchmark/read_gbq_colab/sort_output.py index 3044e0c2a32..7933c4472ec 100644 --- a/tests/benchmark/read_gbq_colab/sort_output.py +++ b/tests/benchmark/read_gbq_colab/sort_output.py @@ -28,9 +28,8 @@ def sort_output(*, 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)) + df.shape + next(iter(df.to_pandas_batches(page_size=PAGE_SIZE))) # Simulate the user sorting by a column and visualizing those results sort_column = "col_int64_1" @@ -38,9 +37,8 @@ def sort_output(*, project_id, dataset_id, table_id): 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)) + df_sorted.shape + next(iter(df_sorted.to_pandas_batches(page_size=PAGE_SIZE))) if __name__ == "__main__": diff --git a/tests/data/nested_structs.jsonl b/tests/data/nested_structs.jsonl index 97e230c9197..f57214b0b3c 100644 --- a/tests/data/nested_structs.jsonl +++ b/tests/data/nested_structs.jsonl @@ -1,6 +1,2 @@ -{"id": 1, "person": {"name": "Alice", "age": 30, "address": {"city": "New York", "country": "USA"}}, "bool_col": true, "int64_col": "123456789", "float64_col": 1.25, "string_col": "Hello World", "json_col": {"a": 1, "b": [1, 2]}, "date_col": "2026-06-24", "time_col": "12:34:56.789012", "datetime_col": "2026-06-24 12:34:56.789012", "timestamp_col": "2026-06-24T12:34:56.789012Z", "bytes_col": "SGVsbG8=", "numeric_col": "123456.789", "bignumeric_col": "123456.7890123456789", "geography_col": "POINT(30 10)", "duration_col": "1000"} -{"id": 2, "person": {"name": "", "age": -1, "address": {"city": "", "country": ""}}, "bool_col": false, "int64_col": "-9223372036854775808", "float64_col": "-Infinity", "string_col": "", "json_col": {}, "date_col": "0001-01-01", "time_col": "00:00:00", "datetime_col": "0001-01-02 00:00:00", "timestamp_col": "0001-01-02T00:00:00Z", "bytes_col": "", "numeric_col": "-99999999999999999999999999999.999999999", "bignumeric_col": "-99999999999999999999999999999999999999.99999999999999999999999999999999999999", "geography_col": "POINT(0 0)", "duration_col": "-9223372036854775"} -{"id": 3, "person": {"name": "Very Long Name...", "age": 150, "address": {"city": "City", "country": "Country"}}, "bool_col": true, "int64_col": "9223372036854775807", "float64_col": "Infinity", "string_col": "Unicode: 🚀 Spark ✨", "json_col": {"max": true, "nested": {"val": 999}}, "date_col": "9999-12-31", "time_col": "23:59:59.999999", "datetime_col": "9999-12-31 23:59:59.999999", "timestamp_col": "9999-12-31T23:59:59.999999Z", "bytes_col": "dmVyeSBsb25nIGJ5dGVzIHZhbHVl", "numeric_col": "99999999999999999999999999999.999999999", "bignumeric_col": "99999999999999999999999999999999999999.99999999999999999999999999999999999999", "geography_col": "POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))", "duration_col": "9223372036854775"} -{"id": 4, "person": null, "bool_col": null, "int64_col": null, "float64_col": null, "string_col": null, "date_col": null, "time_col": null, "datetime_col": null, "timestamp_col": null, "bytes_col": null, "numeric_col": null, "bignumeric_col": null, "geography_col": null, "duration_col": null} -{"id": 5, "person": {"name": "Bob", "age": 0, "address": null}, "bool_col": false, "int64_col": "0", "float64_col": "NaN", "string_col": "Line 1\nLine 2\n\"Quotes\"", "json_col": [1, "two", null], "date_col": "1970-01-01", "time_col": "12:00:00", "datetime_col": "1970-01-01 12:00:00", "timestamp_col": "1970-01-01T12:00:00Z", "bytes_col": "AA==", "numeric_col": "0", "bignumeric_col": "0", "geography_col": "LINESTRING(0 0, 1 1, 2 2)", "duration_col": "0"} -{"id": 6, "person": null, "bool_col": null, "int64_col": null, "float64_col": null, "string_col": null, "json_col": null, "date_col": null, "time_col": null, "datetime_col": null, "timestamp_col": null, "bytes_col": null, "numeric_col": null, "bignumeric_col": null, "geography_col": null, "duration_col": null} +{"id": 1, "person": {"name": "Alice", "age":30, "address": {"city": "New York", "country": "USA"}}} +{"id": 2, "person": {"name": "Bob", "age":25, "address": {"city": "London", "country": "UK"}}} \ No newline at end of file diff --git a/tests/data/nested_structs_schema.json b/tests/data/nested_structs_schema.json index 06e4a3e5275..6692615ceff 100644 --- a/tests/data/nested_structs_schema.json +++ b/tests/data/nested_structs_schema.json @@ -7,7 +7,6 @@ { "name": "person", "type": "RECORD", - "mode": "NULLABLE", "fields": [ { "name": "name", @@ -22,7 +21,6 @@ { "name": "address", "type": "RECORD", - "mode": "NULLABLE", "fields": [ { "name": "city", @@ -37,76 +35,5 @@ ] } ] - }, - { - "name": "bool_col", - "type": "BOOLEAN", - "mode": "NULLABLE" - }, - { - "name": "int64_col", - "type": "INTEGER", - "mode": "NULLABLE" - }, - { - "name": "float64_col", - "type": "FLOAT", - "mode": "NULLABLE" - }, - { - "name": "string_col", - "type": "STRING", - "mode": "NULLABLE" - }, - { - "name": "json_col", - "type": "JSON", - "mode": "NULLABLE" - }, - { - "name": "date_col", - "type": "DATE", - "mode": "NULLABLE" - }, - { - "name": "time_col", - "type": "TIME", - "mode": "NULLABLE" - }, - { - "name": "datetime_col", - "type": "DATETIME", - "mode": "NULLABLE" - }, - { - "name": "timestamp_col", - "type": "TIMESTAMP", - "mode": "NULLABLE" - }, - { - "name": "bytes_col", - "type": "BYTES", - "mode": "NULLABLE" - }, - { - "name": "numeric_col", - "type": "NUMERIC", - "mode": "NULLABLE" - }, - { - "name": "bignumeric_col", - "type": "BIGNUMERIC", - "mode": "NULLABLE" - }, - { - "name": "geography_col", - "type": "GEOGRAPHY", - "mode": "NULLABLE" - }, - { - "name": "duration_col", - "type": "INTEGER", - "mode": "NULLABLE", - "description": "#microseconds" } ] diff --git a/tests/js/babel.config.cjs b/tests/js/babel.config.cjs deleted file mode 100644 index 549f612a2dd..00000000000 --- a/tests/js/babel.config.cjs +++ /dev/null @@ -1,19 +0,0 @@ -/* - * 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 deleted file mode 100644 index ad7dbf97ee3..00000000000 --- a/tests/js/jest.config.cjs +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 deleted file mode 100644 index b6b5934d76e..00000000000 --- a/tests/js/jest.setup.js +++ /dev/null @@ -1,20 +0,0 @@ -/* - * 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 deleted file mode 100644 index 241ebd2a8d5..00000000000 --- a/tests/js/package-lock.json +++ /dev/null @@ -1,7074 +0,0 @@ -{ - "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": "^30.0.0", - "jest-environment-jsdom": "^30.2.0", - "jsdom": "^29.0.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/@asamuzakjp/dom-selector": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", - "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@asamuzakjp/nwsapi": "^2.3.9", - "bidi-js": "^1.0.3", - "css-tree": "^3.2.1", - "is-potential-custom-element-name": "^1.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/generational-cache": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", - "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/@asamuzakjp/nwsapi": { - "version": "2.3.9", - "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", - "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", - "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.29.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", - "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", - "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.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", - "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@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.6", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", - "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", - "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.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", - "@babel/traverse": "^7.28.6", - "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.8", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", - "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "debug": "^4.4.3", - "lodash.debounce": "^4.0.8", - "resolve": "^1.22.11" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", - "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.28.5", - "@babel/helper-optimise-call-expression": "^7.27.1", - "@babel/traverse": "^7.28.6" - }, - "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.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "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-safari-rest-destructuring-rhs-array": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array/-/plugin-bugfix-safari-rest-destructuring-rhs-array-7.29.3.tgz", - "integrity": "sha512-SRS46DFR4HqzUzCVgi90/xMoL+zeBDBvWdKYXSEzh79kXswNFEglUpMKxR04//dPqwYXWUBJ3mpUd933ru9Kmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-skip-transparent-expression-wrappers": "^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.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", - "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", - "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", - "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", - "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", - "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", - "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-remap-async-to-generator": "^7.27.1", - "@babel/traverse": "^7.29.0" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", - "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@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.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", - "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", - "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", - "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.12.0" - } - }, - "node_modules/@babel/plugin-transform-classes": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", - "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-globals": "^7.28.0", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-replace-supers": "^7.28.6", - "@babel/traverse": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", - "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/template": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", - "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", - "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", - "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", - "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", - "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", - "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz", - "integrity": "sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.29.0" - }, - "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", - "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", - "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", - "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", - "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/traverse": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", - "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", - "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", - "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", - "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.27.3", - "@babel/helper-create-class-features-plugin": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.29.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", - "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", - "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", - "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.28.6", - "@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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", - "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "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.28.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", - "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.28.5", - "@babel/helper-plugin-utils": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/preset-env": { - "version": "7.29.5", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.29.5.tgz", - "integrity": "sha512-/69t2aEzGKHD76DyLbHysF/QH2LJOB8iFnYO37unDTKBTubzcMRv0f3H5EiN1Q6ajOd/eB7dAInF0qdFVS06kA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.3", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-plugin-utils": "^7.28.6", - "@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-safari-rest-destructuring-rhs-array": "^7.29.3", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", - "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.28.6", - "@babel/plugin-syntax-import-attributes": "^7.28.6", - "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.27.1", - "@babel/plugin-transform-async-generator-functions": "^7.29.0", - "@babel/plugin-transform-async-to-generator": "^7.28.6", - "@babel/plugin-transform-block-scoped-functions": "^7.27.1", - "@babel/plugin-transform-block-scoping": "^7.28.6", - "@babel/plugin-transform-class-properties": "^7.28.6", - "@babel/plugin-transform-class-static-block": "^7.28.6", - "@babel/plugin-transform-classes": "^7.28.6", - "@babel/plugin-transform-computed-properties": "^7.28.6", - "@babel/plugin-transform-destructuring": "^7.28.5", - "@babel/plugin-transform-dotall-regex": "^7.28.6", - "@babel/plugin-transform-duplicate-keys": "^7.27.1", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-dynamic-import": "^7.27.1", - "@babel/plugin-transform-explicit-resource-management": "^7.28.6", - "@babel/plugin-transform-exponentiation-operator": "^7.28.6", - "@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.28.6", - "@babel/plugin-transform-literals": "^7.27.1", - "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", - "@babel/plugin-transform-member-expression-literals": "^7.27.1", - "@babel/plugin-transform-modules-amd": "^7.27.1", - "@babel/plugin-transform-modules-commonjs": "^7.28.6", - "@babel/plugin-transform-modules-systemjs": "^7.29.4", - "@babel/plugin-transform-modules-umd": "^7.27.1", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", - "@babel/plugin-transform-new-target": "^7.27.1", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", - "@babel/plugin-transform-numeric-separator": "^7.28.6", - "@babel/plugin-transform-object-rest-spread": "^7.28.6", - "@babel/plugin-transform-object-super": "^7.27.1", - "@babel/plugin-transform-optional-catch-binding": "^7.28.6", - "@babel/plugin-transform-optional-chaining": "^7.28.6", - "@babel/plugin-transform-parameters": "^7.27.7", - "@babel/plugin-transform-private-methods": "^7.28.6", - "@babel/plugin-transform-private-property-in-object": "^7.28.6", - "@babel/plugin-transform-property-literals": "^7.27.1", - "@babel/plugin-transform-regenerator": "^7.29.0", - "@babel/plugin-transform-regexp-modifiers": "^7.28.6", - "@babel/plugin-transform-reserved-words": "^7.27.1", - "@babel/plugin-transform-shorthand-properties": "^7.27.1", - "@babel/plugin-transform-spread": "^7.28.6", - "@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.28.6", - "@babel/plugin-transform-unicode-regex": "^7.27.1", - "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", - "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.15", - "babel-plugin-polyfill-corejs3": "^0.14.0", - "babel-plugin-polyfill-regenerator": "^0.6.6", - "core-js-compat": "^3.48.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.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "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/@bramus/specificity": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", - "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-tree": "^3.0.0" - }, - "bin": { - "specificity": "bin/cli.js" - } - }, - "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-syntax-patches-for-csstree": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", - "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "peerDependencies": { - "css-tree": "^3.2.1" - }, - "peerDependenciesMeta": { - "css-tree": { - "optional": true - } - } - }, - "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/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@exodus/bytes": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", - "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - }, - "peerDependencies": { - "@noble/hashes": "^1.8.0 || ^2.0.0" - }, - "peerDependenciesMeta": { - "@noble/hashes": { - "optional": true - } - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "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/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jest/console": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.4.1.tgz", - "integrity": "sha512-v3bhyxUh9Hgmo5p6hAOXe14/R3ZxZDOsvHleh4B07z3m/x4/ngPUXEm9XwK4sF4u+f+P2ORb0Ge+MgpaqRMVDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/core": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.4.2.tgz", - "integrity": "sha512-TZJA6cPJUFxoWhxaLo8t0VX/MZX2wPWr0uIDvLSHIvN4gu9h02vSzqI2kBADG1ExqQlC+cY09xKMSreivvrChQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.4.1", - "@jest/pattern": "30.4.0", - "@jest/reporters": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "exit-x": "^0.2.2", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-changed-files": "30.4.1", - "jest-config": "30.4.2", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-resolve-dependencies": "30.4.2", - "jest-runner": "30.4.2", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "jest-watcher": "30.4.1", - "pretty-format": "30.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/diff-sequences": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.4.0.tgz", - "integrity": "sha512-zOpzlfUs45l6u7jm39qr87JCHUDsaeCtvL+kQe/Vn9jSnRB4/5IPXISm0h9I1vZW/o00Kn4UTJ2MOlhnUGwv3g==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.4.1.tgz", - "integrity": "sha512-AK9yNRqgKxiabqMoe4oW+3/TSSeV8vkdC7BGaxZdU0AFXfOpofTLqdru2GXKZghP3sdgwE9XXpnVwfZ8JnFV4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "jest-mock": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/environment-jsdom-abstract": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.4.1.tgz", - "integrity": "sha512-dSlKrqug3siYNHVnjwIldShY12wAH3spwRltO/+8VOjg0X+xEq7vOs3DbBs4LRKsu7OH+NUb9kuZUNBF9Ho3TA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", - "@types/jsdom": "^21.1.7", - "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/@jest/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-ginrj6TMgh2GshLUGCjO94Ptx9HhdZA/I6A9iUfyeLKFtdAjnKzHDgzgP9HYQgbxM1lbXScQ2eUBz2lGeVDPWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "expect": "30.4.1", - "jest-snapshot": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/expect-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.4.1.tgz", - "integrity": "sha512-ZBn5CglH8fBsQsvs4VWNzD4aWfUYks+IdOOQU3MEK71ol/BcVm+P+rtb1KpiFBpSWSCE27uOahyyf1vfqOVbcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/fake-timers": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.4.1.tgz", - "integrity": "sha512-iW5umdmfPeWzehrVhugFQZqCchSCud5S1l2YT0O9ZhjRR0ExclANDZkiSBwzqtnlOn0J1JXvO+HZ6rkuyOVOgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@sinonjs/fake-timers": "^15.4.0", - "@types/node": "*", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/get-type": { - "version": "30.1.0", - "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", - "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/globals": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.4.1.tgz", - "integrity": "sha512-ZbuY4cmXC8DkxYjfvT2DbcHWL2T6vmsMhXCDcmTB2T0y0gaezBI77ufq5ZAIdcRkYZ7NEQEDg1xFeKbxUJ5v5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/types": "30.4.1", - "jest-mock": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/pattern": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.4.0.tgz", - "integrity": "sha512-RAWn3+f9u8BsHijKJ71uHcFp6vmyEt6VvoWXkl6hKF3qVIuWNmudVjg12DlBPGup/frIl5UcUlH5HfEuvHpEXg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "jest-regex-util": "30.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/reporters": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.4.1.tgz", - "integrity": "sha512-/SnkPCzEQpUaBH81kjdEdDdo2WZl5hxw+BmLDGWjRkm8o7XlhjwsU36cqwe5PGBE5WYpBvDzRSdXx9rbGuJtNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "@types/node": "*", - "chalk": "^4.1.2", - "collect-v8-coverage": "^1.0.2", - "exit-x": "^0.2.2", - "glob": "^10.5.0", - "graceful-fs": "^4.2.11", - "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^6.0.0", - "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^5.0.0", - "istanbul-reports": "^3.1.3", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "slash": "^3.0.0", - "string-length": "^4.0.2", - "v8-to-istanbul": "^9.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/@jest/schemas": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.4.1.tgz", - "integrity": "sha512-i6b4qw5qnP8c5FEeBJg/uZQ4ddrkN6Ca8qISJh0pr7a5hfn3h3v5x60BEbOC7OYAGZNMs1LfFLwnW2CuK8F57Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sinclair/typebox": "^0.34.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/snapshot-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.4.1.tgz", - "integrity": "sha512-ObY4ljvQ95mt6iwKtVLetR/4yXiAgl3H4nJxhztr0MTjrN97TwDYrnCp/kF60Ec9HdhkWTHSu+Hg05aXfngpOA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "natural-compare": "^1.4.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/source-map": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", - "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.25", - "callsites": "^3.1.0", - "graceful-fs": "^4.2.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-result": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.4.1.tgz", - "integrity": "sha512-/ZG7pgEiOmmWkN9TplKbOu4id2N5lh7FHwRwlkgBVAzGdRH+OkkQ8wX/kIxg4zmd3ZQvAL1RwL2yWsvNYYECTw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.4.1", - "@jest/types": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "collect-v8-coverage": "^1.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/test-sequencer": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.4.1.tgz", - "integrity": "sha512-PeYE+4td5rKjoRPxztObrXU+H8hsjZfxKMXOcmrr34JerSyB/ROOxbbicz8B7A5j9R9VayDnVPvBmedqCsFCdw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.4.1", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/transform": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.4.1.tgz", - "integrity": "sha512-Wz0LyktlTvRefoymh+n64hQ84KNXsRGcwdoZ8CSa0Ea+fgYcHZlnk+hDP7v2MS7il2bQ5uTEIxf4/NNfhMN4KQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/types": "30.4.1", - "@jridgewell/trace-mapping": "^0.3.25", - "babel-plugin-istanbul": "^7.0.1", - "chalk": "^4.1.2", - "convert-source-map": "^2.0.0", - "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "pirates": "^4.0.7", - "slash": "^3.0.0", - "write-file-atomic": "^5.0.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/@jest/types": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.4.1.tgz", - "integrity": "sha512-f1x/vJXIfjOlEmejYpbkbgw1gOqpPECwMvMEtBqe47j7H2Hg8h8w3o3ikhSXq3MI15kg+oQ0exWO0uCtTNJLoQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/pattern": "30.4.0", - "@jest/schemas": "30.4.1", - "@types/istanbul-lib-coverage": "^2.0.6", - "@types/istanbul-reports": "^3.0.4", - "@types/node": "*", - "@types/yargs": "^17.0.33", - "chalk": "^4.1.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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/@napi-rs/wasm-runtime": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", - "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.3", - "@emnapi/runtime": "^1.4.3", - "@tybys/wasm-util": "^0.10.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pkgr/core": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", - "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/pkgr" - } - }, - "node_modules/@sinclair/typebox": { - "version": "0.34.49", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", - "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", - "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": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@sinonjs/commons": "^3.0.1" - } - }, - "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/@tybys/wasm-util": { - "version": "0.10.2", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", - "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "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/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": "21.1.7", - "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", - "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", - "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/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/@unrs/resolver-binding-android-arm-eabi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", - "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-android-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", - "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-arm64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", - "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-darwin-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", - "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@unrs/resolver-binding-freebsd-x64": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", - "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", - "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", - "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", - "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-arm64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", - "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", - "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", - "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", - "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", - "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-gnu": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", - "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-linux-x64-musl": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", - "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@unrs/resolver-binding-wasm32-wasi": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", - "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.11" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", - "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", - "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@unrs/resolver-binding-win32-x64-msvc": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", - "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "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-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "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/anymatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "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/babel-jest": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.4.1.tgz", - "integrity": "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/transform": "30.4.1", - "@types/babel__core": "^7.20.5", - "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.4.0", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "slash": "^3.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-0" - } - }, - "node_modules/babel-plugin-istanbul": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", - "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", - "dev": true, - "license": "BSD-3-Clause", - "workspaces": [ - "test/babel-8" - ], - "dependencies": { - "@babel/helper-plugin-utils": "^7.0.0", - "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.3", - "istanbul-lib-instrument": "^6.0.2", - "test-exclude": "^6.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/babel-plugin-jest-hoist": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.4.0.tgz", - "integrity": "sha512-9EdtWM/sSfXLOGLwSn+GS6pIXyBnL07/8gyJlwFXjWy4DxMOyItqyUT29d4lQiS380EZwYlX7/At4PgBS+m2aA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/babel__core": "^7.20.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.17", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", - "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-define-polyfill-provider": "^0.6.8", - "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.14.2", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", - "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8", - "core-js-compat": "^3.48.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.8", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", - "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.8" - }, - "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": "30.4.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.4.0.tgz", - "integrity": "sha512-lBY4jxsNmCnSiu7kquw8ZC9F4+XLMOKypT3RnNHPvU2Kpd4W0xaPuLr5ZkRyOsvLYAY4yaW1ZwTW4xB7NIiZzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "babel-plugin-jest-hoist": "30.4.0", - "babel-preset-current-node-syntax": "^1.2.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0 || ^8.0.0-beta.1" - } - }, - "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.10.23", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz", - "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "require-from-string": "^2.0.2" - } - }, - "node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "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.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "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/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": "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/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "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": "4.4.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", - "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/cjs-module-lexer": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", - "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", - "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/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.49.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.49.0.tgz", - "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", - "dev": true, - "license": "MIT", - "dependencies": { - "browserslist": "^4.28.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/core-js" - } - }, - "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-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "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/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/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.2", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", - "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", - "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/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/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/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.344", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", - "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==", - "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/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "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/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/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/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-x": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", - "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/expect": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.4.1.tgz", - "integrity": "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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/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/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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-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-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/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/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-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": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "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/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/jest": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.4.2.tgz", - "integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.4.2", - "@jest/types": "30.4.1", - "import-local": "^3.2.0", - "jest-cli": "30.4.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.4.1.tgz", - "integrity": "sha512-IuctmYrxi21iOSOaIXpJWalHyPAsVv0GeBHKDn8C1CA4W5htHn7INL+wdnL4Bo0+olEndvAFkmb++tIQJG+vvg==", - "dev": true, - "license": "MIT", - "dependencies": { - "execa": "^5.1.1", - "jest-util": "30.4.1", - "p-limit": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-circus": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.4.2.tgz", - "integrity": "sha512-rvHH7VlY6LgbJXJTQ87GW62g1FntOtbhh0zT+v04kC+pgL6aBKyYINXxWukCpj3dcIBMw5/XUbtDS9dU9JTXeQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/expect": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "co": "^4.6.0", - "dedent": "^1.6.0", - "is-generator-fn": "^2.1.0", - "jest-each": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-runtime": "30.4.2", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "p-limit": "^3.1.0", - "pretty-format": "30.4.1", - "pure-rand": "^7.0.0", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-cli": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.4.2.tgz", - "integrity": "sha512-jfA2ocvVHMXS2QijrJ0d31ektP+d/W0T5RpcTX2Pq+3sVqHlsXVCM2+FmwpL+bdY8OfHpIg9xMxLF17Zg0U49Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/core": "30.4.2", - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", - "chalk": "^4.1.2", - "exit-x": "^0.2.2", - "import-local": "^3.2.0", - "jest-config": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "yargs": "^17.7.2" - }, - "bin": { - "jest": "bin/jest.js" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" - }, - "peerDependenciesMeta": { - "node-notifier": { - "optional": true - } - } - }, - "node_modules/jest-config": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.4.2.tgz", - "integrity": "sha512-rNHAShJQqQwFNoL0hbf3BphSBOWnpOUAKvidLS/AjNVLPfoj5mSf4jQMfW3cYOs6hXeZC7nF7mDHaBnbxELOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@jest/get-type": "30.1.0", - "@jest/pattern": "30.4.0", - "@jest/test-sequencer": "30.4.1", - "@jest/types": "30.4.1", - "babel-jest": "30.4.1", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "deepmerge": "^4.3.1", - "glob": "^10.5.0", - "graceful-fs": "^4.2.11", - "jest-circus": "30.4.2", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-runner": "30.4.2", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "parse-json": "^5.2.0", - "pretty-format": "30.4.1", - "slash": "^3.0.0", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "@types/node": "*", - "esbuild-register": ">=3.4.0", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "esbuild-register": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/jest-diff": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.4.1.tgz", - "integrity": "sha512-CRpFK0RtLriVDGcPPAnR6HMVI8bSR2jnUIgralhauzYQZIb4RH9AtEInTuQr65LmmGggGcRT6HIASxwqsVsmlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/diff-sequences": "30.4.0", - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-docblock": { - "version": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.4.0.tgz", - "integrity": "sha512-ZPMabUZCx5MpbZ2eBYSvZ0J8fvo3dR9oM+eeUpb3aKNQFuS2tu3Duw1TNlMoP8k3WQgKGJuhcMFvwcVuq6T7oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "detect-newline": "^3.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-each": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.4.1.tgz", - "integrity": "sha512-/8MJbH6fuj48TstjrMf+u/pd06Qezz5xOXvZA6442heNOWr8bdeoGZX2d9fCn028CoMgYmroH9//zky5GfyYmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", - "chalk": "^4.1.2", - "jest-util": "30.4.1", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-environment-jsdom": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.4.1.tgz", - "integrity": "sha512-o3nfaN4zej7qgk2X0j8Jhq/S9nAVKs2xK3QeQxeHVvpkEPxaA1yxDGydR+iVI7zPy7Cp62Aq2h3Ja46QvfWHGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/environment-jsdom-abstract": "30.4.1", - "jsdom": "^26.1.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jest-environment-jsdom/node_modules/jsdom": { - "version": "26.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", - "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jest-environment-node": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.4.1.tgz", - "integrity": "sha512-4FZYVOk85hz2AyT6BbarKy9u37g6DbrDyCdFhsnDdXqyrueYQvB+0zO4f/kqLCRD0BsPRXPMNJeQwihKZV8naw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "jest-mock": "30.4.1", - "jest-util": "30.4.1", - "jest-validate": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-haste-map": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.4.1.tgz", - "integrity": "sha512-rFrcONd8jeFsyw+Z9CrScJgglRf2+NFmNam8dKu7n+SoHqNYT47mn0DdEcVUZJpvh7Iz6/si7f7yUH7GJHVgnw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "anymatch": "^3.1.3", - "fb-watchman": "^2.0.2", - "graceful-fs": "^4.2.11", - "jest-regex-util": "30.4.0", - "jest-util": "30.4.1", - "jest-worker": "30.4.1", - "picomatch": "^4.0.3", - "walker": "^1.0.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "optionalDependencies": { - "fsevents": "^2.3.3" - } - }, - "node_modules/jest-leak-detector": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.4.1.tgz", - "integrity": "sha512-IpmyiioeHxiWDhesHnUFmOxcTzwCwKpgACgWajtAP+nYQXiY7DakTxB6Bx9JFiRMljr0AX1PvnQdaU1KFoz6NQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-matcher-utils": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.4.1.tgz", - "integrity": "sha512-zvYfX5CaeEkFrrLS9suWe9rvJrm9J1Iv3ua8kIBv9GEPzcnsfBf0bob37la7s67fs0nlBC3EuvkOLnXQKxtx4A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "chalk": "^4.1.2", - "jest-diff": "30.4.1", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-message-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.4.1.tgz", - "integrity": "sha512-kwCKIvq0MCW1HzLoGola9Te6JUdzgV0loyKJ3Qghrkz9i5/RRIHsL95BMQc2HBBhlBKC4j22K9p11TGHH8RBpQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.27.1", - "@jest/types": "30.4.1", - "@types/stack-utils": "^2.0.3", - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-util": "30.4.1", - "picomatch": "^4.0.3", - "pretty-format": "30.4.1", - "slash": "^3.0.0", - "stack-utils": "^2.0.6" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-mock": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.4.1.tgz", - "integrity": "sha512-/i8SVb8/NSB7RfNi8gfqu8gxLV23KaL5EpAttyb9iz8qWRIqXRLflycz/32wXsYkOnaUlx8NAKnJYtpsmXUmfw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "jest-util": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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": "30.4.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.4.0.tgz", - "integrity": "sha512-mWlvLviKIgIQ8VCuM1xRdD0TWp3zlzionlmDBjuXVBs+VkmXq6FgW9T4Emr7oGz/Rk6feDCGyiugolcQEyp3mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.4.1.tgz", - "integrity": "sha512-Zry8Yq/yJcNAZ7dJ5F2heic8AheXvbFZ7XI5V+h28nrYZ7Qoyy4dItq8OodjnYD270mvX+ZudmrNV9cysqhW5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "chalk": "^4.1.2", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.4.1", - "jest-validate": "30.4.1", - "slash": "^3.0.0", - "unrs-resolver": "^1.7.11" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-resolve-dependencies": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.4.2.tgz", - "integrity": "sha512-gDiVh1I+GxYzz9oXlyw+1wv6VOYX1WYxMOfjsA3iGKePV2oxmbHhwxfkALxNxYy1ciw6APWwkW2zZONwP97aEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "jest-regex-util": "30.4.0", - "jest-snapshot": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runner": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.4.2.tgz", - "integrity": "sha512-2dw0PslVYXxffXGpLo+Ejad+KcI1Qkjn7f4X4619gf21oCUmL+SPfjqIa/losUem3yEOvfNZe/F1HWUcNpODcg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/console": "30.4.1", - "@jest/environment": "30.4.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "exit-x": "^0.2.2", - "graceful-fs": "^4.2.11", - "jest-docblock": "30.4.0", - "jest-environment-node": "30.4.1", - "jest-haste-map": "30.4.1", - "jest-leak-detector": "30.4.1", - "jest-message-util": "30.4.1", - "jest-resolve": "30.4.1", - "jest-runtime": "30.4.2", - "jest-util": "30.4.1", - "jest-watcher": "30.4.1", - "jest-worker": "30.4.1", - "p-limit": "^3.1.0", - "source-map-support": "0.5.13" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-runtime": { - "version": "30.4.2", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.4.2.tgz", - "integrity": "sha512-3/5e8iPz2k/VLqlr8DgTftYyLUv8Su3FkCAO2/Od81UsUTpSxOrS6O5x5KkoQwyUjmpYyDJKeyAvg2T2nvpNkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/environment": "30.4.1", - "@jest/fake-timers": "30.4.1", - "@jest/globals": "30.4.1", - "@jest/source-map": "30.0.1", - "@jest/test-result": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "cjs-module-lexer": "^2.1.0", - "collect-v8-coverage": "^1.0.2", - "glob": "^10.5.0", - "graceful-fs": "^4.2.11", - "jest-haste-map": "30.4.1", - "jest-message-util": "30.4.1", - "jest-mock": "30.4.1", - "jest-regex-util": "30.4.0", - "jest-resolve": "30.4.1", - "jest-snapshot": "30.4.1", - "jest-util": "30.4.1", - "slash": "^3.0.0", - "strip-bom": "^4.0.0" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-snapshot": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.4.1.tgz", - "integrity": "sha512-tEOkkfOMppUyeiHwjZswOQ3lcnoTnws/q5FnGIaeIh/jmoU0ZlgMYRR8sTlTj+nNGCoJ0RDq6SfxGxCsyMTPmw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.27.4", - "@babel/generator": "^7.27.5", - "@babel/plugin-syntax-jsx": "^7.27.1", - "@babel/plugin-syntax-typescript": "^7.27.1", - "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.4.1", - "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.4.1", - "@jest/transform": "30.4.1", - "@jest/types": "30.4.1", - "babel-preset-current-node-syntax": "^1.2.0", - "chalk": "^4.1.2", - "expect": "30.4.1", - "graceful-fs": "^4.2.11", - "jest-diff": "30.4.1", - "jest-matcher-utils": "30.4.1", - "jest-message-util": "30.4.1", - "jest-util": "30.4.1", - "pretty-format": "30.4.1", - "semver": "^7.7.2", - "synckit": "^0.11.8" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-util": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.4.1.tgz", - "integrity": "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/types": "30.4.1", - "@types/node": "*", - "chalk": "^4.1.2", - "ci-info": "^4.2.0", - "graceful-fs": "^4.2.11", - "picomatch": "^4.0.3" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.4.1.tgz", - "integrity": "sha512-PDWi4SOwLnwqNDfHZjOcsEFyZ4fc/2W2gVL3DEoyqnB6jCQMLRtfBong8s6omIw3lI0HWOus12xfnFmQtjW3fw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/get-type": "30.1.0", - "@jest/types": "30.4.1", - "camelcase": "^6.3.0", - "chalk": "^4.1.2", - "leven": "^3.1.0", - "pretty-format": "30.4.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-validate/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/jest-watcher": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.4.1.tgz", - "integrity": "sha512-/l9UonmvCwjHH7d2h3iAwIloLc1H0S8mJZ/LNK3i86hqwPAz8otUJjP9MfYtz9Tt77Su5FD2xGjZn8d31IZHlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/test-result": "30.4.1", - "@jest/types": "30.4.1", - "@types/node": "*", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2", - "emittery": "^0.13.1", - "jest-util": "30.4.1", - "string-length": "^4.0.2" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "node_modules/jest-worker": { - "version": "30.4.1", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.4.1.tgz", - "integrity": "sha512-SHynN/q/QD++iNyvMdy+WMmbCGk8jIsNcRxycXbWubSOhvo6T+j2afcfUSl+3hYsiBebOTo0cT7c2H7CXugu1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.4.1", - "merge-stream": "^2.0.0", - "supports-color": "^8.1.1" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.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": "29.1.1", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", - "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.1.11", - "@asamuzakjp/dom-selector": "^7.1.1", - "@bramus/specificity": "^2.4.2", - "@csstools/css-syntax-patches-for-csstree": "^1.1.3", - "@exodus/bytes": "^1.15.0", - "css-tree": "^3.2.1", - "data-urls": "^7.0.0", - "decimal.js": "^10.6.0", - "html-encoding-sniffer": "^6.0.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.3.5", - "parse5": "^8.0.1", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.1", - "undici": "^7.25.0", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^8.0.1", - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.1", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.13.0 || >=24.0.0" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsdom/node_modules/@asamuzakjp/css-color": { - "version": "5.1.11", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", - "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/generational-cache": "^1.0.1", - "@csstools/css-calc": "^3.2.0", - "@csstools/css-color-parser": "^4.1.0", - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/jsdom/node_modules/@csstools/color-helpers": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", - "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/jsdom/node_modules/@csstools/css-calc": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", - "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/jsdom/node_modules/@csstools/css-color-parser": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", - "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.2.0" - }, - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/jsdom/node_modules/@csstools/css-parser-algorithms": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", - "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^4.0.0" - } - }, - "node_modules/jsdom/node_modules/@csstools/css-tokenizer": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", - "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=20.19.0" - } - }, - "node_modules/jsdom/node_modules/data-urls": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", - "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/jsdom/node_modules/entities": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", - "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20.19.0" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/html-encoding-sniffer": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", - "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.6.0" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "node_modules/jsdom/node_modules/lru-cache": { - "version": "11.3.6", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz", - "integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/jsdom/node_modules/parse5": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", - "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^8.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/jsdom/node_modules/tldts": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", - "integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.30" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/jsdom/node_modules/tldts-core": { - "version": "7.0.30", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz", - "integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom/node_modules/tough-cookie": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", - "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/jsdom/node_modules/tr46": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", - "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/webidl-conversions": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", - "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/whatwg-mimetype": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", - "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" - } - }, - "node_modules/jsdom/node_modules/whatwg-url": { - "version": "16.0.1", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", - "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@exodus/bytes": "^1.11.0", - "tr46": "^6.0.0", - "webidl-conversions": "^8.0.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" - } - }, - "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/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/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "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/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/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "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/napi-postinstall": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", - "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", - "dev": true, - "license": "MIT", - "bin": { - "napi-postinstall": "lib/cli.js" - }, - "engines": { - "node": "^12.20.0 || ^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/napi-postinstall" - } - }, - "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.38", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", - "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", - "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/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "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/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "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": "30.4.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", - "integrity": "sha512-K6KiKMHTL4jjX4u3Kir2EW07nRfcqVTXIImx50wbjHQTcZPgg+gjVeNTIT3l3L1Rd4UefxfogquC9J37SoFyyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jest/schemas": "30.4.1", - "ansi-styles": "^5.2.0", - "react-is-18": "npm:react-is@^18.3.1", - "react-is-19": "npm:react-is@^19.2.5" - }, - "engines": { - "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" - } - }, - "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": "7.0.1", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", - "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", - "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/dubzzz" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fast-check" - } - ], - "license": "MIT" - }, - "node_modules/react-is-18": { - "name": "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/react-is-19": { - "name": "react-is", - "version": "19.2.6", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.6.tgz", - "integrity": "sha512-XjBR15BhXuylgWGuslhDKqlSayuqvqBX91BP8pauG8kd1zY8kotkNWbXksTCNRarse4kuGbe2kIY05ARtwNIvw==", - "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/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "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/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/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.8.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", - "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", - "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/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "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/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "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/string-width-cjs/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-width-cjs/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/string-width-cjs/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-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "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-ansi-cjs/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/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/synckit": { - "version": "0.11.12", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", - "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@pkgr/core": "^0.2.9" - }, - "engines": { - "node": "^14.18.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/synckit" - } - }, - "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.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "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": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "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.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tldts": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", - "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", - "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", - "dev": true, - "license": "MIT" - }, - "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/tough-cookie": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", - "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "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/tslib": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", - "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true - }, - "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": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.18.1" - } - }, - "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/unrs-resolver": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", - "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "napi-postinstall": "^0.3.0" - }, - "funding": { - "url": "https://opencollective.com/unrs-resolver" - }, - "optionalDependencies": { - "@unrs/resolver-binding-android-arm-eabi": "1.11.1", - "@unrs/resolver-binding-android-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-arm64": "1.11.1", - "@unrs/resolver-binding-darwin-x64": "1.11.1", - "@unrs/resolver-binding-freebsd-x64": "1.11.1", - "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", - "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", - "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", - "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", - "@unrs/resolver-binding-linux-x64-musl": "1.11.1", - "@unrs/resolver-binding-wasm32-wasi": "1.11.1", - "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", - "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", - "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "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/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/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi-cjs/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/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "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": "5.0.1", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", - "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/ws": { - "version": "8.21.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", - "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", - "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 deleted file mode 100644 index 86031e068c1..00000000000 --- a/tests/js/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "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": "^30.0.0", - "jest-environment-jsdom": "^30.2.0", - "@testing-library/jest-dom": "^6.4.6", - "jsdom": "^29.0.0" - } -} diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js deleted file mode 100644 index d701d8692e5..00000000000 --- a/tests/js/table_widget.test.js +++ /dev/null @@ -1,531 +0,0 @@ -/* - * 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'; - -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_context') { - 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_context', [ - { column: 'col1', 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_context') { - return [{ column: 'col1', ascending: 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_context', [ - { column: 'col1', 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_context') { - return [{ column: 'col1', ascending: 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_context', []); - 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_context') { - return [{ column: 'col1', ascending: 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 add a column to sort when Shift+Click is used', () => { - // Mock the initial state: already sorted by col1 asc - model.get.mockImplementation((property) => { - if (property === 'table_html') { - return '
col1
col2
'; - } - if (property === 'orderable_columns') { - return ['col1', 'col2']; - } - if (property === 'sort_context') { - return [{ column: 'col1', ascending: 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 header2 = headers[1]; // col2 - - // Simulate Shift+Click - const clickEvent = new MouseEvent('click', { - bubbles: true, - cancelable: true, - shiftKey: true, - }); - header2.dispatchEvent(clickEvent); - - expect(model.set).toHaveBeenCalledWith('sort_context', [ - { column: 'col1', ascending: true }, - { column: 'col2', ascending: true }, - ]); - expect(model.save_changes).toHaveBeenCalled(); - }); - }); - - describe('Theme detection', () => { - beforeEach(() => { - jest.useFakeTimers(); - // Mock the initial state for theme detection tests - 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; - }); - }); - - afterEach(() => { - jest.useRealTimers(); - document.body.classList.remove('vscode-dark'); - }); - - it('should add bigframes-dark-mode class in dark mode', () => { - document.body.classList.add('vscode-dark'); - render({ model, el }); - jest.runAllTimers(); - expect(el.classList.contains('bigframes-dark-mode')).toBe(true); - }); - - it('should not add bigframes-dark-mode class in light mode', () => { - render({ model, el }); - jest.runAllTimers(); - expect(el.classList.contains('bigframes-dark-mode')).toBe(false); - }); - }); - - 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'); - }); - - /* - * Tests that the widget correctly renders HTML with truncated columns (ellipsis) - * and ensures that the ellipsis column is not treated as a sortable column. - */ - it('should set height dynamically on first load and remain fixed', () => { - jest.useFakeTimers(); - - // Mock the table's offsetHeight - let mockHeight = 150; - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - configurable: true, - get: () => mockHeight, - }); - - // Mock model properties - model.get.mockImplementation((property) => { - if (property === 'table_html') { - return '...
'; - } - return null; - }); - - render({ model, el }); - - const tableContainer = el.querySelector('.table-container'); - - // --- First render --- - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === 'change:table_html', - )[1]; - tableHtmlChangeHandler(); - jest.runAllTimers(); - - // Height should be set to the mocked offsetHeight + 2px buffer - expect(tableContainer.style.height).toBe('152px'); - - // --- Second render (e.g., page size change) --- - // Simulate the new content being taller - mockHeight = 350; - tableHtmlChangeHandler(); - jest.runAllTimers(); - - // Height should NOT change - expect(tableContainer.style.height).toBe('152px'); - - // Restore original implementation - Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { - value: 0, - }); - jest.useRealTimers(); - }); - - it('should render truncated columns with ellipsis and not make ellipsis sortable', () => { - // Mock HTML with truncated columns - // Use the structure produced by the python backend - const mockHtml = ` - - - - - - - - - - - - - - - -
col1
...
col10
1...10
- `; - - model.get.mockImplementation((property) => { - if (property === 'table_html') { - return mockHtml; - } - if (property === 'orderable_columns') { - // Only actual columns are orderable - return ['col1', 'col10']; - } - if (property === 'sort_context') { - 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 headers = el.querySelectorAll('th'); - expect(headers).toHaveLength(3); - - // Check col1 (sortable) - const col1Header = headers[0]; - const col1Indicator = col1Header.querySelector('.sort-indicator'); - expect(col1Indicator).not.toBeNull(); // Should exist (hidden by default) - - // Check ellipsis (not sortable) - const ellipsisHeader = headers[1]; - const ellipsisIndicator = ellipsisHeader.querySelector('.sort-indicator'); - // The render function adds sort indicators only if the column name matches an entry in orderable_columns. - // The ellipsis header content is "..." which is not in ['col1', 'col10']. - expect(ellipsisIndicator).toBeNull(); - - // Check col10 (sortable) - const col10Header = headers[2]; - const col10Indicator = col10Header.querySelector('.sort-indicator'); - expect(col10Indicator).not.toBeNull(); - }); - - describe('Max columns', () => { - /* - * Tests for the max columns dropdown functionality. - */ - - it('should render the max columns dropdown', () => { - // Mock basic state - model.get.mockImplementation((property) => { - if (property === 'max_columns') { - return 20; - } - return null; - }); - - render({ model, el }); - - const maxColumnsContainer = el.querySelector('.max-columns'); - expect(maxColumnsContainer).not.toBeNull(); - const label = maxColumnsContainer.querySelector('label'); - expect(label.textContent).toBe('Max columns:'); - const select = maxColumnsContainer.querySelector('select'); - expect(select).not.toBeNull(); - }); - - it('should select the correct initial value', () => { - const initialMaxColumns = 20; - model.get.mockImplementation((property) => { - if (property === 'max_columns') { - return initialMaxColumns; - } - return null; - }); - - render({ model, el }); - - const select = el.querySelector('.max-columns select'); - expect(Number(select.value)).toBe(initialMaxColumns); - }); - - it('should handle None/null initial value as 0 (All)', () => { - model.get.mockImplementation((property) => { - if (property === 'max_columns') { - return null; // Python None is null in JS - } - return null; - }); - - render({ model, el }); - - const select = el.querySelector('.max-columns select'); - expect(Number(select.value)).toBe(0); - expect(select.options[select.selectedIndex].textContent).toBe('All'); - }); - - it('should update model when value changes', () => { - model.get.mockImplementation((property) => { - if (property === 'max_columns') { - return 20; - } - return null; - }); - - render({ model, el }); - - const select = el.querySelector('.max-columns select'); - - // Change to 10 - select.value = '10'; - const event = new Event('change'); - select.dispatchEvent(event); - - expect(model.set).toHaveBeenCalledWith('max_columns', 10); - expect(model.save_changes).toHaveBeenCalled(); - }); - }); -}); diff --git a/tests/js/table_widget_angular.test.js b/tests/js/table_widget_angular.test.js deleted file mode 100644 index 1e7d0275c5d..00000000000 --- a/tests/js/table_widget_angular.test.js +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT 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'; - -describe('TableWidgetAngular', () => { - let render; - - beforeEach(async () => { - jest.resetModules(); - const tableWidgetAngular = ( - await import('../../bigframes/display/table_widget_angular.js') - ).default; - render = tableWidgetAngular.render; - }); - - it('should have a render function', () => { - expect(render).toBeDefined(); - }); - - it( - 'should bootstrap multiple widgets independently ' + - 'on their respective elements', - async () => { - const el1 = document.createElement('div'); - document.body.appendChild(el1); - - const model1 = { - get: jest.fn((prop) => { - if (prop === 'table_html') { - return '
Widget 1 Content
'; - } - if (prop === 'page_size') return 10; - if (prop === 'page') return 0; - if (prop === 'row_count') return 100; - if (prop === 'max_columns') return 20; - return null; - }), - set: jest.fn(), - save_changes: jest.fn(), - on: jest.fn(), - }; - - const el2 = document.createElement('div'); - document.body.appendChild(el2); - - const model2 = { - get: jest.fn((prop) => { - if (prop === 'table_html') { - return '
Widget 2 Content
'; - } - if (prop === 'page_size') return 25; - if (prop === 'page') return 0; - if (prop === 'row_count') return 200; - if (prop === 'max_columns') return 20; - return null; - }), - set: jest.fn(), - save_changes: jest.fn(), - on: jest.fn(), - }; - - render({ model: model1, el: el1 }); - render({ model: model2, el: el2 }); - - // Wait for async angular bootstrap to complete - await new Promise((resolve) => setTimeout(resolve, 200)); - - const appRoot1 = el1.querySelector('.bigframes-widget'); - expect(appRoot1).not.toBeNull(); - expect(el1.textContent).toContain('Widget 1 Content'); - expect(el1.textContent).toContain('100 total rows'); - expect(el1.textContent).toContain('Page 1 of 10'); - - const appRoot2 = el2.querySelector('.bigframes-widget'); - expect(appRoot2).not.toBeNull(); - expect(el2.textContent).toContain('Widget 2 Content'); - expect(el2.textContent).toContain('200 total rows'); - expect(el2.textContent).toContain('Page 1 of 8'); - - document.body.removeChild(el1); - document.body.removeChild(el2); - }); - - it( - 'should render deferred card and trigger execution on click', - async () => { - // Arrange - const el = document.createElement('div'); - document.body.appendChild(el); - - const state = { - is_deferred_mode: true, - dry_run_info: 'Estimated cost: $0.05', - start_execution: false, - table_html: '', - page_size: 10, - page: 0, - row_count: 0, - max_columns: 20, - }; - - const listeners = {}; - const model = { - get: jest.fn((prop) => state[prop]), - set: jest.fn((prop, val) => { - state[prop] = val; - }), - save_changes: jest.fn(), - on: jest.fn((event, callback) => { - listeners[event] = callback; - }), - }; - - // Act - render({ model, el }); - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Assert (Initial state) - const estimate = el.querySelector('.deferred-estimate'); - expect(estimate).not.toBeNull(); - expect(estimate.textContent).toContain('Estimated cost: $0.05'); - - const runButton = el.querySelector('.run-query-button'); - expect(runButton).not.toBeNull(); - expect(runButton.textContent).toContain('Run Query'); - expect(el.querySelector('.table-container')).toBeNull(); - - // Act (Click Run Query) - runButton.click(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - // Assert (Execution requested) - expect(model.set).toHaveBeenCalledWith('start_execution', true); - expect(model.save_changes).toHaveBeenCalled(); - expect(runButton.disabled).toBe(true); - expect(el.querySelector('.spinner')).not.toBeNull(); - - // Act (Simulate Python load completion) - state.is_deferred_mode = false; - state.table_html = '
Data Loaded
'; - state.row_count = 50; - - if (listeners['change:is_deferred_mode']) { - listeners['change:is_deferred_mode'](); - } - if (listeners['change:table_html']) { - listeners['change:table_html'](); - } - if (listeners['change:row_count']) { - listeners['change:row_count'](); - } - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Assert (Transition to loaded state) - expect(el.querySelector('.deferred-container')).toBeNull(); - const tableContainer = el.querySelector('.table-container'); - expect(tableContainer).not.toBeNull(); - expect(el.textContent).toContain('Data Loaded'); - expect(el.textContent).toContain('50 total rows'); - - // Clean up - document.body.removeChild(el); - }); -}); diff --git a/tests/system/conftest.py b/tests/system/conftest.py index 0b30c331a1e..a75918ed235 100644 --- a/tests/system/conftest.py +++ b/tests/system/conftest.py @@ -12,11 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -import base64 -import datetime -import decimal +from datetime import datetime import hashlib -import json import logging import math import pathlib @@ -25,21 +22,15 @@ import typing from typing import Dict, Generator, Optional -import db_dtypes # type: ignore[import-untyped] -import fsspec # type: ignore[import-untyped] -import gcsfs # type: ignore[import-untyped] -import geopandas as gpd # type: ignore[import-untyped] import google.api_core.exceptions import google.cloud.bigquery as bigquery import google.cloud.bigquery_connection_v1 as bigquery_connection_v1 -import google.cloud.bigquery_storage_v1 import google.cloud.exceptions import google.cloud.functions_v2 as functions_v2 import google.cloud.resourcemanager_v3 as resourcemanager_v3 import google.cloud.storage as storage # type: ignore import numpy as np import pandas as pd -import pandas.arrays import pyarrow as pa import pytest import pytz @@ -79,15 +70,6 @@ def _hash_digest_file(hasher, filepath): hasher.update(chunk) -@pytest.fixture(scope="session", autouse=True) -def configure_gcsfs(): - # gcsfs by default uses a cache that can be stale, causing file loads to - # fail if the file was uploaded indirectly (eg via bq export job) during the - # course of the tests. disable the cache to avoid this. - fsspec.config.conf["gcs"] = {"use_listings_cache": False} - gcsfs.GCSFileSystem.clear_instance_cache() - - @pytest.fixture(scope="session") def tokyo_location() -> str: return TOKYO_LOCATION @@ -121,13 +103,6 @@ def bigquery_client(session: bigframes.Session) -> bigquery.Client: return session.bqclient -@pytest.fixture(scope="session") -def bigquery_storage_read_client( - session: bigframes.Session, -) -> google.cloud.bigquery_storage_v1.BigQueryReadClient: - return session.bqstoragereadclient - - @pytest.fixture(scope="session") def bigquery_client_tokyo(session_tokyo: bigframes.Session) -> bigquery.Client: return session_tokyo.bqclient @@ -220,8 +195,7 @@ def bq_connection_name() -> str: @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}" + return f"{bigquery_client.project}.{bigquery_client.location}.{bq_connection_name}" @pytest.fixture(scope="session", autouse=True) @@ -502,213 +476,14 @@ def nested_structs_df( @pytest.fixture(scope="session") def nested_structs_pandas_df(nested_structs_pandas_type: pd.ArrowDtype) -> pd.DataFrame: - """pd.DataFrame pointing at test data. - - Manually parses using json.loads to preserve data types. - """ - with open(DATA_DIR / "nested_structs.jsonl") as f: - raw_rows = [json.loads(line) for line in f] - - ids = [row["id"] for row in raw_rows] - - def get_val(row, col_name): - return row.get(col_name) - - # person - person_struct_schema = nested_structs_pandas_type.pyarrow_dtype - processed_person: list[Optional[dict[str, typing.Any]]] = [] - for row in raw_rows: - x = get_val(row, "person") - if x is None: - processed_person.append(None) - else: - d = dict(x) - if "age" in d and d["age"] is not None: - d["age"] = int(d["age"]) - processed_person.append(d) - person_arr = pa.array(processed_person, type=person_struct_schema) - person_ser = pd.Series(person_arr, index=ids, dtype=nested_structs_pandas_type) - - # bool_col - bool_vals = [ - bool(get_val(row, "bool_col")) if get_val(row, "bool_col") is not None else None - for row in raw_rows - ] - bool_ser = pd.Series(bool_vals, index=ids, dtype=pd.BooleanDtype()) - - # int64_col - int64_vals = [ - int(get_val(row, "int64_col")) - if get_val(row, "int64_col") is not None - else None - for row in raw_rows - ] - int64_ser = pd.Series(int64_vals, index=ids, dtype=pd.Int64Dtype()) - - # float64_col - float64_vals = [ - float(get_val(row, "float64_col")) - if get_val(row, "float64_col") is not None - else None - for row in raw_rows - ] - np_vals = np.array( - [x if x is not None else np.nan for x in float64_vals], dtype=np.float64 - ) - mask = np.array([x is None for x in float64_vals], dtype=bool) - float64_arr = pd.arrays.FloatingArray(np_vals, mask) # type: ignore - float64_ser = pd.Series(float64_arr, index=ids) - - # string_col - string_vals = [ - str(get_val(row, "string_col")) - if get_val(row, "string_col") is not None - else None - for row in raw_rows - ] - string_ser = pd.Series( - string_vals, index=ids, dtype=pd.StringDtype(storage="pyarrow") - ) - - # json_col - json_strs: list[Optional[str]] = [] - for row in raw_rows: - if "json_col" not in row: - json_strs.append(None) - elif row["json_col"] is None: - json_strs.append("null") - else: - json_strs.append( - json.dumps(row["json_col"], sort_keys=True, separators=(",", ":")) - ) - json_arr = pa.array(json_strs, type=db_dtypes.JSONArrowType()) - json_ser = pd.Series( - json_arr, index=ids, dtype=pd.ArrowDtype(db_dtypes.JSONArrowType()) - ) - - # date_col - date_vals = [ - datetime.date.fromisoformat(get_val(row, "date_col")) - if get_val(row, "date_col") is not None - else None - for row in raw_rows - ] - date_arr = pa.array(date_vals, type=pa.date32()) - date_ser = pd.Series(date_arr, index=ids, dtype=pd.ArrowDtype(pa.date32())) - - # time_col - time_vals = [ - datetime.time.fromisoformat(get_val(row, "time_col")) - if get_val(row, "time_col") is not None - else None - for row in raw_rows - ] - time_arr = pa.array(time_vals, type=pa.time64("us")) - time_ser = pd.Series(time_arr, index=ids, dtype=pd.ArrowDtype(pa.time64("us"))) - - # datetime_col - datetime_vals: list[Optional[datetime.datetime]] = [] - for row in raw_rows: - val = get_val(row, "datetime_col") - if val is None: - datetime_vals.append(None) - else: - datetime_vals.append(datetime.datetime.fromisoformat(val.replace(" ", "T"))) - datetime_arr = pa.array(datetime_vals, type=pa.timestamp("us")) - datetime_ser = pd.Series( - datetime_arr, index=ids, dtype=pd.ArrowDtype(pa.timestamp("us")) - ) - - # timestamp_col - timestamp_vals = [ - datetime.datetime.fromisoformat( - get_val(row, "timestamp_col").replace("Z", "+00:00") - ) - if get_val(row, "timestamp_col") is not None - else None - for row in raw_rows - ] - timestamp_arr = pa.array(timestamp_vals, type=pa.timestamp("us", tz="UTC")) - timestamp_ser = pd.Series( - timestamp_arr, index=ids, dtype=pd.ArrowDtype(pa.timestamp("us", tz="UTC")) - ) - - # bytes_col - bytes_vals: list[Optional[bytes]] = [] - for row in raw_rows: - val = get_val(row, "bytes_col") - if val is None: - bytes_vals.append(None) - elif val == "": - bytes_vals.append(b"") - else: - bytes_vals.append(base64.b64decode(val)) - bytes_arr = pa.array(bytes_vals, type=pa.binary()) - bytes_ser = pd.Series(bytes_arr, index=ids, dtype=pd.ArrowDtype(pa.binary())) - - # numeric_col - numeric_vals = [ - decimal.Decimal(str(get_val(row, "numeric_col"))) - if get_val(row, "numeric_col") is not None - else None - for row in raw_rows - ] - numeric_arr = pa.array(numeric_vals, type=pa.decimal128(38, 9)) - numeric_ser = pd.Series( - numeric_arr, index=ids, dtype=pd.ArrowDtype(pa.decimal128(38, 9)) - ) - - # bignumeric_col - bignumeric_vals = [ - decimal.Decimal(str(get_val(row, "bignumeric_col"))) - if get_val(row, "bignumeric_col") is not None - else None - for row in raw_rows - ] - bignumeric_arr = pa.array(bignumeric_vals, type=pa.decimal256(76, 38)) - bignumeric_ser = pd.Series( - bignumeric_arr, index=ids, dtype=pd.ArrowDtype(pa.decimal256(76, 38)) - ) - - # geography_col - geo_vals = [get_val(row, "geography_col") for row in raw_rows] - geo_ser = gpd.GeoSeries.from_wkt(geo_vals) - geo_ser.index = ids - - # duration_col - duration_vals = [ - int(get_val(row, "duration_col")) - if get_val(row, "duration_col") is not None - else None - for row in raw_rows - ] - duration_arr = pa.array(duration_vals, type=pa.duration("us")) - duration_ser = pd.Series( - duration_arr, index=ids, dtype=pd.ArrowDtype(pa.duration("us")) - ) + """pd.DataFrame pointing at test data.""" - df = pd.DataFrame( - { - "person": person_ser, - "bool_col": bool_ser, - "int64_col": int64_ser, - "float64_col": float64_ser, - "string_col": string_ser, - "json_col": json_ser, - "date_col": date_ser, - "time_col": time_ser, - "datetime_col": datetime_ser, - "timestamp_col": timestamp_ser, - "bytes_col": bytes_ser, - "numeric_col": numeric_ser, - "bignumeric_col": bignumeric_ser, - "geography_col": geo_ser, - "duration_col": duration_ser, - }, - index=ids, + df = pd.read_json( + DATA_DIR / "nested_structs.jsonl", + lines=True, ) - df.index.name = "id" - + df = df.set_index("id") + df["person"] = df["person"].astype(nested_structs_pandas_type) return df @@ -810,18 +585,6 @@ 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, @@ -1039,9 +802,9 @@ def new_time_series_pandas_df(): return pd.DataFrame( { "parsed_date": [ - datetime.datetime(2017, 8, 2, tzinfo=utc), - datetime.datetime(2017, 8, 3, tzinfo=utc), - datetime.datetime(2017, 8, 4, tzinfo=utc), + datetime(2017, 8, 2, tzinfo=utc), + datetime(2017, 8, 3, tzinfo=utc), + datetime(2017, 8, 4, tzinfo=utc), ], "total_visits": [2500, 2500, 2500], } @@ -1060,12 +823,12 @@ def new_time_series_pandas_df_w_id(): return pd.DataFrame( { "parsed_date": [ - datetime.datetime(2017, 8, 2, tzinfo=utc), - datetime.datetime(2017, 8, 2, tzinfo=utc), - datetime.datetime(2017, 8, 3, tzinfo=utc), - datetime.datetime(2017, 8, 3, tzinfo=utc), - datetime.datetime(2017, 8, 4, tzinfo=utc), - datetime.datetime(2017, 8, 4, tzinfo=utc), + datetime(2017, 8, 2, tzinfo=utc), + datetime(2017, 8, 2, tzinfo=utc), + datetime(2017, 8, 3, tzinfo=utc), + datetime(2017, 8, 3, tzinfo=utc), + datetime(2017, 8, 4, tzinfo=utc), + datetime(2017, 8, 4, tzinfo=utc), ], "id": ["1", "2", "1", "2", "1", "2"], "total_visits": [2500, 2500, 2500, 2500, 2500, 2500], @@ -1530,14 +1293,6 @@ def usa_names_grouped_table( return session.bqclient.get_table(table_id) -@pytest.fixture(scope="session", autouse=True) -def use_sqlglot_compiler(): - original_setting = bigframes.options.experiments.sql_compiler - bigframes.options.experiments.sql_compiler = "experimental" - yield - bigframes.options.experiments.sql_compiler = original_setting - - @pytest.fixture() def restore_sampling_settings(): enable_downsampling = bigframes.options.sampling.enable_downsampling @@ -1678,7 +1433,7 @@ def cleanup_cloud_functions(session, cloudfunctions_client, dataset_id_permanent continue # Ignore the functions less than one day old - age = datetime.datetime.now() - datetime.datetime.fromtimestamp( + age = datetime.now() - datetime.fromtimestamp( cloud_function.update_time.timestamp() ) if age.days <= 0: @@ -1731,6 +1486,16 @@ def images_uris() -> list[str]: ] +@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() @@ -1738,3 +1503,29 @@ def reset_default_session_and_location(): 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/bigquery/__init__.py b/tests/system/large/bigquery/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/system/large/bigquery/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/large/bigquery/test_ai.py b/tests/system/large/bigquery/test_ai.py deleted file mode 100644 index 504fe5aa389..00000000000 --- a/tests/system/large/bigquery/test_ai.py +++ /dev/null @@ -1,130 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 -from bigframes.bigquery import ai, ml - - -@pytest.fixture(scope="session") -def embedding_model(bq_connection, dataset_id): - model_name = f"{dataset_id}.embedding_model" - return ml.create_model( - model_name=model_name, - options={"endpoint": "gemini-embedding-001"}, - connection_name=bq_connection, - ) - - -@pytest.fixture(scope="session") -def text_model(bq_connection, dataset_id): - model_name = f"{dataset_id}.text_model" - return ml.create_model( - model_name=model_name, - options={"endpoint": "gemini-2.5-flash"}, - connection_name=bq_connection, - ) - - -def test_generate_embedding(embedding_model): - df = bpd.DataFrame( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - ] - } - ) - - result = ai.generate_embedding(embedding_model, df) - - assert len(result) == 2 - assert "embedding" in result.columns - assert "statistics" in result.columns - assert "status" in result.columns - - -def test_generate_embedding_with_options(embedding_model): - df = bpd.DataFrame( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - ] - } - ) - - result = ai.generate_embedding( - embedding_model, df, task_type="RETRIEVAL_DOCUMENT", output_dimensionality=256 - ) - - assert len(result) == 2 - embedding = result["embedding"].to_pandas() - assert len(embedding[0]) == 256 - - -def test_generate_text(text_model): - df = bpd.DataFrame({"prompt": ["Dog", "Cat"]}) - - result = ai.generate_text(text_model, df) - - assert len(result) == 2 - assert "result" in result.columns - assert "statistics" in result.columns - assert "full_response" in result.columns - assert "status" in result.columns - - -def test_generate_text_with_options(text_model): - df = bpd.DataFrame({"prompt": ["Dog", "Cat"]}) - - result = ai.generate_text(text_model, df, max_output_tokens=1) - - # It basically asserts that the results are still returned. - assert len(result) == 2 - - -def test_generate_table(text_model): - df = bpd.DataFrame( - {"prompt": ["Generate a table of 2 programming languages and their creators."]} - ) - - result = ai.generate_table( - text_model, - df, - output_schema="language STRING, creator STRING", - ) - - assert "language" in result.columns - assert "creator" in result.columns - # The model may not always return the exact number of rows requested. - assert len(result) > 0 - - -def test_generate_table_with_mapping_schema(text_model): - df = bpd.DataFrame( - {"prompt": ["Generate a table of 2 programming languages and their creators."]} - ) - - result = ai.generate_table( - text_model, - df, - output_schema={"language": "STRING", "creator": "STRING"}, - ) - - assert "language" in result.columns - assert "creator" in result.columns - # The model may not always return the exact number of rows requested. - assert len(result) > 0 diff --git a/tests/system/large/bigquery/test_io.py b/tests/system/large/bigquery/test_io.py deleted file mode 100644 index 024c6174709..00000000000 --- a/tests/system/large/bigquery/test_io.py +++ /dev/null @@ -1,39 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for for the specific language governing permissions and -# limitations under the License. - -import bigframes.bigquery as bbq - - -def test_load_data(session, dataset_id): - table_name = f"{dataset_id}.test_load_data" - uri = "gs://cloud-samples-data/bigquery/us-states/us-states.csv" - - # Create the external table - table = bbq.load_data( - table_name, - columns={ - "name": "STRING", - "post_abbr": "STRING", - }, - from_files_options={"format": "CSV", "uris": [uri], "skip_leading_rows": 1}, - session=session, - ) - assert table is not None - - # Read the table to verify - import bigframes.pandas as bpd - - bf_df = bpd.read_gbq(table_name) - pd_df = bf_df.to_pandas() - assert len(pd_df) > 0 diff --git a/tests/system/large/bigquery/test_ml.py b/tests/system/large/bigquery/test_ml.py deleted file mode 100644 index f0f7d4f6917..00000000000 --- a/tests/system/large/bigquery/test_ml.py +++ /dev/null @@ -1,117 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.ml as ml -import bigframes.pandas as bpd - - -@pytest.fixture(scope="session") -def embedding_model(bq_connection, dataset_id): - model_name = f"{dataset_id}.embedding_model" - return ml.create_model( - model_name=model_name, - options={"endpoint": "gemini-embedding-001"}, - connection_name=bq_connection, - ) - - -def test_generate_embedding(embedding_model): - df = bpd.DataFrame( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - ] - } - ) - - result = ml.generate_embedding(embedding_model, df) - assert len(result) == 2 - assert "ml_generate_embedding_result" in result.columns - assert "ml_generate_embedding_status" in result.columns - - -def test_generate_embedding_with_options(embedding_model): - df = bpd.DataFrame( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - ] - } - ) - - result = ml.generate_embedding( - embedding_model, df, task_type="RETRIEVAL_DOCUMENT", output_dimensionality=256 - ) - assert len(result) == 2 - assert "ml_generate_embedding_result" in result.columns - assert "ml_generate_embedding_status" in result.columns - embedding = result["ml_generate_embedding_result"].to_pandas() - assert len(embedding[0]) == 256 - - -def test_get_insights(dataset_id): - df = bpd.DataFrame( - { - "dim1": ["a", "a", "b", "b", "a", "a", "b", "b"], - "dim2": ["x", "y", "x", "y", "x", "y", "x", "y"], - "metric": [10, 20, 30, 40, 12, 25, 35, 45], - "is_test": [False, False, False, False, True, True, True, True], - } - ) - model_name = f"{dataset_id}.contribution_analysis_model" - - ml.create_model( - model_name=model_name, - options={ - "model_type": "CONTRIBUTION_ANALYSIS", - "contribution_metric": "SUM(metric)", - "is_test_col": "is_test", - }, - training_data=df, - ) - - result = ml.get_insights(model_name) - assert len(result) > 0 - assert "contributors" in result.columns - - -def test_create_model_linear_regression(dataset_id): - df = bpd.DataFrame({"x": [1, 2, 3], "y": [2, 4, 6]}) - model_name = f"{dataset_id}.linear_regression_model" - - result = ml.create_model( - model_name=model_name, - options={"model_type": "LINEAR_REG", "input_label_cols": ["y"]}, - training_data=df, - ) - - assert result["modelType"] == "LINEAR_REGRESSION" - - -def test_create_model_with_transform(dataset_id): - df = bpd.DataFrame({"x": [1, 2, 3], "y": [2, 4, 6]}) - model_name = f"{dataset_id}.transform_model" - - result = ml.create_model( - model_name=model_name, - options={"model_type": "LINEAR_REG", "input_label_cols": ["y"]}, - training_data=df, - transform=["x * 2 AS x_doubled", "y"], - ) - - assert result["modelType"] == "LINEAR_REGRESSION" diff --git a/tests/system/large/bigquery/test_table.py b/tests/system/large/bigquery/test_table.py deleted file mode 100644 index dd956b3a040..00000000000 --- a/tests/system/large/bigquery/test_table.py +++ /dev/null @@ -1,36 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.bigquery as bbq - - -def test_create_external_table(session, dataset_id, bq_connection): - table_name = f"{dataset_id}.test_object_table" - uri = "gs://cloud-samples-data/bigquery/tutorials/cymbal-pets/images/*" - - # Create the external table - table = bbq.create_external_table( - table_name, - connection_name=bq_connection, - options={"object_metadata": "SIMPLE", "uris": [uri]}, - session=session, - ) - assert table is not None - - # Read the table to verify - import bigframes.pandas as bpd - - bf_df = bpd.read_gbq(table_name) - pd_df = bf_df.to_pandas() - assert len(pd_df) > 0 diff --git a/tests/system/large/blob/test_function.py b/tests/system/large/blob/test_function.py new file mode 100644 index 00000000000..c8fa63d4938 --- /dev/null +++ b/tests/system/large/blob/test_function.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. + +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 + ) + 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_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" + ) + 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( + 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" + ) + 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_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" + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.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" + ) + 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( + 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" + ) + 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_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" + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.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", + ) + 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( + 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", + ) + 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_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", + ) + + assert isinstance(actual, bpd.Series) + assert len(actual) == 2 + assert actual.dtype == dtypes.BYTES_DTYPE + + +@pytest.mark.parametrize( + "verbose", + [ + (True), + (False), + ], +) +def test_blob_pdf_extract( + pdf_mm_df: bpd.DataFrame, + verbose: bool, + bq_connection: str, +): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_extract(connection=bq_connection, verbose=verbose, 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 = "" + if verbose: + # 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] + else: + 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={verbose}): 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={verbose}): Expected keyword '{keyword}' not found in extracted text. " + + +@pytest.mark.parametrize( + "verbose", + [ + (True), + (False), + ], +) +def test_blob_pdf_chunk(pdf_mm_df: bpd.DataFrame, verbose: bool, bq_connection: str): + actual = ( + pdf_mm_df["pdf"] + .blob.pdf_chunk( + connection=bq_connection, + chunk_size=50, + overlap_size=10, + verbose=verbose, + 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 = "" + if verbose: + # 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]) + else: + # 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={verbose}): 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={verbose}): Expected keyword '{keyword}' not found in extracted text. " + + +@pytest.mark.parametrize( + "model_name, verbose", + [ + ("gemini-2.0-flash-001", True), + ("gemini-2.0-flash-001", False), + ("gemini-2.0-flash-lite-001", True), + ("gemini-2.0-flash-lite-001", False), + ], +) +def test_blob_transcribe( + audio_mm_df: bpd.DataFrame, + model_name: str, + verbose: bool, +): + actual = ( + audio_mm_df["audio"] + .blob.audio_transcribe( + model_name=model_name, + verbose=verbose, + ) + .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 = "" + if verbose: + actual_text = actual[0]["content"] + else: + actual_text = actual[0] + 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={verbose}): 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={verbose}): 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 index 888852edd4d..73335afa3c5 100644 --- a/tests/system/large/functions/test_managed_function.py +++ b/tests/system/large/functions/test_managed_function.py @@ -16,6 +16,7 @@ import google.api_core.exceptions import pandas +import pyarrow import pytest import test_utils.prefixer @@ -24,333 +25,405 @@ 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", "") -@pytest.fixture -def function_id(dataset_id, session): - name = prefixer.create_prefix() - yield name +def test_managed_function_array_output(session, scalars_dfs, dataset_id): try: - session.bqclient.delete_routine(f"{dataset_id}.{name}") - # some tests, like test_managed_function_options_errors, should not actually create the function. - # so we ignore the not found error. - except google.api_core.exceptions.NotFound: - pass + 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 + ) -def test_managed_function_array_output(session, scalars_dfs, dataset_id, function_id): - with warnings.catch_warnings(record=True) as record: + scalars_df, scalars_pandas_df = scalars_dfs - @session.udf( - dataset=dataset_id, - name=function_id, - ) - def featurize(x: int) -> list[float]: - return [float(i) for i in [x, x + 1, x + 2]] + bf_int64_col = scalars_df["int64_too"] + bf_result = bf_int64_col.apply(featurize).to_pandas() - # 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) + pd_int64_col = scalars_pandas_df["int64_too"] + pd_result = pd_int64_col.apply(featurize) - scalars_df, scalars_pandas_df = scalars_dfs + # Ignore any dtype disparity. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) - bf_int64_col = scalars_df["int64_too"] - bf_result = bf_int64_col.apply(featurize).to_pandas() + # Make sure the read_gbq_function path works for this function. + featurize_ref = session.read_gbq_function(featurize.bigframes_bigquery_function) - pd_int64_col = scalars_pandas_df["int64_too"] - pd_result = pd_int64_col.apply(featurize) + 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 + ) - # Ignore any dtype disparity. - pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + # Test on the function from read_gbq_function. + got = featurize_ref(10) + assert got == [10.0, 11.0, 12.0] - # Make sure the read_gbq_function path works for this function. - featurize_ref = session.read_gbq_function(f"{dataset_id}.{function_id}") + bf_result_gbq = bf_int64_col.apply(featurize_ref).to_pandas() + pandas.testing.assert_series_equal(bf_result_gbq, pd_result, check_dtype=False) - # Test on the function from read_gbq_function. - got = featurize_ref(10) - assert got == [10.0, 11.0, 12.0] + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets(featurize, session.bqclient, ignore_failures=False) - bf_result_gbq = bf_int64_col.apply(featurize_ref).to_pandas() - pandas.testing.assert_series_equal(bf_result_gbq, pd_result, check_dtype=False) +def test_managed_function_series_apply(session, dataset_id, scalars_dfs): + try: -def test_managed_function_series_apply(session, dataset_id, scalars_dfs, function_id): - @session.udf(dataset=dataset_id, name=function_id) - def foo(x: int) -> bytes: - return bytes(abs(x)) + # 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'" - # Function should still work normally. - assert foo(-2) == bytes(2) + @session.udf(dataset=dataset_id, name=bq_name) + def foo(x: int) -> bytes: + return bytes(abs(x)) - scalars_df, scalars_pandas_df = scalars_dfs + # Function should still work normally. + assert foo(-2) == bytes(2) - bf_result_col = scalars_df["int64_too"].apply(foo) - bf_result = ( - scalars_df["int64_too"].to_frame().assign(result=bf_result_col).to_pandas() - ) + assert hasattr(foo, "bigframes_bigquery_function") + assert hasattr(foo, "input_dtypes") + assert hasattr(foo, "output_dtype") + assert hasattr(foo, "bigframes_bigquery_function_output_dtype") - pd_result_col = scalars_pandas_df["int64_too"].apply(foo) - pd_result = scalars_pandas_df["int64_too"].to_frame().assign(result=pd_result_col) + scalars_df, scalars_pandas_df = scalars_dfs - pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + bf_result_col = scalars_df["int64_too"].apply(foo) + bf_result = ( + scalars_df["int64_too"].to_frame().assign(result=bf_result_col).to_pandas() + ) - # Make sure the read_gbq_function path works for this function. - foo_ref = session.read_gbq_function(f"{dataset_id}.{function_id}") + pd_result_col = scalars_pandas_df["int64_too"].apply(foo) + pd_result = ( + scalars_pandas_df["int64_too"].to_frame().assign(result=pd_result_col) + ) - 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, pd_result, check_dtype=False) - pandas.testing.assert_frame_equal(bf_result_gbq, 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, - function_id, ): - with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): - - @session.udf(dataset=dataset_id, name=function_id) - 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() - ) + try: - 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) + with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): - # Ignore any dtype difference. - pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) + @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 -def test_managed_function_series_combine(session, dataset_id, scalars_dfs, function_id): - # This function is deliberately written to not work with NA input. - def add(x: int, y: int) -> int: - return x + y + 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() + ) - 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]] + 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) + ) - # 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]]) + # 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) - add_managed_func = session.udf(dataset=dataset_id, name=function_id)(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() +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() + ) - # 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) - # 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 + ) - # Make sure the read_gbq_function path works for this function. - add_managed_func_ref = session.read_gbq_function(f"{dataset_id}.{function_id}") - 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) +def test_managed_function_series_combine_array_output(session, dataset_id, scalars_dfs): + try: -def test_managed_function_series_combine_array_output( - session, dataset_id, scalars_dfs, function_id -): - # 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] + # 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() + ) - 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]] + # Ignore any dtype difference. + pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) - # 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]]) + # 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 + ) - 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=function_id, - )(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() - ) + 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 + ) - # Ignore any dtype difference. - pandas.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) + # Test on the function from read_gbq_function. + got = add_list_managed_func_ref(10, 38) + assert got == [10, 38] - # Make sure the read_gbq_function path works for this function. - add_list_managed_func_ref = session.read_gbq_function(f"{dataset_id}.{function_id}") + 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() + ) - # Test on the function from read_gbq_function. - got = add_list_managed_func_ref(10, 38) - assert got == [10, 38] + 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 + ) - 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) +def test_managed_function_dataframe_map(session, dataset_id, scalars_dfs): + try: + def add_one(x): + return x + 1 -def test_managed_function_dataframe_map(session, dataset_id, scalars_dfs, function_id): - 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) - mf_add_one = session.udf( - input_types=[int], - output_type=int, - dataset=dataset_id, - name=function_id, - )(add_one) + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] - 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() - 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) - 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) - pandas.testing.assert_frame_equal(bf_result, pd_result) +def test_managed_function_dataframe_map_array_output(session, scalars_dfs, dataset_id): + try: -def test_managed_function_dataframe_map_array_output( - session, scalars_dfs, dataset_id, function_id -): - def add_one_list(x): - return [x + 1] * 3 + 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=function_id, - )(add_one_list) + 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"] + 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() + 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) + 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) + # 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(f"{dataset_id}.{function_id}") + # 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) + 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, function_id -): - scalars_df, scalars_pandas_df = scalars_dfs - series = scalars_df["int64_too"] - series_pandas = scalars_pandas_df["int64_too"] +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 + def add_ints(x, y): + return x + y - add_ints_mf = session.udf( - input_types=[int, int], - output_type=int, - dataset=dataset_id, - name=function_id, - )(add_ints) - - 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() + 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 ) - 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 - ) + 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, function_id -): +def test_managed_function_dataframe_apply_axis_1_array_output(session, dataset_id): bf_df = bigframes.dataframe.DataFrame( { "Id": [1, 2, 3], @@ -372,69 +445,92 @@ def test_managed_function_dataframe_apply_axis_1_array_output( input_types=[int, float, str], output_type=list[str], dataset=dataset_id, - name=function_id, + name=prefixer.create_prefix(), ) def foo(x, y, z): return [str(x), str(y), z] - # 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) + try: - 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) + 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 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) + # Fails to apply on dataframe with incompatible number of columns. + with pytest.raises( + ValueError, + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 columns\\.$", + ): + bf_df[["Id", "Age"]].apply(foo, axis=1) + + with pytest.raises( + ValueError, + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 columns\\.$", + ): + bf_df.assign(Country="lalaland").apply(foo, axis=1) + + # Fails to apply on dataframe with incompatible column datatypes. + with pytest.raises( + ValueError, + match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", + ): + 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"], + ] + ) - # 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 + ) - 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) - # Make sure the read_gbq_function path works for this function. - foo_ref = session.read_gbq_function(f"{dataset_id}.{function_id}") + 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"] + # 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() + 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 - ) + 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( @@ -445,78 +541,95 @@ def foo(x, y, z): ], ) def test_managed_function_with_connection( - session, scalars_dfs, dataset_id, request, connection_fixture, function_id + session, scalars_dfs, dataset_id, request, connection_fixture ): - bigquery_connection = request.getfixturevalue(connection_fixture) + try: + bigquery_connection = request.getfixturevalue(connection_fixture) - @session.udf( - bigquery_connection=bigquery_connection, - dataset=dataset_id, - name=function_id, - ) - def foo(x: int) -> int: - return x + 10 + @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 + # Function should still work normally. + assert foo(-2) == 8 - scalars_df, scalars_pandas_df = scalars_dfs + 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() - ) + 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) + ) - 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) - pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) +def test_managed_function_options(session, dataset_id, scalars_dfs): + try: -def test_managed_function_options(session, dataset_id, scalars_dfs, function_id): - def multiply_five(x: int) -> int: - return x * 5 + def multiply_five(x: int) -> int: + return x * 5 - mf_multiply_five = session.udf( - dataset=dataset_id, - name=function_id, - max_batching_rows=100, - container_cpu=2, - container_memory="2Gi", - )(multiply_five) + 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 + 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() + 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) + 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) + 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=f"{dataset_id}.{function_id}" # type: ignore - ) + # 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) + 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(f"{dataset_id}.{function_id}") + # 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" + # 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, function_id): +def test_managed_function_options_errors(session, dataset_id): def foo(x: int) -> int: return 0 @@ -527,7 +640,7 @@ def foo(x: int) -> int: ): session.udf( dataset=dataset_id, - name=function_id, + name=prefixer.create_prefix(), max_batching_rows=100, container_cpu=2.5, container_memory="2Gi", @@ -540,7 +653,7 @@ def foo(x: int) -> int: ): session.udf( dataset=dataset_id, - name=function_id, + name=prefixer.create_prefix(), max_batching_rows=100, container_cpu=0.10, container_memory="512Mi", @@ -553,117 +666,120 @@ def foo(x: int) -> int: ): session.udf( dataset=dataset_id, - name=function_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, function_id -): +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, + 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 + ], } - ) - with pytest.raises( - TypeError, - match="Argument type hint must be Pandas Series, not BigFrames Series.", - ): + 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, + } + ) + serialize_row_mf = session.udf( input_types=bigframes.series.Series, output_type=str, dataset=dataset_id, - name=function_id, + name=prefixer.create_prefix(), )(serialize_row) - serialize_row_mf = session.udf( - input_types=pandas.Series, - output_type=str, - dataset=dataset_id, - name=function_id, - )(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 = 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) + # 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( - f"{dataset_id}.{function_id}", 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) + # 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, function_id -): +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 - 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()), - } - ) + try: - 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=function_id, - )(analyze) + 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=bigframes.series.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(analyze) - bf_result = scalars_df[columns].dropna().apply(analyze_mf, axis=1).to_pandas() - pd_result = scalars_pandas_df[columns].dropna().apply(analyze, axis=1) + assert getattr(analyze_mf, "is_row_processor") - # 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) + 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( @@ -733,50 +849,58 @@ def analyze(row): ), ], ) -def test_managed_function_df_apply_axis_1_complex( - session, dataset_id, pd_df, function_id -): +def test_managed_function_df_apply_axis_1_complex(session, dataset_id, pd_df): bf_df = session.read_pandas(pd_df) - 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, + 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=function_id, - )(serialize_row) + serialize_row_mf = session.udf( + input_types=bigframes.series.Series, + output_type=str, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(serialize_row) - bf_result = bf_df.apply(serialize_row_mf, axis=1).to_pandas() - pd_result = pd_df.apply(serialize_row, axis=1) + assert getattr(serialize_row_mf, "is_row_processor") - # ignore known dtype difference between pandas and bigframes. - pandas.testing.assert_series_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + 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, function_id): +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. """ @@ -800,400 +924,254 @@ def test_managed_function_df_apply_axis_1_na_nan_inf(dataset_id, session, functi pd_df = bf_df.to_pandas() - 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=function_id, - )(float_parser) - - 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) - - -def test_managed_function_df_apply_axis_1_args( - session, dataset_id, scalars_dfs, function_id -): - columns = ["int64_col", "int64_too"] - scalars_df, scalars_pandas_df = scalars_dfs - - 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=function_id, - )(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) - - -def test_managed_function_df_apply_axis_1_series_args( - session, dataset_id, scalars_dfs, function_id -): - columns = ["int64_col", "float64_col"] - scalars_df, scalars_pandas_df = scalars_dfs - - 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=function_id, - )(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( - f"{dataset_id}.{function_id}", 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) - - -def test_managed_function_df_where_mask(session, dataset_id, scalars_dfs, function_id): - # 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=function_id, - )(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(f"{dataset_id}.{function_id}") - - 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) - - -def test_managed_function_df_where_mask_series( - session, dataset_id, scalars_dfs, function_id -): - # 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 + try: - is_sum_positive_series_mf = session.udf( - input_types=pandas.Series, - output_type=bool, - dataset=dataset_id, - name=function_id, - )(is_sum_positive_series) + def float_parser(row): + import numpy as mynp + import pandas as mypd - scalars_df, scalars_pandas_df = scalars_dfs - int64_cols = ["int64_col", "int64_too"] + if row["text"] == "pandas na": + return mypd.NA + if row["text"] == "numpy nan": + return mynp.nan + return float(row["text"]) - 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) + float_parser_mf = session.udf( + input_types=bigframes.series.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 + ) - # 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( - f"{dataset_id}.{function_id}", is_row_processor=True - ) +def test_managed_function_df_where_mask(session, dataset_id, scalars_dfs): + try: - # This is for callable `other` arg in dataframe.where method. - def func_for_other(x): - return -x + # The return type has to be bool type for callable where condition. + def is_sum_positive(a, b): + return a + b > 0 - 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) + is_sum_positive_mf = session.udf( + input_types=[int, int], + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(is_sum_positive) - # Ignore any dtype difference. - pandas.testing.assert_frame_equal(bf_result_gbq, pd_result_gbq, check_dtype=False) + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] - # 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) + 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() - # Ignore any dtype difference. - pandas.testing.assert_frame_equal(bf_result_gbq, pd_result_gbq, check_dtype=False) + # 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) -def test_managed_function_df_where_other_issue( - session, dataset_id, scalars_df_index, function_id -): - def the_sum(s: pandas.Series) -> int: - return s["int64_col"] + s["int64_too"] + # 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 + ) - the_sum_mf = session.udf( - dataset=dataset_id, - name=function_id, - )(the_sum) + 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 + ) - int64_cols = ["int64_col", "int64_too"] + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) - bf_int64_df = scalars_df_index[int64_cols] - bf_int64_df_filtered = bf_int64_df.dropna() + # 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 + ) - 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) + # 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_series_where_mask_map( - session, dataset_id, scalars_dfs, function_id -): - # 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=function_id, - )(_is_positive) +def test_managed_function_df_where_mask_series(session, dataset_id, scalars_dfs): + try: - scalars, scalars_pandas = scalars_dfs + # 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 - bf_int64 = scalars["int64_col"] - bf_int64_filtered = bf_int64.dropna() - pd_int64 = scalars_pandas["int64_col"] - pd_int64_filtered = pd_int64.dropna() + is_sum_positive_series_mf = session.udf( + input_types=bigframes.series.Series, + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(is_sum_positive_series) - # 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) + scalars_df, scalars_pandas_df = scalars_dfs + int64_cols = ["int64_col", "int64_too"] - # Ignore any dtype difference. - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + 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 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) + # Test callable condition in dataframe.where method. + bf_result = bf_int64_df_filtered.where(is_sum_positive_series).to_pandas() + pd_result = pd_int64_df_filtered.where(is_sum_positive_series) - # Ignore any dtype difference. - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # Ignore any dtype difference. + pandas.testing.assert_frame_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) + # 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, + ) - # Ignore any dtype difference. - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # 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 + ) -def test_managed_function_series_apply_args( - session, dataset_id, scalars_dfs, function_id -): - with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) - @session.udf(dataset=dataset_id, name=function_id) - def foo_list(x: int, y0: float, y1: bytes, y2: bool) -> list[str]: - return [str(x), str(y0), str(y1), str(y2)] + # 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 + ) - scalars_df, scalars_pandas_df = scalars_dfs + # Ignore any dtype difference. + pandas.testing.assert_frame_equal( + bf_result_gbq, pd_result_gbq, check_dtype=False + ) - 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) - ) + finally: + # Clean up the gcp assets created for the managed function. + cleanup_function_assets( + is_sum_positive_series_mf, session.bqclient, ignore_failures=False + ) - # Ignore any dtype difference. - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) +def test_managed_function_series_where_mask(session, dataset_id, scalars_dfs): + try: -def test_deferred_unnamed_udf_execution(session, scalars_dfs): - import bigframes.functions.udf_def as udf_def + # The return type has to be bool type for callable where condition. + def _is_positive(s): + return s + 1000 > 0 - # Create an unnamed UDF (name=None) - @session.udf() - def unnamed_multiplier(x: int) -> int: - return x * 3 + is_positive_mf = session.udf( + input_types=int, + output_type=bool, + dataset=dataset_id, + name=prefixer.create_prefix(), + )(_is_positive) - assert isinstance(unnamed_multiplier.udf_def, udf_def.PythonUdf) + scalars, scalars_pandas = scalars_dfs - scalars_df, scalars_pandas_df = scalars_dfs - bf_series = scalars_df["int64_too"] - pd_series = scalars_pandas_df["int64_too"] + bf_int64 = scalars["int64_col"] + bf_int64_filtered = bf_int64.dropna() + pd_int64 = scalars_pandas["int64_col"] + pd_int64_filtered = pd_int64.dropna() - bf_result = bf_series.apply(unnamed_multiplier).to_pandas() - pd_result = pd_series.apply(lambda x: x * 3) + # 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) - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) - import bigframes.functions._function_session as functions_sessions + # 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) - config = unnamed_multiplier.udf_def.to_managed_function_config() - expected_routine_name = functions_sessions.get_managed_function_name( - config, session.session_id - ) - routine = session.bqclient.get_routine( - f"{session._anonymous_dataset.project}.{session._anonymous_dataset.dataset_id}.{expected_routine_name}" - ) - assert routine is not None + # 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_deferred_udf_with_runtime_requirements(session, scalars_dfs): - import bigframes.functions.udf_def as udf_def - # Create an unnamed UDF with custom options - @session.udf( - container_cpu=1, - container_memory="2Gi", - max_batching_rows=25, - ) - def heavy_unnamed_udf(x: int) -> int: - return x + 100 +def test_managed_function_series_apply_args(session, dataset_id, scalars_dfs): + try: - assert isinstance(heavy_unnamed_udf.udf_def, udf_def.PythonUdf) + with pytest.warns(bfe.PreviewWarning, match="udf is in preview."): - scalars_df, scalars_pandas_df = scalars_dfs - bf_series = scalars_df["int64_too"] - pd_series = scalars_pandas_df["int64_too"] + @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)] - bf_result = bf_series.apply(heavy_unnamed_udf).to_pandas() - pd_result = pd_series.apply(lambda x: x + 100) + scalars_df, scalars_pandas_df = scalars_dfs - pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) + 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) + ) - # Verify it was deployed with the correct runtime options - import bigframes.functions._function_session as functions_sessions + # Ignore any dtype difference. + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) - config = heavy_unnamed_udf.udf_def.to_managed_function_config() - expected_routine_name = functions_sessions.get_managed_function_name( - config, session.session_id - ) - routine = session.bqclient.get_routine( - f"{session._anonymous_dataset.project}.{session._anonymous_dataset.dataset_id}.{expected_routine_name}" - ) - assert routine._properties["externalRuntimeOptions"]["containerCpu"] == 1 - assert routine._properties["externalRuntimeOptions"]["containerMemory"] == "2Gi" - assert routine._properties["externalRuntimeOptions"]["maxBatchingRows"] == "25" + 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 69769a1a846..3c453a52a47 100644 --- a/tests/system/large/functions/test_remote_function.py +++ b/tests/system/large/functions/test_remote_function.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import datetime import importlib.util import inspect import math # must keep this at top level to test udf referring global import @@ -19,24 +20,23 @@ import shutil import tempfile import textwrap -import uuid import warnings -from datetime import datetime import google.api_core.exceptions +from google.cloud import bigquery, functions_v2, storage import pandas import pytest import test_utils.prefixer -from google.cloud import bigquery, functions_v2, storage import bigframes import bigframes.dataframe import bigframes.dtypes import bigframes.exceptions +import bigframes.functions._utils as bff_utils import bigframes.pandas as bpd import bigframes.series from bigframes.testing.utils import ( - assert_frame_equal, + assert_pandas_df_equal, cleanup_function_assets, delete_cloud_function, get_cloud_functions, @@ -214,7 +214,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -261,7 +261,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -349,7 +349,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -403,7 +403,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -453,7 +453,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -503,7 +503,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -526,6 +526,24 @@ def add_one(x): # Make a unique udf 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) + add_one_uniq_cf_name = bff_utils.get_cloud_function_name( + add_one_uniq_hash, session.session_id + ) + + # There should be no cloud function yet for the unique udf + cloud_functions = list( + get_cloud_functions( + session.cloudfunctionsclient, + session.bqclient.project, + session.bqclient.location, + name=add_one_uniq_cf_name, + ) + ) + assert len(cloud_functions) == 0 + # 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( @@ -537,9 +555,6 @@ def add_one(x): cloud_function_service_account="default", )(add_one_uniq) - assert remote_add_one.bigframes_cloud_function is not None - add_one_uniq_cf_name = remote_add_one.bigframes_cloud_function.split("/")[-1] - # There should have been excactly one cloud function created at this point cloud_functions = list( get_cloud_functions( @@ -576,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) # Test that the remote function works as expected inner_test() @@ -668,7 +683,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -712,7 +727,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -755,7 +770,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -814,7 +829,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -827,6 +842,7 @@ def test_remote_function_with_external_package_dependencies( session, scalars_dfs, dataset_id, bq_cf_connection ): try: + # 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. @@ -868,7 +884,7 @@ def pd_np_foo(x) -> None: # comparing for the purpose of this test pd_result.result = pd_result.result.astype(pandas.Float64Dtype()) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( @@ -881,6 +897,7 @@ def test_remote_function_with_explicit_name_reuse( session, scalars_dfs, dataset_id, bq_cf_connection ): try: + dirs_to_cleanup = [] # Define a user code @@ -911,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) # Create an explicit name for the remote function prefixer = test_utils.prefixer.Prefixer("foo", "") @@ -1092,7 +1109,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -1133,7 +1150,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @@ -1208,14 +1225,14 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) finally: # clean up the gcp assets created for the remote function cleanup_function_assets(square, session.bqclient, session.cloudfunctionsclient) @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_via_session_custom_sa(scalars_pandas_df_index): +def test_remote_function_via_session_custom_sa(scalars_dfs): # TODO(shobs): Automate the following set-up during testing in the test project. # # For upfront convenience, the following set up has been statically created @@ -1235,39 +1252,42 @@ def test_remote_function_via_session_custom_sa(scalars_pandas_df_index): 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=gcf_service_account, - cloud_function_ingress_settings="internal-and-gclb", + cloud_function_ingress_settings="all", ) - def double_num(x): + def square_num(x): if x is None: return x - return x + x + return x * x # assert that the GCF is created with the intended SA gcf = rf_session.cloudfunctionsclient.get_function( - name=double_num.bigframes_cloud_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 = rf_session.read_pandas(scalars_pandas_df_index.int64_col) - bf_result_col = bf_int64_col.apply(double_num) + 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_index.int64_col - pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x + x) + 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) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( - double_num, rf_session.bqclient, rf_session.cloudfunctionsclient + square_num, rf_session.bqclient, rf_session.cloudfunctionsclient ) @@ -1286,7 +1306,7 @@ def double_num(x): ) @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_via_session_custom_build_sa( - set_build_service_account, scalars_pandas_df_index + scalars_dfs, set_build_service_account ): # TODO(shobs): Automate the following set-up during testing in the test project. # @@ -1305,38 +1325,43 @@ def test_remote_function_via_session_custom_build_sa( 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="internal-and-gclb", + cloud_function_ingress_settings="all", ) - def double_num(x): + def square_num(x): if x is None: return x - return x + x + return x * x # assert that the GCF is created with the intended SA gcf = rf_session.cloudfunctionsclient.get_function( - name=double_num.bigframes_cloud_function + name=square_num.bigframes_cloud_function ) assert gcf.build_config.service_account == expected_build_service_account - bf_int64_col = rf_session.read_pandas(scalars_pandas_df_index.int64_col) - bf_result_col = bf_int64_col.apply(double_num) + # 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_index.int64_col - pd_result_col = pd_int64_col.apply(lambda x: x if x is None else x + x) + 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) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( - double_num, rf_session.bqclient, rf_session.cloudfunctionsclient + square_num, rf_session.bqclient, rf_session.cloudfunctionsclient ) @@ -1391,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_frame_equal( + assert_pandas_df_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1415,7 +1440,7 @@ def square_num(x): @pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_via_session_vpc(scalars_pandas_df_index): +def test_remote_function_via_session_vpc(scalars_dfs): # TODO(shobs): Automate the following set-up during testing in the test project. # # For upfront convenience, the following set up has been statically created @@ -1440,88 +1465,47 @@ def test_remote_function_via_session_vpc(scalars_pandas_df_index): try: - def double_num(x): + def square_num(x): if x is None: return x - return x + x + return x * x - double_num_remote = rf_session.remote_function( + # 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( 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="internal-and-gclb", - )(double_num) + cloud_function_ingress_settings="all", + )(square_num) + # assert that the GCF is created with the intended vpc connector gcf = rf_session.cloudfunctionsclient.get_function( - name=double_num_remote.bigframes_cloud_function + name=square_num_remote.bigframes_cloud_function ) - - # assert that the GCF test_remote_function_via_session_custom_sais 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 - bf_int64_col = rf_session.read_pandas(scalars_pandas_df_index.int64_col) - bf_result_col = bf_int64_col.apply(double_num_remote) + # 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_remote) bf_result = bf_int64_col.to_frame().assign(result=bf_result_col).to_pandas() - pd_int64_col = scalars_pandas_df_index.int64_col - pd_result_col = pd_int64_col.apply(double_num) + pd_int64_col = scalars_pandas_df["int64_col"] + pd_result_col = pd_int64_col.apply(square_num) pd_result = pd_int64_col.to_frame().assign(result=pd_result_col) - assert_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) finally: # clean up the gcp assets created for the remote function cleanup_function_assets( - double_num_remote, rf_session.bqclient, rf_session.cloudfunctionsclient + 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"), [ @@ -1547,9 +1531,7 @@ def square(x): bq_routine = session.bqclient.get_routine( square_remote.bigframes_bigquery_function ) - assert bq_routine.remote_function_options.max_batching_rows == ( - max_batching_rows or 1000 - ) + assert bq_routine.remote_function_options.max_batching_rows == max_batching_rows scalars_df, scalars_pandas_df = scalars_dfs @@ -1623,13 +1605,11 @@ 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({}, 0, id="no-set"), - pytest.param({"cloud_function_max_instances": None}, 0, id="set-None"), + pytest.param({}, 100, id="no-set"), + pytest.param({"cloud_function_max_instances": None}, 100, id="set-None"), pytest.param({"cloud_function_max_instances": 1000}, 1000, id="set-explicit"), ], ) @@ -1669,51 +1649,6 @@ def square(x): ) -@pytest.mark.flaky(retries=2, delay=120) -def test_remote_function_reflects_config_change_with_reuse(session): - square_remote = None - square_remote_2 = None - try: - - def square(x): - return x * x - - # random alphanumeric name starting with a letter - deploy_name = "a" + str(uuid.uuid4().hex) - square_remote = session.remote_function( - input_types=[int], - name=deploy_name, - output_type=int, - reuse=True, - cloud_function_service_account="default", - cloud_function_cpus=1, - )(square) - square_remote_2 = session.remote_function( - input_types=[int], - name=deploy_name, - output_type=int, - reuse=True, - cloud_function_service_account="default", - cloud_function_cpus=2, - )(square) - - # Assert that the GCF is created with the intended max instance count - gcf = session.cloudfunctionsclient.get_function( - name=square_remote_2.bigframes_cloud_function - ) - assert float(gcf.service_config.available_cpu) == 2.0 - finally: - # clean up the gcp assets created for the remote function - if square_remote is not None: - cleanup_function_assets( - square_remote, session.bqclient, session.cloudfunctionsclient - ) - if square_remote_2 is not None: - cleanup_function_assets( - square_remote_2, session.bqclient, session.cloudfunctionsclient - ) - - @pytest.mark.flaky(retries=2, delay=120) def test_df_apply_axis_1(session, scalars_dfs): columns = ["bool_col", "int64_col", "int64_too", "float64_col", "string_col"] @@ -1741,7 +1676,7 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1790,7 +1725,7 @@ def analyze(row): ) analyze_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1914,7 +1849,7 @@ def serialize_row(row): ) serialize_row_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=str, reuse=False, cloud_function_service_account="default", @@ -1963,7 +1898,7 @@ def test_df_apply_axis_1_na_nan_inf(session): try: - def float_parser(row: pandas.Series): + def float_parser(row): import numpy as mynp import pandas as mypd @@ -1974,6 +1909,7 @@ def float_parser(row: pandas.Series): return float(row["text"]) float_parser_remote = session.remote_function( + input_types=bigframes.series.Series, output_type=float, reuse=False, cloud_function_service_account="default", @@ -2001,149 +1937,20 @@ def float_parser(row: pandas.Series): ) -@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( - ( - "memory_mib_args", - "expected_memory", - "expected_cpus", - ), + ("memory_mib_args", "expected_memory"), [ - pytest.param({}, "1024Mi", None, id="no-set"), - pytest.param( - {"cloud_function_memory_mib": None}, "1024Mi", None, id="set-None" - ), - pytest.param({"cloud_function_memory_mib": 128}, "128Mi", None, id="set-128"), - pytest.param( - {"cloud_function_memory_mib": 512, "cloud_function_cpus": 0.6}, - "512Mi", - "0.6", - id="set-512", - ), - pytest.param( - {"cloud_function_memory_mib": 1024}, "1024Mi", None, id="set-1024" - ), - pytest.param( - {"cloud_function_memory_mib": 4096, "cloud_function_cpus": 4}, - "4096Mi", - "4", - id="set-4096", - ), - pytest.param( - {"cloud_function_memory_mib": 32768}, "32768Mi", None, id="set-32768" - ), + pytest.param({}, "1024Mi", id="no-set"), + pytest.param({"cloud_function_memory_mib": None}, "256M", id="set-None"), + pytest.param({"cloud_function_memory_mib": 128}, "128Mi", id="set-128"), + pytest.param({"cloud_function_memory_mib": 1024}, "1024Mi", id="set-1024"), + pytest.param({"cloud_function_memory_mib": 4096}, "4096Mi", id="set-4096"), + pytest.param({"cloud_function_memory_mib": 32768}, "32768Mi", id="set-32768"), ], ) @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_gcf_memory( - session, scalars_dfs, memory_mib_args, expected_memory, expected_cpus + session, scalars_dfs, memory_mib_args, expected_memory ): try: @@ -2159,12 +1966,6 @@ def square(x: int) -> int: name=square_remote.bigframes_cloud_function ) assert gcf.service_config.available_memory == expected_memory - if expected_cpus is not None: - assert gcf.service_config.available_cpu == expected_cpus - if float(gcf.service_config.available_cpu) >= 1.0: - assert gcf.service_config.max_instance_request_concurrency >= float( - gcf.service_config.available_cpu - ) scalars_df, scalars_pandas_df = scalars_dfs @@ -2186,8 +1987,12 @@ def square(x: int) -> int: pytest.param(32769, id="set-32769-too-high"), ], ) +@pytest.mark.flaky(retries=2, delay=120) def test_remote_function_gcf_memory_unsupported(session, memory_mib): - with pytest.raises(ValueError, match="Cloud run supports"): + with pytest.raises( + google.api_core.exceptions.InvalidArgument, + match="Invalid value specified for container memory", + ): @session.remote_function( reuse=False, @@ -2395,19 +2200,19 @@ def foo(x, y, z): # 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.", + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 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.", + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 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 .*", + match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2479,19 +2284,19 @@ def foo(x, y, z): # 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.", + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 2 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.", + match="^BigFrames BigQuery function takes 3 arguments but DataFrame has 4 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 .*", + match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", ): bf_df.assign(Age=bf_df["Age"].astype("Int64")).apply(foo, axis=1) @@ -2553,19 +2358,19 @@ def foo(x): # Fails to apply on dataframe with incompatible number of columns with pytest.raises( ValueError, - match="^Parameter count mismatch:.* expected 1 parameters but received 0 DataFrame.*", + match="^BigFrames BigQuery function takes 1 arguments but DataFrame has 0 columns\\.$", ): bf_df[[]].apply(foo, axis=1) with pytest.raises( ValueError, - match="^Parameter count mismatch:.* expected 1 parameters but received 2 DataFrame.*", + match="^BigFrames BigQuery function takes 1 arguments but DataFrame has 2 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 .*", + match="^BigFrames BigQuery function takes arguments of types .* but DataFrame dtypes are .*", ): bf_df.assign(Id=bf_df["Id"].astype("Float64")).apply(foo, axis=1) @@ -2650,6 +2455,12 @@ def generate_stats(row: pandas.Series) -> list[int]: 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, @@ -3041,6 +2852,7 @@ def foo(x: int) -> int: @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 @@ -3084,57 +2896,17 @@ def is_sum_positive(a, b): ) -@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: + def is_sum_positive_series(s): 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( + input_types=bigframes.series.Series, + output_type=bool, dataset=dataset_id, reuse=False, cloud_function_service_account="default", @@ -3154,7 +2926,7 @@ def func_for_other(x): # Test callable condition in dataframe.where method. bf_result = bf_int64_df_filtered.where( - is_sum_positive_series_mf, func_for_other + is_sum_positive_series, func_for_other ).to_pandas() pd_result = pd_int64_df_filtered.where(is_sum_positive_series, func_for_other) diff --git a/tests/system/large/ml/conftest.py b/tests/system/large/ml/conftest.py deleted file mode 100644 index ffb02e8beb8..00000000000 --- a/tests/system/large/ml/conftest.py +++ /dev/null @@ -1,87 +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 hashlib -import logging - -import google.cloud.exceptions -import pytest -from google.cloud import bigquery - -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_ensemble.py b/tests/system/large/ml/test_ensemble.py index eabd36ab387..c2e9036eed7 100644 --- a/tests/system/large/ml/test_ensemble.py +++ b/tests/system/large/ml/test_ensemble.py @@ -155,7 +155,7 @@ def test_xgbclassifier_default_params(penguins_df_default_index, dataset_id): ) -@pytest.mark.flaky(retries=2) +# @pytest.mark.flaky(retries=2) def test_xgbclassifier_dart_booster_multiple_params( penguins_df_default_index, dataset_id ): diff --git a/tests/system/large/ml/test_forecasting.py b/tests/system/large/ml/test_forecasting.py index 8500ad9d5f1..72a0ee469b5 100644 --- a/tests/system/large/ml/test_forecasting.py +++ b/tests/system/large/ml/test_forecasting.py @@ -88,7 +88,6 @@ def test_arima_plus_model_fit_score( result, columns=expected_columns, index=2 if id_col_name else 1, - col_exact=False, ) # save, load to ensure configuration was kept diff --git a/tests/system/large/ml/test_linear_model.py b/tests/system/large/ml/test_linear_model.py index 60edc717a5a..f0e2892ba80 100644 --- a/tests/system/large/ml/test_linear_model.py +++ b/tests/system/large/ml/test_linear_model.py @@ -13,10 +13,9 @@ # limitations under the License. import pandas as pd -import pytest -import bigframes.ml.linear_model from bigframes.ml import model_selection +import bigframes.ml.linear_model from bigframes.testing import utils @@ -62,20 +61,12 @@ 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( - df_fixture, dataset_id, request + penguins_df_default_index, dataset_id ): - df = request.getfixturevalue(df_fixture) model = bigframes.ml.linear_model.LinearRegression() - df = df.dropna() + df = penguins_df_default_index.dropna() X = df[ [ "species", @@ -118,7 +109,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 = df._session.bqclient.get_model(bq_model_name) + bq_model = penguins_df_default_index._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 @@ -461,39 +452,3 @@ 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 deleted file mode 100644 index 638e151ca14..00000000000 --- a/tests/system/large/ml/test_llm.py +++ /dev/null @@ -1,799 +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 typing import Callable -from unittest import mock - -import pandas as pd -import pyarrow as pa -import pytest - -import bigframes.pandas as bpd -from bigframes.ml import core, llm -from bigframes.testing import utils - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.5-pro", - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -@pytest.mark.flaky(retries=2) -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.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.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.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.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.5-flash", - "gemini-2.5-flash-lite", - ), -) -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, - col_exact=False, - ) - - -@pytest.mark.parametrize( - "model_name", - ( - "gemini-2.5-flash", - "gemini-2.5-flash-lite", - ), -) -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", - ], - col_exact=False, - ) - - -@pytest.mark.parametrize( - "model_name", - ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), -) -def test_create_load_text_embedding_generator_model( - dataset_id, model_name, session, bq_connection -): - text_embedding_model = llm.TextEmbeddingGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - assert text_embedding_model is not None - assert text_embedding_model._bqml_model is not None - - # save, load to ensure configuration was kept - reloaded_model = text_embedding_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", - ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), -) -@pytest.mark.flaky(retries=2) -def test_text_embedding_generator_predict_default_params_success( - llm_text_df, model_name, session, bq_connection -): - text_embedding_model = llm.TextEmbeddingGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - df = text_embedding_model.predict(llm_text_df).to_pandas() - utils.check_pandas_df_schema_and_index( - df, columns=utils.ML_GENERATE_EMBEDDING_OUTPUT, index=3, col_exact=False - ) - assert len(df["ml_generate_embedding_result"][0]) == 768 - - -@pytest.mark.parametrize( - "model_name", - ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), -) -@pytest.mark.flaky(retries=2) -def test_text_embedding_generator_multi_cols_predict_success( - llm_text_df: bpd.DataFrame, model_name, session, bq_connection -): - df = llm_text_df.assign(additional_col=1) - df = df.rename(columns={"prompt": "content"}) - text_embedding_model = llm.TextEmbeddingGenerator( - model_name=model_name, connection_name=bq_connection, session=session - ) - pd_df = text_embedding_model.predict(df).to_pandas() - utils.check_pandas_df_schema_and_index( - pd_df, - columns=utils.ML_GENERATE_EMBEDDING_OUTPUT + ["additional_col"], - index=3, - col_exact=False, - ) - assert len(pd_df["ml_generate_embedding_result"][0]) == 768 - - -def test_create_load_multimodal_embedding_generator_model( - dataset_id, session, bq_connection -): - mm_embedding_model = llm.MultimodalEmbeddingGenerator( - connection_name=bq_connection, session=session - ) - 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 = mm_embedding_model.to_gbq( - f"{dataset_id}.temp_mm_model", replace=True - ) - assert f"{dataset_id}.temp_mm_model" == reloaded_model._bqml_model.model_name - assert reloaded_model.connection_name == bq_connection - - -# Overrides __eq__ function for comparing as mock.call parameter -class EqCmpAllDataFrame(bpd.DataFrame): - def __eq__(self, other): - return self.equals(other) - - -@pytest.mark.skip("b/436340035 test failed") -@pytest.mark.parametrize( - ( - "model_class", - "options", - ), - [ - ( - llm.GeminiTextGenerator, - { - "temperature": 0.9, - "max_output_tokens": 8192, - "top_p": 1.0, - "ground_with_google_search": False, - }, - ), - ( - llm.Claude3TextGenerator, - { - "max_output_tokens": 128, - "top_k": 40, - "top_p": 0.95, - }, - ), - ], -) -def test_text_generator_retry_success( - session, - model_class, - options, - bq_connection, -): - # Requests. - df0 = EqCmpAllDataFrame( - { - "prompt": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ] - }, - index=[0, 1, 2], - session=session, - ) - df1 = EqCmpAllDataFrame( - { - "ml_generate_text_status": ["error", "error"], - "prompt": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ) - df2 = EqCmpAllDataFrame( - { - "ml_generate_text_status": ["error"], - "prompt": [ - "What is BQML?", - ], - }, - index=[1], - 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_generate_text.side_effect = [ - EqCmpAllDataFrame( - { - "ml_generate_text_status": ["", "error", "error"], - "prompt": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_text_status": ["error", ""], - "prompt": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_text_status": [""], - "prompt": [ - "What is BQML?", - ], - }, - index=[1], - session=session, - ), - ] - - text_generator_model = model_class(connection_name=bq_connection, session=session) - text_generator_model._bqml_model = mock_bqml_model - - 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_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", - "options", - ), - [ - ( - llm.GeminiTextGenerator, - { - "temperature": 0.9, - "max_output_tokens": 8192, - "top_p": 1.0, - "ground_with_google_search": False, - }, - ), - ( - llm.Claude3TextGenerator, - { - "max_output_tokens": 128, - "top_k": 40, - "top_p": 0.95, - }, - ), - ], -) -def test_text_generator_retry_no_progress(session, model_class, options, bq_connection): - # Requests. - df0 = EqCmpAllDataFrame( - { - "prompt": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ] - }, - index=[0, 1, 2], - session=session, - ) - df1 = EqCmpAllDataFrame( - { - "ml_generate_text_status": ["error", "error"], - "prompt": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - 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_generate_text.side_effect = [ - EqCmpAllDataFrame( - { - "ml_generate_text_status": ["", "error", "error"], - "prompt": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_text_status": ["error", "error"], - "prompt": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ), - ] - - text_generator_model = model_class(connection_name=bq_connection, session=session) - text_generator_model._bqml_model = mock_bqml_model - - 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_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( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ] - }, - index=[0, 1, 2], - session=session, - ) - df1 = EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["error", "error"], - "content": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ) - df2 = EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["error"], - "content": [ - "What is BQML?", - ], - }, - index=[1], - 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_generate_embedding.side_effect = [ - EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["", "error", "error"], - "content": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["error", ""], - "content": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_embedding_status": [""], - "content": [ - "What is BQML?", - ], - }, - index=[1], - session=session, - ), - ] - options: dict = {} - - text_embedding_model = llm.TextEmbeddingGenerator( - connection_name=bq_connection, session=session - ) - text_embedding_model._bqml_model = mock_bqml_model - - 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): - # Requests. - df0 = EqCmpAllDataFrame( - { - "content": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ] - }, - index=[0, 1, 2], - session=session, - ) - df1 = EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["error", "error"], - "content": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - 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_generate_embedding.side_effect = [ - EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["", "error", "error"], - "content": [ - "What is BigQuery?", - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[0, 1, 2], - session=session, - ), - EqCmpAllDataFrame( - { - "ml_generate_embedding_status": ["error", "error"], - "content": [ - "What is BQML?", - "What is BigQuery DataFrame?", - ], - }, - index=[1, 2], - session=session, - ), - ] - options: dict = {} - - text_embedding_model = llm.TextEmbeddingGenerator( - connection_name=bq_connection, session=session - ) - text_embedding_model._bqml_model = mock_bqml_model - - 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) - - 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, - ) - - -# b/436340035 temp disable the test to unblock presumbit -@pytest.mark.parametrize( - "model_class", - [ - llm.TextEmbeddingGenerator, - llm.MultimodalEmbeddingGenerator, - llm.GeminiTextGenerator, - # llm.Claude3TextGenerator, - ], -) -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/large/operations/__init__.py b/tests/system/large/operations/__init__.py new file mode 100644 index 00000000000..6d5e14bcf4a --- /dev/null +++ b/tests/system/large/operations/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/system/large/operations/conftest.py b/tests/system/large/operations/conftest.py new file mode 100644 index 00000000000..6f64c7552f3 --- /dev/null +++ b/tests/system/large/operations/conftest.py @@ -0,0 +1,33 @@ +# 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 pytest + +import bigframes.ml.llm as llm + + +@pytest.fixture(scope="session") +def gemini_flash_model(session, bq_connection) -> llm.GeminiTextGenerator: + return llm.GeminiTextGenerator( + session=session, + connection_name=bq_connection, + model_name="gemini-2.0-flash-001", + ) + + +@pytest.fixture(scope="session") +def text_embedding_generator(session, bq_connection) -> llm.TextEmbeddingGenerator: + return llm.TextEmbeddingGenerator( + session=session, connection_name=bq_connection, model_name="text-embedding-005" + ) diff --git a/tests/system/large/operations/test_ai.py b/tests/system/large/operations/test_ai.py new file mode 100644 index 00000000000..86b30d9c657 --- /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 new file mode 100644 index 00000000000..7ae78a5c53a --- /dev/null +++ b/tests/system/large/operations/test_semantics.py @@ -0,0 +1,1293 @@ +# 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 contextlib import nullcontext +from unittest.mock import patch + +import pandas as pd +import pandas.testing +import pytest + +import bigframes +from bigframes import dataframe, dtypes, exceptions, series + +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" + + +def test_semantics_experiment_off_raise_error(): + df = dataframe.DataFrame( + {"country": ["USA", "Germany"], "city": ["Seattle", "Berlin"]} + ) + + with bigframes.option_context(SEM_OP_EXP_OPTION, False), pytest.raises( + NotImplementedError + ): + df.semantics + + +@pytest.mark.parametrize( + ("max_agg_rows", "cluster_column"), + [ + pytest.param(1, None, id="one", marks=pytest.mark.xfail(raises=ValueError)), + pytest.param(2, None, id="two"), + pytest.param(3, None, id="three"), + pytest.param(4, None, id="four"), + pytest.param(5, "Years", id="two_w_cluster_column"), + pytest.param(6, "Years", id="three_w_cluster_column"), + pytest.param(7, "Years", id="four_w_cluster_column"), + ], +) +def test_agg(session, gemini_flash_model, max_agg_rows, cluster_column): + df = dataframe.DataFrame( + data={ + "Movies": [ + "Titanic", + "The Wolf of Wall Street", + "Killers of the Flower Moon", + "The Revenant", + "Inception", + "Shuttle Island", + "The Great Gatsby", + ], + "Years": [1997, 2013, 2023, 2015, 2010, 2010, 2013], + }, + session=session, + ) + instruction = "Find the shared first name of actors in {Movies}. One word answer." + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 50, + ): + actual_s = df.semantics.agg( + instruction, + model=gemini_flash_model, + max_agg_rows=max_agg_rows, + cluster_column=cluster_column, + ).to_pandas() + + 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) + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_agg_with_confirmation(session, gemini_flash_model, reply, monkeypatch): + df = dataframe.DataFrame( + data={ + "Movies": [ + "Titanic", + "The Wolf of Wall Street", + "Killers of the Flower Moon", + "The Revenant", + "Inception", + "Shuttle Island", + "The Great Gatsby", + ], + "Years": [1997, 2013, 2023, 2015, 2010, 2010, 2013], + }, + session=session, + ) + instruction = "Find the shared first name of actors in {Movies}. One word answer." + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.semantics.agg( + instruction, + model=gemini_flash_model, + ) + + +def test_agg_w_int_column(session, gemini_flash_model): + df = dataframe.DataFrame( + data={ + "Movies": [ + "Killers of the Flower Moon", + "The Great Gatsby", + "The Wolf of Wall Street", + ], + "Years": [2023, 2013, 2013], + }, + session=session, + ) + 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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_s = df.semantics.agg( + instruction, + model=gemini_flash_model, + ).to_pandas() + + 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) + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + "No column reference", + id="zero_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{Movies} is good", + id="non_existing_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{Movies} is better than {Movies}", + id="two_columns", + marks=pytest.mark.xfail(raises=NotImplementedError), + ), + ], +) +def test_agg_invalid_instruction_raise_error(instruction, gemini_flash_model): + df = dataframe.DataFrame( + data={ + "Movies": [ + "Titanic", + "The Wolf of Wall Street", + "Killers of the Flower Moon", + ], + "Year": [1997, 2013, 2023], + }, + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df.semantics.agg(instruction, gemini_flash_model) + + +@pytest.mark.parametrize( + "cluster_column", + [ + pytest.param( + "non_existing_column", + id="non_existing_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "Movies", id="non_int_column", marks=pytest.mark.xfail(raises=TypeError) + ), + ], +) +def test_agg_invalid_cluster_column_raise_error(gemini_flash_model, cluster_column): + df = dataframe.DataFrame( + data={ + "Movies": [ + "Titanic", + "The Wolf of Wall Street", + "Killers of the Flower Moon", + "The Revenant", + ], + }, + ) + instruction = "Find the shared first name of actors in {Movies}. One word answer." + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df.semantics.agg(instruction, gemini_flash_model, cluster_column=cluster_column) + + +@pytest.mark.parametrize( + ("n_clusters"), + [ + pytest.param(1, id="one", marks=pytest.mark.xfail(raises=ValueError)), + pytest.param(2, id="two"), + ], +) +def test_cluster_by(session, text_embedding_generator, n_clusters): + df = dataframe.DataFrame( + ( + { + "Item": [ + "Orange", + "Cantaloupe", + "Watermelon", + "Chicken", + "Duck", + "Hen", + "Rooster", + ] + } + ), + session=session, + ) + output_column = "cluster id" + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + result = df.semantics.cluster_by( + "Item", + output_column, + text_embedding_generator, + n_clusters=n_clusters, + ) + + assert output_column in result + # In rare cases, it's possible to have fewer than K clusters due to randomness. + assert len(result[output_column].unique()) <= n_clusters + + +@pytest.mark.parametrize( + ("reply"), + [ + pytest.param("y"), + pytest.param( + "n", marks=pytest.mark.xfail(raises=exceptions.OperationAbortedError) + ), + ], +) +def test_cluster_by_with_confirmation( + session, text_embedding_generator, reply, monkeypatch +): + df = dataframe.DataFrame( + ( + { + "Item": [ + "Orange", + "Cantaloupe", + "Watermelon", + "Chicken", + "Duck", + "Hen", + "Rooster", + ] + } + ), + session=session, + ) + monkeypatch.setattr("builtins.input", lambda: reply) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.semantics.cluster_by( + "Item", + "cluster id", + text_embedding_generator, + n_clusters=2, + ) + + +def test_cluster_by_invalid_column(session, text_embedding_generator): + df = dataframe.DataFrame( + ({"Product": ["Smartphone", "Laptop", "Coffee Maker", "T-shirt", "Jeans"]}), + session=session, + ) + output_column = "cluster id" + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.cluster_by( + "unknown_column", + output_column, + text_embedding_generator, + n_clusters=3, + ) + + +def test_cluster_by_invalid_model(session, gemini_flash_model): + df = dataframe.DataFrame( + ({"Product": ["Smartphone", "Laptop", "Coffee Maker", "T-shirt", "Jeans"]}), + session=session, + ) + output_column = "cluster id" + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.semantics.cluster_by( + "Product", + output_column, + gemini_flash_model, + n_clusters=3, + ) + + +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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.semantics.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( + 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"), + [ + 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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.semantics.filter("{city} is the capital of {country}", None) + + +def test_map(session, gemini_flash_model): + 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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = df.semantics.map( + "What is the {gluten-free} food made from {ingredient_1} and {ingredient_2}? One word only.", + "food", + gemini_flash_model, + ).to_pandas() + # Result sanitation + actual_df["food"] = actual_df["food"].str.strip().str.lower() + + expected_df = pd.DataFrame( + { + "ingredient_1": ["Burger Bun", "Soy Bean"], + "ingredient_2": ["Beef Patty", "Bittern"], + "gluten-free": [True, True], + "food": ["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( + 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"), + [ + 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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.semantics.map( + "What is the {gluten-free} food made from {ingredient_1} and {ingredient_2}? One word only.", + "food", + 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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.map(instruction, "food", gemini_flash_model) + + +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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.semantics.map( + "What is the food made from {ingredient_1} and {ingredient_2}? One word only.", + "food", + None, + ) + + +@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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = cities.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + cities.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_df = animals.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError, match=error_pattern): + df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + cities.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_result = df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + actual_result = df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 0, + ): + df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(TypeError): + df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.semantics.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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df1.semantics.sim_join( + df2, + left_on="creatures", + right_on="creatures", + model=text_embedding_generator, + max_rows=1, + ) + + +@pytest.mark.parametrize( + "instruction", + [ + pytest.param( + "No column reference", + id="zero_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{Animals}", + id="non_existing_column", + marks=pytest.mark.xfail(raises=ValueError), + ), + pytest.param( + "{Animals} and {Animals}", + id="two_columns", + marks=pytest.mark.xfail(raises=NotImplementedError), + ), + pytest.param( + "{index}", + id="preserved", + marks=pytest.mark.xfail(raises=ValueError), + ), + ], +) +def test_top_k_invalid_instruction_raise_error(instruction, gemini_flash_model): + df = dataframe.DataFrame( + { + "Animals": ["Dog", "Cat", "Bird", "Horse"], + "ID": [1, 2, 3, 4], + "index": ["a", "b", "c", "d"], + } + ) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ): + df.semantics.top_k(instruction, model=gemini_flash_model, k=2) + + +def test_top_k_invalid_k_raise_error(gemini_flash_model): + df = dataframe.DataFrame({"Animals": ["Dog", "Cat", "Bird", "Horse"]}) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 10, + ), pytest.raises(ValueError): + df.semantics.top_k( + "{Animals} are more popular as pets", + gemini_flash_model, + k=0, + ) + + +@patch("builtins.input", return_value="") +def test_confirm_operation__below_threshold_do_not_confirm(mock_input): + df = dataframe.DataFrame({}) + + with bigframes.option_context( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 3, + ): + df.semantics._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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + None, + ): + df.semantics._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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 1, + "compute.semantic_ops_threshold_autofail", + True, + ), pytest.raises(exceptions.OperationAbortedError): + df.semantics._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( + SEM_OP_EXP_OPTION, + True, + THRESHOLD_OPTION, + 3, + ), expectation as e: + assert df.semantics._confirm_operation(4) == e diff --git a/tests/system/large/streaming/test_bigtable.py b/tests/system/large/streaming/test_bigtable.py index f10c534404e..e57b7e6e0e2 100644 --- a/tests/system/large/streaming/test_bigtable.py +++ b/tests/system/large/streaming/test_bigtable.py @@ -13,9 +13,8 @@ # limitations under the License. import time -import uuid -from datetime import datetime, timedelta from typing import Generator +import uuid import pytest @@ -92,12 +91,11 @@ def test_streaming_df_to_bigtable( 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 + # wait 100 seconds in order to ensure the query doesn't stop # (i.e. it is continuous) - time.sleep(200) + time.sleep(100) assert query_job.running() assert query_job.error_result is None assert str(query_job.job_id).startswith(job_id_prefix) diff --git a/tests/system/large/streaming/test_pubsub.py b/tests/system/large/streaming/test_pubsub.py index cdc27ae65cf..277b44c93b5 100644 --- a/tests/system/large/streaming/test_pubsub.py +++ b/tests/system/large/streaming/test_pubsub.py @@ -12,10 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import uuid from concurrent import futures -from datetime import datetime, timedelta from typing import Generator +import uuid import pytest @@ -100,12 +99,11 @@ def callback(message): 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 + # wait 100 seconds in order to ensure the query doesn't stop # (i.e. it is continuous) - future.result(timeout=200) + future.result(timeout=100) except futures.TimeoutError: future.cancel() assert query_job.running() diff --git a/tests/system/large/test_dataframe.py b/tests/system/large/test_dataframe.py index dc7671d18a6..396f2eb4361 100644 --- a/tests/system/large/test_dataframe.py +++ b/tests/system/large/test_dataframe.py @@ -40,27 +40,3 @@ def test_cov_150_columns(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 index c352d618d6a..87d2acd34b9 100644 --- a/tests/system/large/test_dataframe_io.py +++ b/tests/system/large/test_dataframe_io.py @@ -22,9 +22,8 @@ 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), + 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)) @@ -49,20 +48,18 @@ def test_to_pandas_batches_override_global_option( ): 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 + pages = list( + df.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), + 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 3127d5865a9..3ebe2bb040e 100644 --- a/tests/system/large/test_location.py +++ b/tests/system/large/test_location.py @@ -13,9 +13,7 @@ # limitations under the License. import typing -import unittest.mock as mock -import google.auth.credentials import pandas import pandas.testing import pytest @@ -178,12 +176,8 @@ def test_bq_rep_endpoints(bigquery_location): def test_clients_provider_no_location(): - credentials = mock.create_autospec(google.auth.credentials.Credentials) - with pytest.raises(ValueError, match="Must set location to use regional endpoints"): - bigframes.session.clients.ClientsProvider( - project="", credentials=credentials, use_regional_endpoints=True - ) + bigframes.session.clients.ClientsProvider(use_regional_endpoints=True) @pytest.mark.parametrize( @@ -192,16 +186,12 @@ def test_clients_provider_no_location(): sorted(bigframes.constants.REP_NOT_ENABLED_BIGQUERY_LOCATIONS), ) def test_clients_provider_use_regional_endpoints_non_rep_locations(bigquery_location): - credentials = mock.create_autospec(google.auth.credentials.Credentials) with pytest.raises( ValueError, match=f"not .*available in the location {bigquery_location}", ): bigframes.session.clients.ClientsProvider( - project="", - credentials=credentials, - location=bigquery_location, - use_regional_endpoints=True, + location=bigquery_location, use_regional_endpoints=True ) diff --git a/tests/system/large/test_session.py b/tests/system/large/test_session.py index 937b3c9e274..d28146498d0 100644 --- a/tests/system/large/test_session.py +++ b/tests/system/large/test_session.py @@ -13,12 +13,9 @@ # 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 @@ -26,42 +23,6 @@ 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( - ("write_engine"), - [ - ("bigquery_load"), - ("bigquery_streaming"), - # TODO(b/502298527): Reenable bigquery_write test - # ("bigquery_write"), - ], -) -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): # we will create two tables and confirm that they are deleted # when the session is closed @@ -177,35 +138,3 @@ 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_tpch.py b/tests/system/large/test_tpch.py deleted file mode 100644 index de630ce0dd4..00000000000 --- a/tests/system/large/test_tpch.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 os -import re - -import pandas as pd -import pytest -from google.cloud import bigquery - -TPCH_PATH = "third_party/bigframes_vendored/tpch" -PROJECT_ID = "bigframes-dev-perf" -DATASET_ID = "tpch_0001g" -DATASET = { - "line_item_ds": f"{PROJECT_ID}.{DATASET_ID}.LINEITEM", - "region_ds": f"{PROJECT_ID}.{DATASET_ID}.REGION", - "nation_ds": f"{PROJECT_ID}.{DATASET_ID}.NATION", - "supplier_ds": f"{PROJECT_ID}.{DATASET_ID}.SUPPLIER", - "part_ds": f"{PROJECT_ID}.{DATASET_ID}.PART", - "part_supp_ds": f"{PROJECT_ID}.{DATASET_ID}.PARTSUPP", - "customer_ds": f"{PROJECT_ID}.{DATASET_ID}.CUSTOMER", - "orders_ds": f"{PROJECT_ID}.{DATASET_ID}.ORDERS", -} - - -def _execute_sql_query(bigquery_client, sql_query): - sql_query = sql_query.format(**DATASET) - - job_config = bigquery.QueryJobConfig(use_query_cache=False) - query_job = bigquery_client.query(sql_query, job_config=job_config) - query_job.result() - df = query_job.to_dataframe() - df.columns = df.columns.str.upper() - return df - - -def _execute_bigframes_script(session, bigframes_script): - bigframes_script = re.sub( - r"next\((\w+)\.to_pandas_batches\((.*?)\)\)", - r"return \1.to_pandas()", - bigframes_script, - ) - bigframes_script = re.sub(r"_\s*=\s*(\w+)", r"return \1", bigframes_script) - - bigframes_script = ( - bigframes_script - + f"\nresult = q('{PROJECT_ID}', '{DATASET_ID}', _initialize_session)" - ) - exec_globals = {"_initialize_session": session} - exec(bigframes_script, exec_globals) - bigframes_result = exec_globals.get("result") - return bigframes_result - - -def _verify_result(bigframes_result, sql_result): - if isinstance(bigframes_result, pd.DataFrame): - pd.testing.assert_frame_equal( - sql_result.reset_index(drop=True), - bigframes_result.reset_index(drop=True), - check_dtype=False, - ) - else: - assert sql_result.shape == (1, 1) - sql_scalar = sql_result.iloc[0, 0] - assert sql_scalar == bigframes_result - - -@pytest.mark.parametrize("query_num", range(1, 23)) -@pytest.mark.parametrize("ordered", [True, False]) -def test_tpch_correctness(session, unordered_session, query_num, ordered): - """Runs verification of TPCH benchmark script outputs to ensure correctness.""" - # Execute SQL: - sql_file_path = f"{TPCH_PATH}/sql_queries/q{query_num}.sql" - assert os.path.exists(sql_file_path) - with open(sql_file_path, "r") as f: - sql_query = f.read() - - sql_result = _execute_sql_query(session.bqclient, sql_query) - - # Execute BigFrames: - file_path = f"{TPCH_PATH}/queries/q{query_num}.py" - assert os.path.exists(file_path) - with open(file_path, "r") as file: - bigframes_script = file.read() - - bigframes_result = _execute_bigframes_script( - session if ordered else unordered_session, bigframes_script - ) - - _verify_result(bigframes_result, sql_result) diff --git a/tests/system/load/test_llm.py b/tests/system/load/test_llm.py index eec76cf9b67..fc049567495 100644 --- a/tests/system/load/test_llm.py +++ b/tests/system/load/test_llm.py @@ -41,8 +41,8 @@ def llm_remote_text_df(session, llm_remote_text_pandas_df): @pytest.mark.parametrize( "model_name", ( - "gemini-2.5-flash", - "gemini-2.5-flash-lite", + "gemini-2.0-flash-001", + "gemini-2.0-flash-lite-001", ), ) def test_llm_gemini_configure_fit( @@ -79,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-2.5-flash", 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, @@ -98,10 +98,18 @@ 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"), +) @pytest.mark.flaky(retries=3, delay=120) -def test_claude3_text_generator_create_load(dataset_id, session, bq_connection): +def test_claude3_text_generator_create_load( + dataset_id, model_name, session, session_us_east5, bq_connection +): + if model_name in ("claude-3-5-sonnet", "claude-3-opus"): + session = session_us_east5 claude3_text_generator_model = llm.Claude3TextGenerator( - model_name="claude-3-haiku", connection_name=bq_connection, session=session + model_name=model_name, connection_name=bq_connection, session=session ) assert claude3_text_generator_model is not None assert claude3_text_generator_model._bqml_model is not None @@ -112,15 +120,21 @@ def test_claude3_text_generator_create_load(dataset_id, session, bq_connection): ) 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 == "claude-3-haiku" + assert reloaded_model.model_name == model_name +@pytest.mark.parametrize( + "model_name", + ("claude-3-sonnet", "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( - llm_text_df, session, bq_connection + llm_text_df, model_name, session, session_us_east5, bq_connection ): + if model_name in ("claude-3-5-sonnet", "claude-3-opus"): + session = session_us_east5 claude3_text_generator_model = llm.Claude3TextGenerator( - model_name="claude-3-haiku", connection_name=bq_connection, session=session + model_name=model_name, connection_name=bq_connection, session=session ) df = claude3_text_generator_model.predict(llm_text_df).to_pandas() utils.check_pandas_df_schema_and_index( @@ -128,12 +142,18 @@ 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"), +) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_with_params_success( - llm_text_df, session, bq_connection + llm_text_df, model_name, session, session_us_east5, bq_connection ): + if model_name in ("claude-3-5-sonnet", "claude-3-opus"): + session = session_us_east5 claude3_text_generator_model = llm.Claude3TextGenerator( - model_name="claude-3-haiku", connection_name=bq_connection, session=session + model_name=model_name, connection_name=bq_connection, session=session ) df = claude3_text_generator_model.predict( llm_text_df, max_output_tokens=100, top_k=20, top_p=0.5 @@ -143,13 +163,20 @@ 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"), +) @pytest.mark.flaky(retries=3, delay=120) def test_claude3_text_generator_predict_multi_col_success( - llm_text_df, session, bq_connection + llm_text_df, model_name, session, session_us_east5, bq_connection ): + if model_name in ("claude-3-5-sonnet", "claude-3-opus"): + session = session_us_east5 + llm_text_df["additional_col"] = 1 claude3_text_generator_model = llm.Claude3TextGenerator( - model_name="claude-3-haiku", connection_name=bq_connection, session=session + model_name=model_name, connection_name=bq_connection, session=session ) df = claude3_text_generator_model.predict(llm_text_df).to_pandas() utils.check_pandas_df_schema_and_index( diff --git a/tests/system/small/bigquery/test_ai.py b/tests/system/small/bigquery/test_ai.py deleted file mode 100644 index 0b6738dec80..00000000000 --- a/tests/system/small/bigquery/test_ai.py +++ /dev/null @@ -1,590 +0,0 @@ -# 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 uuid -from unittest import mock - -import google.cloud.bigquery -import pandas as pd -import pyarrow as pa -import pytest - -import bigframes.bigquery as bbq -import bigframes.pandas as bpd -from bigframes import dataframe, dtypes, series -from bigframes.testing import utils as test_utils - - -@pytest.fixture -def use_ibis_compiler(): - original_setting = bpd.options.experiments.sql_compiler - bpd.options.experiments.sql_compiler = "legacy" - try: - yield - finally: - bpd.options.experiments.sql_compiler = original_setting - - -def _create_mock_obj_ref_df(session, uris, name="image", connection=None): - df = bpd.DataFrame({name: uris}, session=session) - # Convert string URIs to ObjectRef structs - if connection is None: - connection = "us.bigframes-rf-conn" - df[name] = bbq.obj.make_ref(df[name], authorizer=connection) - - table_id = f"bigframes-dev.bigframes_tests_sys.tmp_obj_ref_{uuid.uuid4().hex}" - df.to_gbq(table_id, if_exists="replace") - - client = session.bqclient - table = client.get_table(table_id) - schema = list(table.schema) - for i, field in enumerate(schema): - if field.name == name: - schema[i] = google.cloud.bigquery.SchemaField( - name=field.name, - field_type=field.field_type, - mode=field.mode, - description="bigframes_dtype: OBJ_REF_DTYPE", - fields=field.fields, - ) - break - table.schema = schema - client.update_table(table, ["schema"]) - - return session.read_gbq(table_id) - - -def test_ai_function_pandas_tuple_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_pandas_series_input(session): - s = pd.Series(["cat", "lavender"]) - - result = bbq.ai.classify( - s, categories=["animal", "plant"], endpoint="gemini-2.5-flash" - ) - - assert len(result) == len(s) - assert result.dtype == dtypes.STRING_DTYPE - - -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): - 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_access_full_response_with_ibis(session, use_ibis_compiler): - 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") - .struct.field("full_response") - .to_pandas() - ) - - assert _contains_no_nulls(result) - - -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_access_full_response_with_ibis(session, use_ibis_compiler): - 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") - .struct.field("full_response") - .to_pandas() - ) - - assert _contains_no_nulls(result) - - -def test_ai_generate_bool_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - result = bbq.ai.generate_bool((image_runtime, " 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_access_full_response_with_ibis(session, use_ibis_compiler): - 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") - .struct.field("full_response") - .to_pandas() - ) - - assert _contains_no_nulls(result) - - -def test_ai_generate_int_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - result = bbq.ai.generate_int( - ("How many animals are there in the picture ", image_runtime) - ) - - 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_access_full_response_with_ibis(session, use_ibis_compiler): - 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") - .struct.field("full_response") - .to_pandas() - ) - - assert _contains_no_nulls(result) - - -def test_ai_generate_double_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - result = bbq.ai.generate_double( - ("How many animals are there in the picture ", image_runtime) - ) - - 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_embed_series_content(session): - content = bpd.Series(["dog"], session=session) - - result = bbq.ai.embed(content, endpoint="text-embedding-005") - - assert _contains_no_nulls(result) - assert result.dtype == pd.ArrowDtype( - pa.struct( - ( - pa.field("result", pa.list_(pa.float64())), - pa.field("status", pa.string()), - ) - ) - ) - - -def test_ai_embed_string_content(session): - with mock.patch( - "bigframes.core.global_session.get_global_session" - ) as mock_get_session: - mock_get_session.return_value = session - - result = bbq.ai.embed("dog", endpoint="text-embedding-005") - - assert _contains_no_nulls(result) - assert result.dtype == pd.ArrowDtype( - pa.struct( - ( - pa.field("result", pa.list_(pa.float64())), - 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, - optimization_mode="maximize_quality", - max_error_ratio=0.5, - ) - - assert len(result) == len(s1) - assert result.dtype == dtypes.BOOL_DTYPE - - -def test_ai_if_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - result = bbq.ai.if_((image_runtime, " contains an animal")) - - assert len(result) == len(df) - 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 len(result) == len(s) - assert result.dtype == dtypes.STRING_DTYPE - - -def test_ai_classify_with_examples(session): - s = bpd.Series(["cat", "orchid"], session=session) - - result = bbq.ai.classify(s, ["animal", "plant"], examples=[("dog", "animal")]) - - assert len(result) == len(s) - assert result.dtype == dtypes.STRING_DTYPE - - -def test_ai_classify_output_mode(session, bq_connection): - s = bpd.Series(["cat", "orchid"], session=session) - - result = bbq.ai.classify( - s, ["animal", "plant"], output_mode="multi", examples=[("dog", ["animal"])] - ) - - assert len(result) == len(s) - assert result.dtype == dtypes.list_type(dtypes.STRING_DTYPE) - - -def test_ai_classify_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - result = bbq.ai.classify(image_runtime, ["photo", "cartoon"]) - - assert len(result) == len(df) - 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 len(result) == len(s) - assert result.dtype == dtypes.FLOAT_DTYPE - - -def test_ai_score_multi_model(session, bq_connection): - df = _create_mock_obj_ref_df( - session, - ["gs://cloud-samples-data/vision/ocr/sign.jpg"], - name="image", - connection=bq_connection, - ) - image_runtime = bbq.obj.get_access_url(df["image"], mode="R") - prompt = ("Rank the liveliness of ", image_runtime, "on the scale from 1 to 3") - - result = bbq.ai.score(prompt) - - assert len(result) == len(df) - 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 test_ai_similarity(session): - s1 = bpd.Series(["happy", "sad"], session=session) - s2 = pd.Series(["glad", "angry"]) - - result = bbq.ai.similarity(s1, s2, endpoint="text-embedding-005") - - assert _contains_no_nulls(result) - assert result.dtype == dtypes.FLOAT_DTYPE - - -def test_ai_similarity_one_content_is_string_literal(session): - s1 = "happy" - s2 = bpd.Series(["glad", "angry"], session=session) - - result = bbq.ai.similarity(s1, s2, model="embeddinggemma-300m") - - assert _contains_no_nulls(result) - assert result.dtype == dtypes.FLOAT_DTYPE - - -def test_ai_similarity_both_contents_are_string_literals(session): - s1 = "happy" - s2 = "glad" - - result = bbq.ai.similarity(s1, s2, endpoint="text-embedding-005") - - assert _contains_no_nulls(result) - assert result.dtype == dtypes.FLOAT_DTYPE - - -def _contains_no_nulls(s: series.Series | pd.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 c8c69f7457e..2ceb90e22c8 100644 --- a/tests/system/small/bigquery/test_array.py +++ b/tests/system/small/bigquery/test_array.py @@ -12,8 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import cast - import numpy as np import pandas as pd import pytest @@ -68,15 +66,10 @@ ], ) def test_array_length(input_data, expected): - series = pd.Series(input_data) - expected = pd.Series( - expected, - index=pd.Index(range(len(input_data)), dtype="Int64"), - dtype=bigframes.dtypes.INT_DTYPE, - ) - result = cast(bpd.Series, bbq.array_length(series)) + series = bpd.Series(input_data) + expected = pd.Series(expected, dtype=bigframes.dtypes.INT_DTYPE) pd.testing.assert_series_equal( - result.to_pandas(), + bbq.array_length(series).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 58e07928f0c..dc68e7b892d 100644 --- a/tests/system/small/bigquery/test_datetime.py +++ b/tests/system/small/bigquery/test_datetime.py @@ -18,7 +18,6 @@ import pyarrow as pa import pytest -import bigframes.testing.utils from bigframes import bigquery _TIMESTAMP_DTYPE = pd.ArrowDtype(pa.timestamp("us", tz="UTC")) @@ -41,7 +40,7 @@ def test_unix_seconds(scalars_dfs): .apply(lambda ts: _to_unix_epoch(ts, "s")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal(actual_res, expected_res) + pd.testing.assert_series_equal(actual_res, expected_res) def test_unix_seconds_after_type_casting(int_series): @@ -54,9 +53,7 @@ def test_unix_seconds_after_type_casting(int_series): .apply(lambda ts: _to_unix_epoch(ts, "s")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal( - actual_res, expected_res, check_index_type=False - ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) def test_unix_seconds_incorrect_input_type_raise_error(scalars_dfs): @@ -76,7 +73,7 @@ def test_unix_millis(scalars_dfs): .apply(lambda ts: _to_unix_epoch(ts, "ms")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal(actual_res, expected_res) + pd.testing.assert_series_equal(actual_res, expected_res) def test_unix_millis_after_type_casting(int_series): @@ -89,9 +86,7 @@ def test_unix_millis_after_type_casting(int_series): .apply(lambda ts: _to_unix_epoch(ts, "ms")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal( - actual_res, expected_res, check_index_type=False - ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) def test_unix_millis_incorrect_input_type_raise_error(scalars_dfs): @@ -111,7 +106,7 @@ def test_unix_micros(scalars_dfs): .apply(lambda ts: _to_unix_epoch(ts, "us")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal(actual_res, expected_res) + pd.testing.assert_series_equal(actual_res, expected_res) def test_unix_micros_after_type_casting(int_series): @@ -124,9 +119,7 @@ def test_unix_micros_after_type_casting(int_series): .apply(lambda ts: _to_unix_epoch(ts, "us")) .astype("Int64") ) - bigframes.testing.utils.assert_series_equal( - actual_res, expected_res, check_index_type=False - ) + pd.testing.assert_series_equal(actual_res, expected_res, check_index_type=False) def test_unix_micros_incorrect_input_type_raise_error(scalars_dfs): diff --git a/tests/system/small/bigquery/test_geo.py b/tests/system/small/bigquery/test_geo.py index 16df467d24a..c89ca59acaa 100644 --- a/tests/system/small/bigquery/test_geo.py +++ b/tests/system/small/bigquery/test_geo.py @@ -28,11 +28,10 @@ Polygon, ) +from bigframes.bigquery import st_length import bigframes.bigquery as bbq import bigframes.geopandas import bigframes.session -import bigframes.testing.utils -from bigframes.bigquery import st_length def test_geo_st_area(session: bigframes.session.Session): @@ -57,7 +56,7 @@ def test_geo_st_area(session: bigframes.session.Session): geobf_s_result = bbq.st_area(geobf_s).to_pandas().round(-3) assert geobf_s_result.iloc[0] >= 1000 - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( geobf_s_result, geopd_s_result, check_dtype=False, @@ -110,7 +109,7 @@ def test_st_length_various_geometries(session): # Test default use_spheroid result_default = st_length(geoseries).to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( result_default, expected_lengths, rtol=1e-3, @@ -119,7 +118,7 @@ def test_st_length_various_geometries(session): # Test explicit use_spheroid=False result_explicit_false = st_length(geoseries, use_spheroid=False).to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( result_explicit_false, expected_lengths, rtol=1e-3, @@ -153,7 +152,7 @@ def test_geo_st_difference_with_geometry_objects(session: bigframes.session.Sess index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -192,7 +191,7 @@ def test_geo_st_difference_with_single_geometry_object( index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -218,7 +217,7 @@ def test_geo_st_difference_with_similar_geometry_objects( index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -274,7 +273,7 @@ def test_geo_st_distance_with_geometry_objects(session: bigframes.session.Sessio index=[0, 1, 2, 3], dtype="Float64", ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -321,7 +320,7 @@ def test_geo_st_distance_with_single_geometry_object( ], dtype="Float64", ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -356,7 +355,7 @@ def test_geo_st_intersection_with_geometry_objects(session: bigframes.session.Se index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -395,7 +394,7 @@ def test_geo_st_intersection_with_single_geometry_object( index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -425,7 +424,7 @@ def test_geo_st_intersection_with_similar_geometry_objects( index=[0, 1, 2], dtype=geopandas.array.GeometryDtype(), ) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( geobf_s_result, expected, check_index_type=False, @@ -444,7 +443,9 @@ def test_geo_st_isclosed(session: bigframes.session.Session): GeometryCollection(), # Empty GeometryCollection bigframes.geopandas.GeoSeries.from_wkt( ["GEOMETRYCOLLECTION EMPTY"], session=session - ).iloc[0], # Also empty + ).iloc[ + 0 + ], # Also empty None, # Should be filtered out by dropna ], index=[0, 1, 2, 3, 4, 5, 6], @@ -464,7 +465,7 @@ def test_geo_st_isclosed(session: bigframes.session.Session): ] expected_series = pd.Series(data=expected_data, dtype="boolean") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, expected_series, # We default to Int64 (nullable) dtype, but pandas defaults to int64 index. @@ -479,12 +480,3 @@ def test_st_buffer(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 2d97172e7b5..4ecbd013184 100644 --- a/tests/system/small/bigquery/test_json.py +++ b/tests/system/small/bigquery/test_json.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. - import geopandas as gpd # type: ignore import pandas as pd import pyarrow as pa @@ -385,103 +384,3 @@ 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", None, "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( - ['{"version":1,"project":"pandas"}', '{"version":2,"project":"numpy"}'], - 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( - ['{"version":1,"project":"pandas"}', '{"version":2,"project":"numpy"}'], - 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.json_keys(s) diff --git a/tests/system/small/bigquery/test_mathematical.py b/tests/system/small/bigquery/test_mathematical.py deleted file mode 100644 index 66aef96e57d..00000000000 --- a/tests/system/small/bigquery/test_mathematical.py +++ /dev/null @@ -1,37 +0,0 @@ -# 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.bigquery as bbq - - -def test_rand(scalars_df_index): - df = scalars_df_index - - # Apply rand - df = df.assign(random=bbq.rand()) - result = df["random"] - - # Eagerly evaluate - result_pd = result.to_pandas() - - # Check length - assert len(result_pd) == len(df) - - # Check values in [0, 1) - assert (result_pd >= 0).all() - assert (result_pd < 1).all() - - # Check not all values are equal (unlikely collision for random) - if len(result_pd) > 1: - assert result_pd.nunique() > 1 diff --git a/tests/system/small/bigquery/test_sql.py b/tests/system/small/bigquery/test_sql.py index c0f7eed938e..c519b427faf 100644 --- a/tests/system/small/bigquery/test_sql.py +++ b/tests/system/small/bigquery/test_sql.py @@ -12,12 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest import bigframes.bigquery as bbq import bigframes.dtypes as dtypes import bigframes.pandas as bpd -import bigframes.testing.utils def test_sql_scalar_for_all_scalar_types(scalars_df_null_index): @@ -59,10 +59,8 @@ 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 = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) @pytest.mark.parametrize( @@ -85,10 +83,8 @@ 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 = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) def test_sql_scalar_for_array_series(repeated_df): @@ -118,18 +114,14 @@ def test_sql_scalar_for_array_series(repeated_df): + repeated_df["numeric_list_col"].list.len() + repeated_df["string_list_col"].list.len() ) - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + 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 = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) def test_sql_scalar_for_struct_series(nested_structs_df): @@ -140,18 +132,14 @@ def test_sql_scalar_for_struct_series(nested_structs_df): expected = nested_structs_df["person"].struct.field( "name" ).str.len() + nested_structs_df["person"].struct.field("age") - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + 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 = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) def test_sql_scalar_for_json_series(json_df): @@ -162,16 +150,12 @@ def test_sql_scalar_for_json_series(json_df): ], ) expected = bbq.json_value(json_df["json_col"], "$.int_value") - expected.name = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + 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 = result.name - bigframes.testing.utils.assert_series_equal( - result.to_pandas(), expected.to_pandas() - ) + expected.name = None + pd.testing.assert_series_equal(result.to_pandas(), expected.to_pandas()) diff --git a/tests/system/small/bigquery/test_struct.py b/tests/system/small/bigquery/test_struct.py index 85404969605..58c822f642f 100644 --- a/tests/system/small/bigquery/test_struct.py +++ b/tests/system/small/bigquery/test_struct.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest import bigframes.bigquery as bbq import bigframes.series as series -import bigframes.testing.utils @pytest.mark.parametrize( @@ -53,10 +53,9 @@ def test_struct_from_dataframe(columns_arg): srs = series.Series( columns_arg, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( srs.to_pandas(), bbq.struct(srs.struct.explode()).to_pandas(), check_index_type=False, check_dtype=False, - check_names=False, # None vs nan version dependent ) diff --git a/tests/system/small/bigquery/test_vector_search.py b/tests/system/small/bigquery/test_vector_search.py index b8ad4c0df22..a282135fa6f 100644 --- a/tests/system/small/bigquery/test_vector_search.py +++ b/tests/system/small/bigquery/test_vector_search.py @@ -13,7 +13,7 @@ # limitations under the License. import random -from typing import Any, Dict, Iterable, cast +from typing import Any, cast, Dict, Iterable import google.cloud.bigquery import numpy as np @@ -23,7 +23,7 @@ import bigframes.bigquery as bbq import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal +from bigframes.testing.utils import assert_pandas_df_equal # Need at least 5,000 rows to create a vector index. VECTOR_DF = pd.DataFrame( @@ -123,17 +123,12 @@ 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, - ) - .sort_values("distance") - .sort_index() - .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, + ).to_pandas() # type:ignore expected = pd.DataFrame( { "query_id": ["cat", "dog", "dog", "cat"], @@ -154,7 +149,7 @@ def test_vector_search_basic_params_with_df(): }, index=pd.Index([1, 0, 0, 1], dtype="Int64"), ) - assert_frame_equal( + assert_pandas_df_equal( expected.sort_values("id"), vector_search_result.sort_values("id"), check_dtype=False, @@ -162,60 +157,80 @@ def test_vector_search_basic_params_with_df(): ) -def test_vector_search_different_params_with_query(session): - base_df = bpd.DataFrame( +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( { - "id": [1, 2, 3, 4], + "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], "my_embedding": [ - np.array([0.0, 1.0]), - np.array([1.0, 0.0]), - np.array([0.0, -1.0]), - np.array([-1.0, 0.0]), + np.array([2.0, 4.0]), + np.array([1.0, 2.0]), + np.array([1.0, 2.0]), + np.array([2.0, 4.0]), ], + "distance": [0.0, 0.0, 0.001777, 0.001777], }, - session=session, + 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 ) - 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 new file mode 100644 index 00000000000..d3b4c4faa0e --- /dev/null +++ b/tests/system/small/blob/test_io.py @@ -0,0 +1,94 @@ +# 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 +import bigframes.pandas as bpd + + +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": images_uris, + "version": [None, None], + "authorizer": [bq_connection.casefold(), bq_connection.casefold()], + "details": [None, None], + } + ) + + pd.testing.assert_frame_equal( + pd_blob_df, expected_pd_df, check_dtype=False, check_index_type=False + ) + + +def test_blob_create_from_glob_path( + bq_connection: str, session: bigframes.Session, images_gcs_path, images_uris +): + blob_df = session.from_glob_path( + 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) + ) + 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 + ) diff --git a/tests/system/small/blob/test_properties.py b/tests/system/small/blob/test_properties.py index c3597b37116..47d4d2aa04f 100644 --- a/tests/system/small/blob/test_properties.py +++ b/tests/system/small/blob/test_properties.py @@ -13,17 +13,13 @@ # limitations under the License. import pandas as pd -import pytest -import bigframes.bigquery as bbq import bigframes.dtypes as dtypes import bigframes.pandas as bpd -pytest.skip("Skipping blob tests due to b/481790217", allow_module_level=True) - def test_blob_uri(images_uris: list[str], images_mm_df: bpd.DataFrame): - actual = images_mm_df["blob_col"].struct.field("uri").to_pandas() + actual = images_mm_df["blob_col"].blob.uri().to_pandas() expected = pd.Series(images_uris, name="uri") pd.testing.assert_series_equal( @@ -32,7 +28,7 @@ def test_blob_uri(images_uris: list[str], images_mm_df: bpd.DataFrame): def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): - actual = images_mm_df["blob_col"].struct.field("authorizer").to_pandas() + actual = images_mm_df["blob_col"].blob.authorizer().to_pandas() expected = pd.Series( [bq_connection.casefold(), bq_connection.casefold()], name="authorizer" ) @@ -43,9 +39,7 @@ def test_blob_authorizer(images_mm_df: bpd.DataFrame, bq_connection: str): def test_blob_version(images_mm_df: bpd.DataFrame): - actual = bbq.json_value( - images_mm_df["blob_col"].struct.field("details"), "$.version" - ).to_pandas() + actual = images_mm_df["blob_col"].blob.version().to_pandas() expected = pd.Series(["1753907851152593", "1753907851111538"], name="version") pd.testing.assert_series_equal( @@ -54,7 +48,7 @@ def test_blob_version(images_mm_df: bpd.DataFrame): def test_blob_metadata(images_mm_df: bpd.DataFrame): - actual = images_mm_df["blob_col"].struct.field("details").to_pandas() + actual = images_mm_df["blob_col"].blob.metadata().to_pandas() expected = pd.Series( [ ( @@ -78,9 +72,7 @@ def test_blob_metadata(images_mm_df: bpd.DataFrame): def test_blob_content_type(images_mm_df: bpd.DataFrame): - actual = bbq.json_value( - images_mm_df["blob_col"].struct.field("details"), "$.content_type" - ).to_pandas() + 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( @@ -89,9 +81,7 @@ def test_blob_content_type(images_mm_df: bpd.DataFrame): def test_blob_md5_hash(images_mm_df: bpd.DataFrame): - actual = bbq.json_value( - images_mm_df["blob_col"].struct.field("details"), "$.md5_hash" - ).to_pandas() + actual = images_mm_df["blob_col"].blob.md5_hash().to_pandas() expected = pd.Series( ["e130ad042261a1883cd2cc06831cf748", "e2ae3191ff2b809fd0935f01a537c650"], name="md5_hash", @@ -103,11 +93,7 @@ def test_blob_md5_hash(images_mm_df: bpd.DataFrame): def test_blob_size(images_mm_df: bpd.DataFrame): - actual = ( - bbq.json_value(images_mm_df["blob_col"].struct.field("details"), "$.size") - .astype("Int64") - .to_pandas() - ) + actual = images_mm_df["blob_col"].blob.size().to_pandas() expected = pd.Series([338390, 43333], name="size") pd.testing.assert_series_equal( @@ -116,9 +102,7 @@ def test_blob_size(images_mm_df: bpd.DataFrame): def test_blob_updated(images_mm_df: bpd.DataFrame): - actual = bbq.json_value( - images_mm_df["blob_col"].struct.field("details"), "$.updated" - ).to_pandas() + actual = images_mm_df["blob_col"].blob.updated().to_pandas() expected = pd.Series( [ pd.Timestamp("2025-07-30 20:37:31", tz="UTC"), diff --git a/tests/system/small/blob/test_urls.py b/tests/system/small/blob/test_urls.py new file mode 100644 index 00000000000..02a76587f5f --- /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/test_base.py b/tests/system/small/core/indexes/test_base.py index 3225f643299..05ea40cfb9c 100644 --- a/tests/system/small/core/indexes/test_base.py +++ b/tests/system/small/core/indexes/test_base.py @@ -12,10 +12,10 @@ # 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 -from packaging import version @pytest.mark.parametrize("level", [None, 0, 1, "level0", "level1"]) diff --git a/tests/system/small/core/logging/__init__.py b/tests/system/small/core/logging/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/system/small/core/logging/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/logging/test_data_types.py b/tests/system/small/core/logging/test_data_types.py deleted file mode 100644 index d69e17cfff8..00000000000 --- a/tests/system/small/core/logging/test_data_types.py +++ /dev/null @@ -1,113 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Sequence - -import pandas as pd -import pyarrow as pa - -import bigframes.pandas as bpd -from bigframes import dtypes -from bigframes.core.logging import data_types - - -def encode_types(inputs: Sequence[dtypes.Dtype]) -> str: - encoded_val = 0 - for t in inputs: - encoded_val = encoded_val | data_types._get_dtype_mask(t) - - return f"{encoded_val:x}" - - -def test_get_type_refs_no_op(scalars_df_index): - node = scalars_df_index._block._expr.node - expected_types: list[dtypes.Dtype] = [] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_projection(scalars_df_index): - node = ( - scalars_df_index["datetime_col"] - scalars_df_index["datetime_col"] - )._block._expr.node - expected_types = [dtypes.DATETIME_DTYPE, dtypes.TIMEDELTA_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_filter(scalars_df_index): - node = scalars_df_index[scalars_df_index["int64_col"] > 0]._block._expr.node - expected_types = [dtypes.INT_DTYPE, dtypes.BOOL_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_order_by(scalars_df_index): - node = scalars_df_index.sort_index()._block._expr.node - expected_types = [dtypes.INT_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_join(scalars_df_index): - node = ( - scalars_df_index[["int64_col"]].merge( - scalars_df_index[["float64_col"]], - left_on="int64_col", - right_on="float64_col", - ) - )._block._expr.node - expected_types = [dtypes.INT_DTYPE, dtypes.FLOAT_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_isin(scalars_df_index): - node = scalars_df_index["string_col"].isin(["a"])._block._expr.node - expected_types = [dtypes.STRING_DTYPE, dtypes.BOOL_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_agg(scalars_df_index): - node = scalars_df_index[["bool_col", "string_col"]].count()._block._expr.node - expected_types = [ - dtypes.INT_DTYPE, - dtypes.BOOL_DTYPE, - dtypes.STRING_DTYPE, - dtypes.FLOAT_DTYPE, - ] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_window(scalars_df_index): - node = ( - scalars_df_index[["string_col", "bool_col"]] - .groupby("string_col") - .rolling(window=3) - .count() - ._block._expr.node - ) - expected_types = [dtypes.STRING_DTYPE, dtypes.BOOL_DTYPE, dtypes.INT_DTYPE] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) - - -def test_get_type_refs_explode(): - df = bpd.DataFrame({"A": ["a", "b"], "B": [[1, 2], [3, 4, 5]]}) - node = df.explode("B")._block._expr.node - expected_types = [pd.ArrowDtype(pa.list_(pa.int64()))] - - assert data_types.encode_type_refs(node) == encode_types(expected_types) diff --git a/tests/system/small/core/test_convert.py b/tests/system/small/core/test_convert.py index f63f945ad24..7ce0dd47ba2 100644 --- a/tests/system/small/core/test_convert.py +++ b/tests/system/small/core/test_convert.py @@ -13,9 +13,9 @@ # limitations under the License. +from pandas import testing import pandas as pd import pytest -from pandas import testing from bigframes import dataframe from bigframes.core import convert diff --git a/tests/system/small/core/test_reshape.py b/tests/system/small/core/test_reshape.py deleted file mode 100644 index aba9bf01859..00000000000 --- a/tests/system/small/core/test_reshape.py +++ /dev/null @@ -1,120 +0,0 @@ -# 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 - -import bigframes.testing.utils -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, - ) - - bigframes.testing.utils.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/conftest.py b/tests/system/small/engines/conftest.py index 823ba9806d5..9699cc6a610 100644 --- a/tests/system/small/engines/conftest.py +++ b/tests/system/small/engines/conftest.py @@ -14,13 +14,12 @@ import pathlib from typing import Generator -import google.cloud.bigquery_storage_v1 +from google.cloud import bigquery import pandas as pd import pytest -from google.cloud import bigquery import bigframes -from bigframes.core import ArrayValue, events, local_data +from bigframes.core import ArrayValue, local_data from bigframes.session import ( direct_gbq_execution, local_scan_executor, @@ -45,53 +44,18 @@ def fake_session() -> Generator[bigframes.Session, None, None]: yield session -@pytest.fixture(scope="session") -def pyarrow_engine(): - return local_scan_executor.LocalScanExecutor() - - -@pytest.fixture(scope="session") -def polars_engine(): - return polars_executor.PolarsExecutor() - - -@pytest.fixture(scope="session") -def bq_engine( - bigquery_client: bigquery.Client, - bigquery_storage_read_client: google.cloud.bigquery_storage_v1.BigQueryReadClient, -): - return direct_gbq_execution.DirectGbqExecutor( - bigquery_client, - bqstoragereadclient=bigquery_storage_read_client, - publisher=events.Publisher(), - compiler="ibis", - ) - - -@pytest.fixture(scope="session") -def sqlglot_engine( - bigquery_client: bigquery.Client, - bigquery_storage_read_client: google.cloud.bigquery_storage_v1.BigQueryReadClient, -) -> semi_executor.SemiExecutor: - return direct_gbq_execution.DirectGbqExecutor( - bigquery_client, - bqstoragereadclient=bigquery_storage_read_client, - publisher=events.Publisher(), - ) - - @pytest.fixture(scope="session", params=["pyarrow", "polars", "bq", "bq-sqlglot"]) -def engine( - request, pyarrow_engine, polars_engine, bq_engine, sqlglot_engine -) -> semi_executor.SemiExecutor: +def engine(request, bigquery_client: bigquery.Client) -> semi_executor.SemiExecutor: if request.param == "pyarrow": - return pyarrow_engine + return local_scan_executor.LocalScanExecutor() if request.param == "polars": - return polars_engine + return polars_executor.PolarsExecutor() if request.param == "bq": - return bq_engine + return direct_gbq_execution.DirectGbqExecutor(bigquery_client) if request.param == "bq-sqlglot": - return sqlglot_engine + return direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot" + ) raise ValueError(f"Unrecognized param: {request.param}") diff --git a/tests/system/small/engines/test_aggregation.py b/tests/system/small/engines/test_aggregation.py index 669eae9ebf7..c2fc9ad7069 100644 --- a/tests/system/small/engines/test_aggregation.py +++ b/tests/system/small/engines/test_aggregation.py @@ -13,16 +13,9 @@ # limitations under the License. import pytest -from google.cloud import bigquery +from bigframes.core import array_value, expression, identifiers, nodes import bigframes.operations.aggregations as agg_ops -from bigframes.core import ( - agg_expressions, - array_value, - expression, - identifiers, - nodes, -) from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution @@ -44,7 +37,7 @@ def apply_agg_to_all_valid( continue try: _ = op.output_type(array.get_column_type(arg)) - expr = agg_expressions.UnaryAggregation(op, expression.deref(arg)) + expr = expression.UnaryAggregation(op, expression.deref(arg)) name = f"{arg}-{op.name}" exprs_by_name.append((expr, name)) except TypeError: @@ -54,25 +47,6 @@ def apply_agg_to_all_valid( 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, @@ -82,11 +56,11 @@ def test_engines_aggregate_size( scalars_array_value.node, aggregations=( ( - agg_expressions.NullaryAggregation(agg_ops.SizeOp()), + expression.NullaryAggregation(agg_ops.SizeOp()), identifiers.ColumnId("size_op"), ), ( - agg_expressions.UnaryAggregation( + expression.UnaryAggregation( agg_ops.SizeUnaryOp(), expression.deref("string_col") ), identifiers.ColumnId("unary_size_op"), @@ -96,7 +70,7 @@ def test_engines_aggregate_size( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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], @@ -110,33 +84,6 @@ def test_engines_unary_aggregates( 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, - bq_engine, - sqlglot_engine, -): - node = apply_agg_to_all_valid( - scalars_array_value, - agg_ops.MedianOp(), - ).node - assert_equivalence_execution(node, bq_engine, sqlglot_engine) - - @pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) @pytest.mark.parametrize( "grouping_cols", @@ -156,11 +103,11 @@ def test_engines_grouped_aggregate( scalars_array_value.node, aggregations=( ( - agg_expressions.NullaryAggregation(agg_ops.SizeOp()), + expression.NullaryAggregation(agg_ops.SizeOp()), identifiers.ColumnId("size_op"), ), ( - agg_expressions.UnaryAggregation( + expression.UnaryAggregation( agg_ops.SizeUnaryOp(), expression.deref("string_col") ), identifiers.ColumnId("unary_size_op"), diff --git a/tests/system/small/engines/test_array_ops.py b/tests/system/small/engines/test_array_ops.py index 159f23f48d6..c53b9e9dc1d 100644 --- a/tests/system/small/engines/test_array_ops.py +++ b/tests/system/small/engines/test_array_ops.py @@ -14,9 +14,9 @@ import pytest +from bigframes.core import array_value, expression import bigframes.operations as ops import bigframes.operations.aggregations as agg_ops -from bigframes.core import array_value, expression from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution @@ -26,7 +26,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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)) @@ -46,7 +46,7 @@ def test_engines_to_array_op(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_array_reduce_op(arrays_array_value: array_value.ArrayValue, engine): arr, _ = arrays_array_value.compute_values( [ diff --git a/tests/system/small/engines/test_bool_ops.py b/tests/system/small/engines/test_bool_ops.py index a6ef702885b..065a43c2093 100644 --- a/tests/system/small/engines/test_bool_ops.py +++ b/tests/system/small/engines/test_bool_ops.py @@ -16,8 +16,8 @@ import pytest -import bigframes.operations as ops 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 @@ -46,7 +46,7 @@ def apply_op_pairwise( return new_arr -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) @pytest.mark.parametrize( "op", [ diff --git a/tests/system/small/engines/test_comparison_ops.py b/tests/system/small/engines/test_comparison_ops.py index cd6ece55863..0fcc48b10a9 100644 --- a/tests/system/small/engines/test_comparison_ops.py +++ b/tests/system/small/engines/test_comparison_ops.py @@ -12,15 +12,14 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio import itertools import pytest -import bigframes.operations as ops from bigframes.core import array_value +import bigframes.operations as ops from bigframes.session import polars_executor -from bigframes.testing.engine_utils import SPEC, assert_equivalence_execution +from bigframes.testing.engine_utils import assert_equivalence_execution pytest.importorskip("polars") @@ -69,16 +68,3 @@ def test_engines_project_comparison_op( # 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) - - -@pytest.mark.parametrize("engine", ["bq-sqlglot"], indirect=True) -def test_engines_precedence_like_and_in( - scalars_array_value: array_value.ArrayValue, engine -): - exprs = [ - ops.eq_op.as_expr("bool_col", ops.StrContainsOp("a").as_expr("string_col")), - ] - arr, _ = scalars_array_value.compute_values(exprs) - res = asyncio.run(engine.execute(arr.node, SPEC)) - assert res is not None - assert len(res.batches().to_pandas()) > 0 diff --git a/tests/system/small/engines/test_concat.py b/tests/system/small/engines/test_concat.py index 5786cfc4193..e10570fab21 100644 --- a/tests/system/small/engines/test_concat.py +++ b/tests/system/small/engines/test_concat.py @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_concat_self( scalars_array_value: array_value.ArrayValue, engine, @@ -34,7 +34,7 @@ def test_engines_concat_self( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_concat_filtered_sorted( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/system/small/engines/test_filtering.py b/tests/system/small/engines/test_filtering.py index fcb85aa8859..9b7cd034b4c 100644 --- a/tests/system/small/engines/test_filtering.py +++ b/tests/system/small/engines/test_filtering.py @@ -13,8 +13,8 @@ # limitations under the License. import pytest -import bigframes.operations as ops 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 @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_filter_bool_col( scalars_array_value: array_value.ArrayValue, engine, @@ -35,7 +35,7 @@ def test_engines_filter_bool_col( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_filter_expr_cond( scalars_array_value: array_value.ArrayValue, engine, @@ -47,7 +47,7 @@ def test_engines_filter_expr_cond( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_filter_true( scalars_array_value: array_value.ArrayValue, engine, @@ -57,7 +57,7 @@ def test_engines_filter_true( assert_equivalence_execution(node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_filter_false( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/system/small/engines/test_generic_ops.py b/tests/system/small/engines/test_generic_ops.py index 96beb51f99d..8deef3638e6 100644 --- a/tests/system/small/engines/test_generic_ops.py +++ b/tests/system/small/engines/test_generic_ops.py @@ -16,13 +16,13 @@ import pytest +from bigframes.core import array_value, expression import bigframes.dtypes import bigframes.operations as ops -from bigframes.core import array_value, expression from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution -polars = pytest.importorskip("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() @@ -52,14 +52,8 @@ def apply_op( return new_arr -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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), @@ -69,7 +63,7 @@ def test_engines_astype_int(scalars_array_value: array_value.ArrayValue, engine) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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( @@ -84,7 +78,7 @@ def test_engines_astype_string_int(scalars_array_value: array_value.ArrayValue, assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_float(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -95,7 +89,7 @@ def test_engines_astype_float(scalars_array_value: array_value.ArrayValue, engin assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_string_float( scalars_array_value: array_value.ArrayValue, engine ): @@ -112,7 +106,7 @@ def test_engines_astype_string_float( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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) @@ -121,7 +115,7 @@ def test_engines_astype_bool(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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( @@ -133,7 +127,7 @@ def test_engines_astype_string(scalars_array_value: array_value.ArrayValue, engi assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_numeric(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -144,7 +138,7 @@ def test_engines_astype_numeric(scalars_array_value: array_value.ArrayValue, eng assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_string_numeric( scalars_array_value: array_value.ArrayValue, engine ): @@ -161,7 +155,7 @@ def test_engines_astype_string_numeric( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_date(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -172,7 +166,7 @@ def test_engines_astype_date(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_string_date( scalars_array_value: array_value.ArrayValue, engine ): @@ -189,7 +183,7 @@ def test_engines_astype_string_date( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_datetime(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -200,7 +194,7 @@ def test_engines_astype_datetime(scalars_array_value: array_value.ArrayValue, en assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_string_datetime( scalars_array_value: array_value.ArrayValue, engine ): @@ -217,7 +211,7 @@ def test_engines_astype_string_datetime( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_timestamp(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -228,7 +222,7 @@ def test_engines_astype_timestamp(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_string_timestamp( scalars_array_value: array_value.ArrayValue, engine ): @@ -249,7 +243,7 @@ def test_engines_astype_string_timestamp( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_time(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -260,19 +254,19 @@ def test_engines_astype_time(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, engine): exprs = [ - ops.JSONDecode(to_type=bigframes.dtypes.INT_DTYPE).as_expr( + ops.AsTypeOp(to_type=bigframes.dtypes.INT_DTYPE).as_expr( expression.const("5", bigframes.dtypes.JSON_DTYPE) ), - ops.JSONDecode(to_type=bigframes.dtypes.FLOAT_DTYPE).as_expr( + ops.AsTypeOp(to_type=bigframes.dtypes.FLOAT_DTYPE).as_expr( expression.const("5", bigframes.dtypes.JSON_DTYPE) ), - ops.JSONDecode(to_type=bigframes.dtypes.BOOL_DTYPE).as_expr( + ops.AsTypeOp(to_type=bigframes.dtypes.BOOL_DTYPE).as_expr( expression.const("true", bigframes.dtypes.JSON_DTYPE) ), - ops.JSONDecode(to_type=bigframes.dtypes.STRING_DTYPE).as_expr( + ops.AsTypeOp(to_type=bigframes.dtypes.STRING_DTYPE).as_expr( expression.const('"hello world"', bigframes.dtypes.JSON_DTYPE) ), ] @@ -281,45 +275,7 @@ def test_engines_astype_from_json(scalars_array_value: array_value.ArrayValue, e 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.ToJSON().as_expr(expression.deref("int64_col")), - ops.ToJSON().as_expr( - # Use a const since float to json has precision issues - expression.const(5.2, bigframes.dtypes.FLOAT_DTYPE) - ), - ops.ToJSON().as_expr(expression.deref("bool_col")), - ops.ToJSON().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_to_json_string(scalars_array_value: array_value.ArrayValue, engine): - exprs = [ - ops.ToJSONString().as_expr(expression.deref("int64_col")), - ops.ToJSONString().as_expr( - # Use a const since float to json has precision issues - expression.const(5.2, bigframes.dtypes.FLOAT_DTYPE) - ), - ops.ToJSONString().as_expr(expression.deref("bool_col")), - ops.ToJSONString().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) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, engine): arr = apply_op( scalars_array_value, @@ -329,7 +285,7 @@ def test_engines_astype_timedelta(scalars_array_value: array_value.ArrayValue, e assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_where_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -344,7 +300,7 @@ def test_engines_where_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_coalesce_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -358,7 +314,7 @@ def test_engines_coalesce_op(scalars_array_value: array_value.ArrayValue, engine assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_fillna_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -372,7 +328,7 @@ def test_engines_fillna_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_casewhen_op_single_case( scalars_array_value: array_value.ArrayValue, engine ): @@ -388,7 +344,7 @@ def test_engines_casewhen_op_single_case( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_casewhen_op_double_case( scalars_array_value: array_value.ArrayValue, engine ): @@ -406,7 +362,7 @@ def test_engines_casewhen_op_double_case( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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"))] @@ -415,7 +371,7 @@ def test_engines_isnull_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], 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"))] @@ -424,40 +380,7 @@ def test_engines_notnull_op(scalars_array_value: array_value.ArrayValue, engine) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) -def test_engines_coerce_to_bool_op_scalars( - scalars_array_value: array_value.ArrayValue, engine -): - arr, _ = scalars_array_value.compute_values( - [ - ops.coerce_to_bool_op.as_expr(expression.deref("bool_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("int64_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("float64_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("string_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("bytes_col")), - ] - ) - - assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) - - -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) -def test_engines_coerce_to_bool_op_arrays( - arrays_array_value: array_value.ArrayValue, engine -): - arr, _ = arrays_array_value.compute_values( - [ - ops.coerce_to_bool_op.as_expr(expression.deref("int_list_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("bool_list_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("float_list_col")), - ops.coerce_to_bool_op.as_expr(expression.deref("string_list_col")), - ] - ) - - assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) - - -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -469,7 +392,7 @@ def test_engines_invert_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): arr, col_ids = scalars_array_value.compute_values( [ @@ -502,7 +425,7 @@ def test_engines_isin_op(scalars_array_value: array_value.ArrayValue, engine): assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_isin_op_nested_filter( scalars_array_value: array_value.ArrayValue, engine ): @@ -515,21 +438,3 @@ def test_engines_isin_op_nested_filter( arr = scalars_array_value.filter(filter_clause) assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) - - -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) -def test_engines_getitem_ops(arrays_array_value: array_value.ArrayValue, engine): - arr, _ = arrays_array_value.compute_values( - [ - ops.GetItemOp(0).as_expr(expression.deref("float_list_col")), - ops.DynamicGetItemOp().as_expr( - expression.deref("float_list_col"), expression.const(0) - ), - ops.GetItemOp(0).as_expr(expression.deref("string_list_col")), - ops.DynamicGetItemOp().as_expr( - expression.deref("string_list_col"), expression.const(0) - ), - ] - ) - - assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) diff --git a/tests/system/small/engines/test_googlesql_ops.py b/tests/system/small/engines/test_googlesql_ops.py deleted file mode 100644 index e47308fa355..00000000000 --- a/tests/system/small/engines/test_googlesql_ops.py +++ /dev/null @@ -1,36 +0,0 @@ -# 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.operations.googlesql as gsql_ops -from bigframes.core import array_value -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 test_engines_googlesql_st_area( - scalars_array_value: array_value.ArrayValue, bq_engine, sqlglot_engine -): - expr = gsql_ops.ST_AREA.as_expr("geography_col") - - arr, _ = scalars_array_value.compute_values([expr]) - - assert_equivalence_execution(arr.node, bq_engine, sqlglot_engine) diff --git a/tests/system/small/engines/test_join.py b/tests/system/small/engines/test_join.py index 15dbfabdac3..91c199a4374 100644 --- a/tests/system/small/engines/test_join.py +++ b/tests/system/small/engines/test_join.py @@ -55,7 +55,7 @@ def test_engines_join_on_coerced_key( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) @pytest.mark.parametrize("join_type", ["left", "inner", "right", "outer"]) def test_engines_join_multi_key( scalars_array_value: array_value.ArrayValue, @@ -90,7 +90,7 @@ def test_engines_cross_join( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) @pytest.mark.parametrize( ("left_key", "right_key"), [ @@ -102,10 +102,8 @@ def test_engines_cross_join( 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, + scalars_array_value, lcol=left_key, rcol=right_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 index c188e37370c..7928922e41f 100644 --- a/tests/system/small/engines/test_numeric_ops.py +++ b/tests/system/small/engines/test_numeric_ops.py @@ -17,8 +17,8 @@ import pytest -import bigframes.operations as ops 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 @@ -53,47 +53,6 @@ def apply_op_pairwise( return new_arr -def apply_op( - array: array_value.ArrayValue, op: ops.UnaryOp, 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) - labels.append(f"{arg}_{op.name}") - 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"], indirect=True) -def test_engines_project_ceil( - scalars_array_value: array_value.ArrayValue, - engine, -): - arr = apply_op(scalars_array_value, ops.ceil_op) - assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) - - -@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) -def test_engines_project_floor( - scalars_array_value: array_value.ArrayValue, - engine, -): - arr = apply_op(scalars_array_value, ops.floor_op) - assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) - - @pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) def test_engines_project_add( scalars_array_value: array_value.ArrayValue, @@ -202,7 +161,7 @@ def test_engines_project_floordiv_durations( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_project_mod( scalars_array_value: array_value.ArrayValue, engine, diff --git a/tests/system/small/engines/test_read_local.py b/tests/system/small/engines/test_read_local.py index 257bddd9179..bf1a10beec5 100644 --- a/tests/system/small/engines/test_read_local.py +++ b/tests/system/small/engines/test_read_local.py @@ -31,7 +31,7 @@ def test_engines_read_local( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in managed_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -46,7 +46,7 @@ def test_engines_read_local_w_offsets( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in managed_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -64,7 +64,7 @@ def test_engines_read_local_w_col_subset( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in managed_data_source.schema.items[::-2] ) local_node = nodes.ReadLocalNode( @@ -79,7 +79,7 @@ def test_engines_read_local_w_zero_row_source( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in zero_row_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -88,16 +88,15 @@ def test_engines_read_local_w_zero_row_source( assert_equivalence_execution(local_node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize( - "engine", ["polars", "bq", "pyarrow", "bq-sqlglot"], indirect=True -) +# TODO: Fix sqlglot impl +@pytest.mark.parametrize("engine", ["polars", "bq", "pyarrow"], 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) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in nested_data_source.schema.items ) local_node = nodes.ReadLocalNode( @@ -112,7 +111,7 @@ def test_engines_read_local_w_repeated_source( engine, ): scan_list = nodes.ScanList.from_items( - nodes.ScanItem(identifiers.ColumnId(item.column), item.column) + nodes.ScanItem(identifiers.ColumnId(item.column), item.dtype, item.column) for item in repeated_data_source.schema.items ) local_node = nodes.ReadLocalNode( diff --git a/tests/system/small/engines/test_slicing.py b/tests/system/small/engines/test_slicing.py index 022758893d2..7340ff145b9 100644 --- a/tests/system/small/engines/test_slicing.py +++ b/tests/system/small/engines/test_slicing.py @@ -24,7 +24,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) @pytest.mark.parametrize( ("start", "stop", "step"), [ diff --git a/tests/system/small/engines/test_sorting.py b/tests/system/small/engines/test_sorting.py index cbb6215adae..ec1c0d95ee3 100644 --- a/tests/system/small/engines/test_sorting.py +++ b/tests/system/small/engines/test_sorting.py @@ -12,13 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import asyncio - import pytest -import bigframes.operations as bf_ops from bigframes.core import array_value, nodes, ordering -from bigframes.session import execution_spec, polars_executor +import bigframes.operations as bf_ops +from bigframes.session import polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution pytest.importorskip("polars") @@ -98,10 +96,7 @@ def test_polars_engines_skips_unrecognized_order_expr( ), ) node = nodes.OrderByNode(node, ORDER_EXPRESSIONS) - result = asyncio.run( - engine.execute(node, execution_spec.ExecutionSpec(ordered=True)) - ) - assert result is None + assert engine.execute(node, ordered=True) is None def apply_reverse(node: nodes.BigFrameNode) -> nodes.BigFrameNode: diff --git a/tests/system/small/engines/test_strings.py b/tests/system/small/engines/test_strings.py index 32a8c4bcd78..cbab517ef0b 100644 --- a/tests/system/small/engines/test_strings.py +++ b/tests/system/small/engines/test_strings.py @@ -14,8 +14,8 @@ import pytest -import bigframes.operations as ops 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 @@ -25,7 +25,7 @@ REFERENCE_ENGINE = polars_executor.PolarsExecutor() -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -38,7 +38,7 @@ def test_engines_str_contains(scalars_array_value: array_value.ArrayValue, engin assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_str_contains_regex( scalars_array_value: array_value.ArrayValue, engine ): @@ -53,7 +53,7 @@ def test_engines_str_contains_regex( assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ @@ -65,7 +65,7 @@ def test_engines_str_startswith(scalars_array_value: array_value.ArrayValue, eng assert_equivalence_execution(arr.node, REFERENCE_ENGINE, engine) -@pytest.mark.parametrize("engine", ["polars", "bq", "bq-sqlglot"], indirect=True) +@pytest.mark.parametrize("engine", ["polars", "bq"], indirect=True) def test_engines_str_endswith(scalars_array_value: array_value.ArrayValue, engine): arr, _ = scalars_array_value.compute_values( [ diff --git a/tests/system/small/engines/test_temporal_ops.py b/tests/system/small/engines/test_temporal_ops.py deleted file mode 100644 index 61b1b06b1f2..00000000000 --- a/tests/system/small/engines/test_temporal_ops.py +++ /dev/null @@ -1,113 +0,0 @@ -# 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 asyncio - -import pandas as pd -import pytest - -import bigframes.operations as ops -from bigframes import dtypes -from bigframes.core import array_value -from bigframes.core import expression as ex -from bigframes.session import polars_executor -from bigframes.testing.engine_utils import SPEC, 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) - - -@pytest.mark.parametrize("engine", ["bq", "bq-sqlglot"], indirect=True) -def test_engines_temporal_arithmetic( - scalars_array_value: array_value.ArrayValue, engine -): - exprs = [ - ops.timestamp_add_op.as_expr( - "timestamp_col", ex.const(pd.Timedelta(seconds=1), dtypes.TIMEDELTA_DTYPE) - ), - ops.timestamp_sub_op.as_expr( - "timestamp_col", ex.const(pd.Timedelta(seconds=1), dtypes.TIMEDELTA_DTYPE) - ), - ops.date_add_op.as_expr( - "date_col", ex.const(pd.Timedelta(days=1), dtypes.TIMEDELTA_DTYPE) - ), - ops.date_sub_op.as_expr( - "date_col", ex.const(pd.Timedelta(days=1), dtypes.TIMEDELTA_DTYPE) - ), - ops.timestamp_diff_op.as_expr("timestamp_col", "timestamp_col"), - ops.date_diff_op.as_expr("date_col", "date_col"), - ] - - arr, _ = scalars_array_value.compute_values(exprs) - res = asyncio.run(engine.execute(arr.node, SPEC)) - assert res is not None - assert len(res.batches().to_pandas()) > 0 - - -@pytest.mark.parametrize("engine", ["bq", "bq-sqlglot"], indirect=True) -def test_engines_to_datetime(scalars_array_value: array_value.ArrayValue, engine): - exprs = [ - ops.ToDatetimeOp().as_expr("timestamp_col"), - ] - arr, _ = scalars_array_value.compute_values(exprs) - res = asyncio.run(engine.execute(arr.node, SPEC)) - assert res is not None - df = res.batches().to_pandas() - # The input timestamp was: TIMESTAMP('2021-07-21T17:43:43.945289+00:00') - # The output should be naive DATETIME('2021-07-21T17:43:43.945289') - val = df.iloc[0, -1] - assert pd.Timestamp(val) == pd.Timestamp("2021-07-21T17:43:43.945289") diff --git a/tests/system/small/engines/test_windowing.py b/tests/system/small/engines/test_windowing.py index 8235fe0ef6b..a5f20a47cd3 100644 --- a/tests/system/small/engines/test_windowing.py +++ b/tests/system/small/engines/test_windowing.py @@ -12,18 +12,12 @@ # 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 array_value, expression, identifiers, nodes, window_spec import bigframes.operations.aggregations as agg_ops -from bigframes.core import ( - agg_expressions, - array_value, - expression, - identifiers, - nodes, - window_spec, -) -from bigframes.session import polars_executor +from bigframes.session import direct_gbq_execution, polars_executor from bigframes.testing.engine_utils import assert_equivalence_execution pytest.importorskip("polars") @@ -41,24 +35,28 @@ def test_engines_with_offsets( assert_equivalence_execution(result.node, REFERENCE_ENGINE, engine) +@pytest.mark.parametrize("never_skip_nulls", [True, False]) @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, + never_skip_nulls, agg_op, - bq_engine, - sqlglot_engine, ): 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"), - ), - ), + expression=expression.UnaryAggregation(agg_op, expression.deref("int64_too")), window_spec=window, + output_name=identifiers.ColumnId("agg_int64"), + never_skip_nulls=never_skip_nulls, + skip_reproject_unsafe=False, ) - assert_equivalence_execution(window_node, bq_engine, sqlglot_engine) + + bq_executor = direct_gbq_execution.DirectGbqExecutor(bigquery_client) + bq_sqlgot_executor = direct_gbq_execution.DirectGbqExecutor( + bigquery_client, compiler="sqlglot" + ) + 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 869b26ca38c..86076e764fe 100644 --- a/tests/system/small/functions/test_remote_function.py +++ b/tests/system/small/functions/test_remote_function.py @@ -19,40 +19,24 @@ import bigframes_vendored.constants as constants import google.api_core.exceptions -import pandas +from google.cloud import bigquery import pandas as pd import pyarrow import pytest import test_utils.prefixer -from google.cloud import bigquery import bigframes import bigframes.clients -import bigframes.core.events import bigframes.dtypes import bigframes.exceptions -import bigframes.session._io.bigquery from bigframes.functions import _utils as bff_utils from bigframes.functions import function as bff -from bigframes.testing.utils import assert_frame_equal, assert_series_equal +import bigframes.session._io.bigquery +from bigframes.testing.utils import assert_pandas_df_equal, get_function_name _prefixer = test_utils.prefixer.Prefixer("bigframes", "") -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 or [], 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}" - - @pytest.fixture(scope="module") def bq_cf_connection() -> str: """Pre-created BQ connection in the test project in US location, used to @@ -116,8 +100,12 @@ def get_bq_connection_id_path_format(connection_id_dot_format): 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, + cloudfunctions_client, + resourcemanager_client, scalars_dfs, dataset_id_permanent, bq_cf_connection, @@ -128,6 +116,10 @@ def square(x): square = bff.remote_function( input_types=int, output_type=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, # See e2e tests for tests that actually deploy the Cloud Function. @@ -165,7 +157,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -178,9 +170,10 @@ def test_remote_function_connection_w_location( def square(x): return x * x - square = session.remote_function( + square = bff.remote_function( 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. @@ -213,7 +206,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -246,9 +239,10 @@ def square(x): "The location does not match BigQuery connection location:" ), ): - session.remote_function( + 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. @@ -268,9 +262,10 @@ def test_remote_function_connection_w_location_project( def square(x): return x * x - square = session.remote_function( + square = bff.remote_function( 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. @@ -303,7 +298,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -338,9 +333,10 @@ def square(x): "The project_id does not match BigQuery connection gcp_project_id:" ), ): - session.remote_function( + 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. @@ -350,6 +346,49 @@ def square(x): )(square) +@pytest.mark.flaky(retries=2, delay=120) +def test_remote_function_direct_session_param( + session_with_bq_connection, scalars_dfs, dataset_id_permanent +): + def square(x): + return x * x + + square = bff.remote_function( + input_types=int, + output_type=int, + session=session_with_bq_connection, + dataset=dataset_id_permanent, + name=get_function_name(square), + cloud_function_service_account="default", + )(square) + + # Function should still work normally. + assert square(2) == 4 + + scalars_df, scalars_pandas_df = scalars_dfs + + bf_int64_col = scalars_df["int64_col"] + bf_int64_col_filter = bf_int64_col.notnull() + bf_int64_col_filtered = bf_int64_col[bf_int64_col_filter] + bf_result_col = bf_int64_col_filtered.apply(square) + bf_result = ( + bf_int64_col_filtered.to_frame().assign(result=bf_result_col).to_pandas() + ) + + pd_int64_col = scalars_pandas_df["int64_col"] + pd_int64_col_filter = pd_int64_col.notnull() + pd_int64_col_filtered = pd_int64_col[pd_int64_col_filter] + pd_result_col = pd_int64_col_filtered.apply(lambda x: x * x) + # TODO(shobs): Figure why pandas .apply() changes the dtype, i.e. + # pd_int64_col_filtered.dtype is Int64Dtype() + # pd_int64_col_filtered.apply(lambda x: x * x).dtype is int64. + # For this test let's force the pandas dtype to be same as bigframes' dtype. + 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) + + @pytest.mark.flaky(retries=2, delay=120) def test_remote_function_via_session_default( session_with_bq_connection, scalars_dfs, dataset_id_permanent @@ -396,7 +435,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -441,7 +480,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -468,12 +507,7 @@ def add_one(x): pd_int64_df = scalars_pandas_df[int64_cols] pd_int64_df_filtered = pd_int64_df.dropna() - - # TODO(swast): Remove when pandas 2.1.x+ is the minimum supported. - if hasattr(pd_int64_df_filtered, "map"): - pd_result = pd_int64_df_filtered.map(add_one) - else: - pd_result = pd_int64_df_filtered.applymap(add_one) + pd_result = pd_int64_df_filtered.applymap(add_one) # TODO(shobs): Figure why pandas .applymap() changes the dtype, i.e. # pd_int64_df_filtered.dtype is Int64Dtype() # pd_int64_df_filtered.applymap(lambda x: x).dtype is int64. @@ -481,7 +515,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -508,13 +542,7 @@ def add_one(x): pd_int64_df = scalars_pandas_df[int64_cols] pd_int64_df_filtered = pd_int64_df[pd_int64_df["int64_col"].notnull()] - - # TODO(swast): Remove when pandas 2.1.x+ is the minimum supported. - if hasattr(pd_int64_df_filtered, "map"): - pd_result = pd_int64_df_filtered.map(add_one) - else: - pd_result = pd_int64_df_filtered.applymap(add_one) - + pd_result = pd_int64_df_filtered.applymap(add_one) # TODO(shobs): Figure why pandas .applymap() changes the dtype, i.e. # pd_int64_df_filtered.dtype is Int64Dtype() # pd_int64_df_filtered.applymap(lambda x: x).dtype is int64. @@ -522,7 +550,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df_filtered[col].dtype) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -547,13 +575,7 @@ def add_one(x): bf_result = bf_int64_df.applymap(remote_add_one, na_action="ignore").to_pandas() pd_int64_df = scalars_pandas_df[int64_cols] - - # TODO(swast): Remove when pandas 2.1.x+ is the minimum supported. - if hasattr(pd_int64_df, "map"): - pd_result = pd_int64_df.map(add_one, na_action="ignore") - else: - pd_result = pd_int64_df.applymap(add_one, na_action="ignore") - + pd_result = pd_int64_df.applymap(add_one, na_action="ignore") # TODO(shobs): Figure why pandas .applymap() changes the dtype, i.e. # pd_int64_df_filtered.dtype is Int64Dtype() # pd_int64_df_filtered.applymap(lambda x: x).dtype is int64. @@ -561,7 +583,7 @@ def add_one(x): for col in pd_result: pd_result[col] = pd_result[col].astype(pd_int64_df[col].dtype) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.flaky(retries=2, delay=120) @@ -593,7 +615,7 @@ def bytes_to_hex(mybytes: bytes) -> bytes: )(bytes_to_hex) bf_result = scalars_df.bytes_col.map(remote_bytes_to_hex).to_pandas() - assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -643,8 +665,9 @@ def add_one(x): 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: - session.read_gbq_function( + bff.read_gbq_function( str(dataset_ref.routine("not_a_function")), + session=session, ) assert "Unknown function" in str(e.value) @@ -653,6 +676,10 @@ def test_read_gbq_function_detects_invalid_function(session, dataset_id): @pytest.mark.flaky(retries=2, delay=120) def test_read_gbq_function_like_original( session, + bigquery_client, + bigqueryconnection_client, + cloudfunctions_client, + resourcemanager_client, scalars_df_index, dataset_id_permanent, bq_cf_connection, @@ -663,7 +690,11 @@ def square1(x): square1 = bff.remote_function( input_types=[int], output_type=int, + bigquery_client=bigquery_client, + bigquery_connection_client=bigqueryconnection_client, dataset=dataset_id_permanent, + cloud_functions_client=cloudfunctions_client, + resource_manager_client=resourcemanager_client, bigquery_connection=bq_cf_connection, reuse=True, name=get_function_name(square1), @@ -673,8 +704,9 @@ def square1(x): # Function should still work normally. assert square1(2) == 4 - square2 = session.read_gbq_function( + square2 = bff.read_gbq_function( function_name=square1.bigframes_bigquery_function, # type: ignore + session=session, ) # The newly-created function (square1) should have a remote function AND a @@ -704,7 +736,7 @@ def square1(x): s2_result_col = int64_col_filtered.apply(square2) s2_result = int64_col_filtered.to_frame().assign(result=s2_result_col) - assert_frame_equal(s1_result.to_pandas(), s2_result.to_pandas()) + assert_pandas_df_equal(s1_result.to_pandas(), s2_result.to_pandas()) def test_read_gbq_function_runs_existing_udf(session): @@ -720,7 +752,7 @@ def test_read_gbq_function_runs_existing_udf_4_params(session): def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_unique): - bigframes.session._io.bigquery.start_query_with_job( + bigframes.session._io.bigquery.start_query_with_client( session.bqclient, textwrap.dedent( f""" @@ -736,7 +768,7 @@ def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_un project=None, timeout=None, metrics=None, - publisher=bigframes.core.events.Publisher(), + query_with_job=True, ) func = session.read_gbq_function(routine_id_unique) @@ -750,7 +782,7 @@ def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_un pd_result = pd_s.apply(func) bf_result = bf_s.apply(func) assert bigframes.dtypes.is_array_string_like(bf_result.dtype) - assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False ) @@ -758,7 +790,7 @@ def test_read_gbq_function_runs_existing_udf_array_output(session, routine_id_un def test_read_gbq_function_runs_existing_udf_2_params_array_output( session, routine_id_unique ): - bigframes.session._io.bigquery.start_query_with_job( + bigframes.session._io.bigquery.start_query_with_client( session.bqclient, textwrap.dedent( f""" @@ -774,7 +806,7 @@ def test_read_gbq_function_runs_existing_udf_2_params_array_output( project=None, timeout=None, metrics=None, - publisher=bigframes.core.events.Publisher(), + query_with_job=True, ) func = session.read_gbq_function(routine_id_unique) @@ -790,7 +822,7 @@ def test_read_gbq_function_runs_existing_udf_2_params_array_output( 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) - assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False ) @@ -798,7 +830,7 @@ def test_read_gbq_function_runs_existing_udf_2_params_array_output( def test_read_gbq_function_runs_existing_udf_4_params_array_output( session, routine_id_unique ): - bigframes.session._io.bigquery.start_query_with_job( + bigframes.session._io.bigquery.start_query_with_client( session.bqclient, textwrap.dedent( f""" @@ -814,7 +846,7 @@ def test_read_gbq_function_runs_existing_udf_4_params_array_output( project=None, timeout=None, metrics=None, - publisher=bigframes.core.events.Publisher(), + query_with_job=True, ) func = session.read_gbq_function(routine_id_unique) @@ -844,7 +876,7 @@ def test_read_gbq_function_runs_existing_udf_4_params_array_output( ) bf_result = bf_df.apply(func, axis=1) assert bigframes.dtypes.is_array_string_like(bf_result.dtype) - assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False ) @@ -874,8 +906,9 @@ def test_read_gbq_function_reads_udfs(session, bigquery_client, dataset_id): for routine in (sql_routine, js_routine): # Create the routine in BigQuery and read it back using read_gbq_function. bigquery_client.create_routine(routine, exists_ok=True) - square = session.read_gbq_function( + square = bff.read_gbq_function( str(routine.reference), + session=session, ) # It should point to the named routine and yield the expected results. @@ -899,7 +932,7 @@ 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_frame_equal( + assert_pandas_df_equal( direct_df, converted_indirect_df, ignore_order=True, check_index_type=False ) @@ -949,23 +982,27 @@ def test_read_gbq_function_requires_explicit_types( bigquery_client.create_routine(only_arg_type_specified, exists_ok=True) bigquery_client.create_routine(neither_type_specified, exists_ok=True) - session.read_gbq_function( + bff.read_gbq_function( str(both_types_specified.reference), + session=session, ) with pytest.warns( bigframes.exceptions.UnknownDataTypeWarning, match=r"missing input data types[\s\S]*assume default data type", ): - session.read_gbq_function( + bff.read_gbq_function( str(only_return_type_specified.reference), + session=session, ) with pytest.raises(ValueError): - session.read_gbq_function( + bff.read_gbq_function( str(only_arg_type_specified.reference), + session=session, ) with pytest.raises(ValueError): - session.read_gbq_function( + bff.read_gbq_function( str(neither_type_specified.reference), + session=session, ) @@ -1010,7 +1047,7 @@ def test_read_gbq_function_respects_python_output_type( # Create the routine in BigQuery and read it back using read_gbq_function. bigquery_client.create_routine(sql_routine, exists_ok=True) - func = session.read_gbq_function(str(sql_routine.reference)) + func = bff.read_gbq_function(str(sql_routine.reference), session=session) # test that the function works as expected s = bigframes.series.Series([1, 10, 100]) @@ -1018,7 +1055,9 @@ def test_read_gbq_function_respects_python_output_type( actual = s.apply(func).to_pandas() # ignore type disparities, e.g. "int64" in pandas v/s "Int64" in bigframes - assert_series_equal(expected, actual, check_dtype=False, check_index_type=False) + pd.testing.assert_series_equal( + expected, actual, check_dtype=False, check_index_type=False + ) @pytest.mark.parametrize( @@ -1056,7 +1095,7 @@ def test_read_gbq_function_supports_python_output_type_only_for_string_outputs( TypeError, match="An explicit output_type should be provided only for a BigQuery function with STRING output.", ): - session.read_gbq_function(str(sql_routine.reference)) + bff.read_gbq_function(str(sql_routine.reference), session=session) @pytest.mark.parametrize( @@ -1087,7 +1126,7 @@ def test_read_gbq_function_supported_python_output_type( # Create the routine in BigQuery and read it back using read_gbq_function. bigquery_client.create_routine(sql_routine, exists_ok=True) - session.read_gbq_function(str(sql_routine.reference)) + bff.read_gbq_function(str(sql_routine.reference), session=session) @pytest.mark.flaky(retries=2, delay=120) @@ -1115,6 +1154,20 @@ def test_df_apply_scalar_func(session, scalars_dfs): ) +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 " + f"takes in a single input representing the row. {constants.FEEDBACK_LINK}" + ) + + @pytest.mark.flaky(retries=2, delay=120) def test_df_apply_axis_1(session, scalars_dfs, dataset_id_permanent): columns = [ @@ -1127,7 +1180,7 @@ def test_df_apply_axis_1(session, scalars_dfs, dataset_id_permanent): ] scalars_df, scalars_pandas_df = scalars_dfs - def add_ints(row: pandas.Series) -> int: + def add_ints(row): return row["int64_col"] + row["int64_too"] with pytest.warns( @@ -1135,6 +1188,8 @@ def add_ints(row: pandas.Series) -> int: match="input_types=Series is in preview.", ): add_ints_remote = session.remote_function( + input_types=bigframes.series.Series, + output_type=int, dataset=dataset_id_permanent, name=get_function_name(add_ints, is_row_processor=True), cloud_function_service_account="default", @@ -1156,7 +1211,9 @@ def add_ints(row: pandas.Series) -> int: # bf_result.to_numpy() produces an array of numpy.float64's # (in system_prerelease tests), while pd_result.to_numpy() produces an # array of ints, ignore this mismatch by using check_exact=False. - assert_series_equal(pd_result, bf_result, check_dtype=False, check_exact=False) + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_exact=False + ) # Read back the deployed BQ remote function using read_gbq_function. func_ref = session.read_gbq_function( @@ -1164,17 +1221,14 @@ def add_ints(row: pandas.Series) -> int: 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 == 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() - assert_series_equal(pd_result, bf_result_gbq, check_dtype=False, check_exact=False) + pd.testing.assert_series_equal( + pd_result, bf_result_gbq, check_dtype=False, check_exact=False + ) @pytest.mark.flaky(retries=2, delay=120) @@ -1183,11 +1237,11 @@ 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: pandas.Series) -> int: + def add_ints(row): return row["int64_col"] + row["int64_too"] add_ints_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=int, dataset=dataset_id_permanent, name=get_function_name(add_ints, is_row_processor=True), @@ -1210,7 +1264,9 @@ def add_ints(row: pandas.Series) -> int: # bf_result.to_numpy() produces an array of numpy.float64's # (in system_prerelease tests), while pd_result.to_numpy() produces an # array of ints, ignore this mismatch by using check_exact=False. - assert_series_equal(pd_result, bf_result, check_dtype=False, check_exact=False) + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_exact=False + ) @pytest.mark.flaky(retries=2, delay=120) @@ -1225,7 +1281,7 @@ def add_numbers(row): return row["x"] + row["y"] add_numbers_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=float, dataset=dataset_id_permanent, name=get_function_name(add_numbers, is_row_processor=True), @@ -1241,7 +1297,9 @@ def add_numbers(row): # bf_result.index[0].dtype is 'string[pyarrow]' while # pd_result.index[0].dtype is 'object', ignore this mismatch by using # check_index_type=False. - assert_series_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + pd.testing.assert_series_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_apply_axis_1_unsupported_callable(scalars_dfs): @@ -1277,7 +1335,7 @@ def echo_len(row): return len(row) echo_len_remote = session.remote_function( - input_types=pandas.Series, + input_types=bigframes.series.Series, output_type=float, dataset=dataset_id_permanent, name=get_function_name(echo_len, is_row_processor=True), @@ -1290,17 +1348,13 @@ def echo_len(row): dtype = scalars_df[column].dtype - with ( - pytest.raises( - NotImplementedError, - match=re.escape( - f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1. Supported dtypes are (" - ), - ), - pytest.warns( - bigframes.exceptions.PreviewWarning, - match="axis=1 scenario is in preview.", + with pytest.raises( + NotImplementedError, + match=re.escape( + f"DataFrame has a column of dtype '{dtype}' which is not supported with axis=1. Supported dtypes are (" ), + ), pytest.warns( + bigframes.exceptions.PreviewWarning, match="axis=1 scenario is in preview." ): scalars_df[[column]].apply(echo_len_remote, axis=1) @@ -1409,7 +1463,7 @@ def is_odd(x: int) -> bool: bf_result = bf_method(is_odd_remote).to_pandas() # ignore any dtype difference - assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.flaky(retries=2, delay=120) @@ -1458,7 +1512,7 @@ def add(x: int, y: int) -> int: ) # ignore any dtype difference - assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.flaky(retries=2, delay=120) @@ -1520,7 +1574,7 @@ def add_pandas(s: pd.Series) -> float: bf_result = bf_df[bf_filter].apply(add_remote, axis=1).to_pandas() # ignore any dtype difference - assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -1603,11 +1657,12 @@ def func_tuple(x): with pytest.raises( ValueError, - match=r"must be one of the supported types", + match=r"'typing\.Sequence\[int\]' must be one of the supported types", ): - session.remote_function( + bff.remote_function( input_types=int, output_type=Sequence[int], + session=session, dataset=dataset_id_permanent, bigquery_connection=bq_cf_connection, reuse=True, diff --git a/tests/system/small/geopandas/test_geoseries.py b/tests/system/small/geopandas/test_geoseries.py index 9f1f830dc68..a2f0759161d 100644 --- a/tests/system/small/geopandas/test_geoseries.py +++ b/tests/system/small/geopandas/test_geoseries.py @@ -18,11 +18,11 @@ 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 geopandas.array import GeometryDtype # type:ignore from shapely.geometry import ( # type: ignore GeometryCollection, LineString, diff --git a/tests/system/small/ml/conftest.py b/tests/system/small/ml/conftest.py index 2f84b351e04..8f05e7fe036 100644 --- a/tests/system/small/ml/conftest.py +++ b/tests/system/small/ml/conftest.py @@ -13,8 +13,8 @@ # limitations under the License. import os -import uuid from typing import cast +import uuid import pandas as pd import pytest @@ -83,6 +83,15 @@ def ephemera_penguins_linear_model( return bf_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_logistic_model( session, penguins_logistic_model_name diff --git a/tests/system/small/ml/test_cluster.py b/tests/system/small/ml/test_cluster.py index 2bf334e84df..4840329cdac 100644 --- a/tests/system/small/ml/test_cluster.py +++ b/tests/system/small/ml/test_cluster.py @@ -12,12 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import numpy as np import pandas as pd -import bigframes.pandas as bpd from bigframes.ml import cluster -from bigframes.testing.utils import assert_frame_equal +import bigframes.pandas as bpd +from bigframes.testing.utils import assert_pandas_df_equal _PD_NEW_PENGUINS = pd.DataFrame.from_dict( { @@ -72,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_frame_equal(result, expected, ignore_order=True) + assert_pandas_df_equal(result, expected, ignore_order=True) def test_kmeans_detect_anomalies( @@ -142,26 +141,6 @@ def test_kmeans_cluster_centers(penguins_kmeans_model: cluster.KMeans): .sort_values(["centroid_id", "feature"]) .reset_index(drop=True) ) - - # FIX: Helper to ignore row order inside categorical_value lists - # and sign flipping of values inside numerical_value list. - # This prevents the test from failing if BQML returns [MALE, FEMALE] instead of [FEMALE, MALE] - # or 0.197 versus -0.197. - def sort_and_abs_categorical(val): - # Accept BOTH python lists AND numpy arrays - if isinstance(val, (list, np.ndarray)) and len(val) > 0: - # Take abs of value first, then sort - processed = [ - {"category": x["category"], "value": abs(x["value"])} for x in val - ] - return sorted(processed, key=lambda x: x["category"]) - return val - - result["numerical_value"] = result["numerical_value"].abs() - result["categorical_value"] = result["categorical_value"].apply( - sort_and_abs_categorical - ) - expected = ( pd.DataFrame( { @@ -219,18 +198,11 @@ def sort_and_abs_categorical(val): .sort_values(["centroid_id", "feature"]) .reset_index(drop=True) ) - - # Sort and sign flip expected values to match the output of the model. - expected["numerical_value"] = expected["numerical_value"].abs() - expected["categorical_value"] = expected["categorical_value"].apply( - sort_and_abs_categorical - ) - pd.testing.assert_frame_equal( result, expected, check_exact=False, - rtol=0.1, # Keep or slightly increase if numerical drift persists + rtol=0.1, # int64 Index by default in pandas versus Int64 (nullable) Index in BigQuery DataFrame check_index_type=False, check_dtype=False, diff --git a/tests/system/small/ml/test_core.py b/tests/system/small/ml/test_core.py index c32ba80b30e..ef62e5ddd31 100644 --- a/tests/system/small/ml/test_core.py +++ b/tests/system/small/ml/test_core.py @@ -12,10 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import typing from datetime import datetime +import typing -import numpy as np import pandas as pd import pyarrow as pa import pytest @@ -79,16 +78,6 @@ def test_model_eval_with_data(penguins_bqml_linear_model, penguins_df_default_in def test_model_centroids(penguins_bqml_kmeans_model: core.BqmlModel): result = penguins_bqml_kmeans_model.centroids().to_pandas() - - # FIX: Helper to ignore row order inside categorical_value lists - # This prevents the test from failing if BQML returns [MALE, FEMALE] instead of [FEMALE, MALE] - def sort_categorical(val): - if isinstance(val, (list, np.ndarray)) and len(val) > 0: - return sorted(val, key=lambda x: x["category"]) - return val - - result["categorical_value"] = result["categorical_value"].apply(sort_categorical) - expected = ( pd.DataFrame( { @@ -146,12 +135,6 @@ def sort_categorical(val): .sort_values(["centroid_id", "feature"]) .reset_index(drop=True) ) - - # Sort expected values to match the output of the model. - expected["categorical_value"] = expected["categorical_value"].apply( - sort_categorical - ) - pd.testing.assert_frame_equal( result, expected, @@ -169,26 +152,6 @@ def test_pca_model_principal_components(penguins_bqml_pca_model: core.BqmlModel) # result is too long, only check the first principal component here. result = result.head(7) - - # FIX: Helper to ignore row order inside categorical_value lists - # and sign flipping of values inside numerical_value list. - # This prevents the test from failing if BQML returns [MALE, FEMALE] instead of [FEMALE, MALE] - # or 0.197 versus -0.197. - def sort_and_abs_categorical(val): - # Accept BOTH python lists AND numpy arrays - if isinstance(val, (list, np.ndarray)) and len(val) > 0: - # Take abs of value first, then sort - processed = [ - {"category": x["category"], "value": abs(x["value"])} for x in val - ] - return sorted(processed, key=lambda x: x["category"]) - return val - - result["numerical_value"] = result["numerical_value"].abs() - result["categorical_value"] = result["categorical_value"].apply( - sort_and_abs_categorical - ) - expected = ( pd.DataFrame( { @@ -248,12 +211,6 @@ def sort_and_abs_categorical(val): .reset_index(drop=True) ) - # Sort and sign flip expected values to match the output of the model. - expected["numerical_value"] = expected["numerical_value"].abs() - expected["categorical_value"] = expected["categorical_value"].apply( - sort_and_abs_categorical - ) - utils.assert_pandas_df_equal_pca_components( result, expected, @@ -276,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_frame_equal( + utils.assert_pandas_df_equal( result, expected, check_exact=False, diff --git a/tests/system/small/ml/test_decomposition.py b/tests/system/small/ml/test_decomposition.py index 36abfe55adf..10255003a10 100644 --- a/tests/system/small/ml/test_decomposition.py +++ b/tests/system/small/ml/test_decomposition.py @@ -12,12 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -import numpy as np import pandas as pd +from bigframes.ml import decomposition import bigframes.pandas as bpd import bigframes.testing.utils -from bigframes.ml import decomposition def test_pca_predict( @@ -35,7 +34,7 @@ def test_pca_predict( ) bigframes.testing.utils.assert_pandas_df_equal_pca( - predictions, expected, check_exact=False, rtol=0.2 + predictions, expected, check_exact=False, rtol=0.1 ) @@ -56,7 +55,7 @@ def test_pca_detect_anomalies( expected, check_exact=False, check_dtype=False, - rtol=0.2, + rtol=0.1, ) @@ -79,7 +78,7 @@ def test_pca_detect_anomalies_params( expected, check_exact=False, check_dtype=False, - rtol=0.2, + rtol=0.1, ) @@ -93,7 +92,7 @@ def test_pca_score(penguins_pca_model: decomposition.PCA): result, expected, check_exact=False, - rtol=0.2, + rtol=0.1, check_index_type=False, ) @@ -103,26 +102,6 @@ def test_pca_components_(penguins_pca_model: decomposition.PCA): # result is too long, only check the first principal component here. result = result.head(7) - - # FIX: Helper to ignore row order inside categorical_value lists - # and sign flipping of values inside numerical_value list. - # This prevents the test from failing if BQML returns [MALE, FEMALE] instead of [FEMALE, MALE] - # or 0.197 versus -0.197. - def sort_and_abs_categorical(val): - # Accept BOTH python lists AND numpy arrays - if isinstance(val, (list, np.ndarray)) and len(val) > 0: - # Take abs of value first, then sort - processed = [ - {"category": x["category"], "value": abs(x["value"])} for x in val - ] - return sorted(processed, key=lambda x: x["category"]) - return val - - result["numerical_value"] = result["numerical_value"].abs() - result["categorical_value"] = result["categorical_value"].apply( - sort_and_abs_categorical - ) - expected = ( pd.DataFrame( { @@ -182,17 +161,11 @@ def sort_and_abs_categorical(val): .reset_index(drop=True) ) - # Sort and sign flip expected values to match the output of the model. - expected["numerical_value"] = expected["numerical_value"].abs() - expected["categorical_value"] = expected["categorical_value"].apply( - sort_and_abs_categorical - ) - bigframes.testing.utils.assert_pandas_df_equal_pca_components( result, expected, check_exact=False, - rtol=0.2, # FIX: Slightly increased rtol for numerical drift (from 0.1) + rtol=0.1, check_index_type=False, check_dtype=False, ) @@ -207,11 +180,11 @@ def test_pca_explained_variance_(penguins_pca_model: decomposition.PCA): "explained_variance": [3.278657, 1.270829, 1.125354], }, ) - bigframes.testing.utils.assert_frame_equal( + bigframes.testing.utils.assert_pandas_df_equal( result, expected, check_exact=False, - rtol=0.2, + rtol=0.1, check_index_type=False, check_dtype=False, ignore_order=True, @@ -227,11 +200,11 @@ def test_pca_explained_variance_ratio_(penguins_pca_model: decomposition.PCA): "explained_variance_ratio": [0.469357, 0.181926, 0.1611], }, ) - bigframes.testing.utils.assert_frame_equal( + bigframes.testing.utils.assert_pandas_df_equal( result, expected, check_exact=False, - rtol=0.2, + rtol=0.1, check_index_type=False, check_dtype=False, ignore_order=True, diff --git a/tests/system/small/ml/test_forecasting.py b/tests/system/small/ml/test_forecasting.py index 23487983ee3..d1b6b18fbe1 100644 --- a/tests/system/small/ml/test_forecasting.py +++ b/tests/system/small/ml/test_forecasting.py @@ -432,10 +432,8 @@ def test_arima_plus_detect_anomalies_params( }, ) pd.testing.assert_frame_equal( - 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), + anomalies[["is_anomaly", "lower_bound", "upper_bound", "anomaly_probability"]], + expected, rtol=0.1, check_index_type=False, check_dtype=False, @@ -451,16 +449,11 @@ 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() - .sort_values("id") - .reset_index(drop=True) - ) + 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() else: result = time_series_arima_plus_model.score( new_time_series_df[["parsed_date"]], new_time_series_df[["total_visits"]] @@ -479,8 +472,6 @@ 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( { @@ -493,11 +484,10 @@ def test_arima_plus_score( dtype="Float64", ) pd.testing.assert_frame_equal( - result[expected.columns], + result, expected, rtol=0.1, check_index_type=False, - check_dtype=False, ) @@ -552,16 +542,11 @@ 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() - .sort_values("id") - .reset_index(drop=True) - ) + 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() else: result = time_series_arima_plus_model.score( new_time_series_df["parsed_date"], new_time_series_df["total_visits"] @@ -580,8 +565,6 @@ 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( { @@ -594,11 +577,10 @@ def test_arima_plus_score_series( dtype="Float64", ) pd.testing.assert_frame_equal( - result[expected.columns], + result, expected, rtol=0.1, check_index_type=False, - check_dtype=False, ) diff --git a/tests/system/small/ml/test_linear_model.py b/tests/system/small/ml/test_linear_model.py index da9fc8e14f8..8b04d55e613 100644 --- a/tests/system/small/ml/test_linear_model.py +++ b/tests/system/small/ml/test_linear_model.py @@ -228,6 +228,42 @@ def test_to_gbq_saved_linear_reg_model_scores( ) +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 = pandas.Index(["attribution"]) + pandas.testing.assert_index_equal(global_ex.columns, expected_columns) + result = global_ex.to_pandas().drop(["attribution"], axis=1).sort_index() + expected_feature = ( + pandas.DataFrame( + { + "feature": [ + "island", + "species", + "sex", + "flipper_length_mm", + "culmen_depth_mm", + "culmen_length_mm", + ] + }, + ) + .set_index("feature") + .sort_index() + ) + pandas.testing.assert_frame_equal( + result, + expected_feature, + check_exact=False, + check_index_type=False, + ) + + def test_to_gbq_replace(penguins_linear_model, table_id_unique): penguins_linear_model.to_gbq(table_id_unique, replace=True) with pytest.raises(google.api_core.exceptions.Conflict): diff --git a/tests/system/small/ml/test_llm.py b/tests/system/small/ml/test_llm.py new file mode 100644 index 00000000000..245fead0287 --- /dev/null +++ b/tests/system/small/ml/test_llm.py @@ -0,0 +1,809 @@ +# 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 Callable +from unittest import mock + +import pandas as pd +import pyarrow as pa +import pytest + +from bigframes import exceptions +from bigframes.ml import core, llm +import bigframes.pandas as bpd +from bigframes.testing import utils + + +@pytest.mark.parametrize( + "model_name", + ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), +) +def test_create_load_text_embedding_generator_model( + dataset_id, model_name, session, bq_connection +): + text_embedding_model = llm.TextEmbeddingGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + assert text_embedding_model is not None + assert text_embedding_model._bqml_model is not None + + # save, load to ensure configuration was kept + reloaded_model = text_embedding_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", + ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), +) +@pytest.mark.flaky(retries=2) +def test_text_embedding_generator_predict_default_params_success( + llm_text_df, model_name, session, bq_connection +): + text_embedding_model = llm.TextEmbeddingGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + df = text_embedding_model.predict(llm_text_df).to_pandas() + utils.check_pandas_df_schema_and_index( + df, columns=utils.ML_GENERATE_EMBEDDING_OUTPUT, index=3, col_exact=False + ) + assert len(df["ml_generate_embedding_result"][0]) == 768 + + +@pytest.mark.parametrize( + "model_name", + ("text-embedding-005", "text-embedding-004", "text-multilingual-embedding-002"), +) +@pytest.mark.flaky(retries=2) +def test_text_embedding_generator_multi_cols_predict_success( + llm_text_df: bpd.DataFrame, model_name, session, bq_connection +): + df = llm_text_df.assign(additional_col=1) + df = df.rename(columns={"prompt": "content"}) + text_embedding_model = llm.TextEmbeddingGenerator( + model_name=model_name, connection_name=bq_connection, session=session + ) + pd_df = text_embedding_model.predict(df).to_pandas() + utils.check_pandas_df_schema_and_index( + pd_df, + columns=utils.ML_GENERATE_EMBEDDING_OUTPUT + ["additional_col"], + index=3, + col_exact=False, + ) + assert len(pd_df["ml_generate_embedding_result"][0]) == 768 + + +def test_create_load_multimodal_embedding_generator_model( + dataset_id, session, bq_connection +): + mm_embedding_model = llm.MultimodalEmbeddingGenerator( + connection_name=bq_connection, session=session + ) + 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 = mm_embedding_model.to_gbq( + f"{dataset_id}.temp_mm_model", replace=True + ) + assert f"{dataset_id}.temp_mm_model" == reloaded_model._bqml_model.model_name + assert reloaded_model.connection_name == bq_connection + + +@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 +) # 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", + ), +) +@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", + ), +) +@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", + ), +) +@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", + ), +) +@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, + ) + + +# Overrides __eq__ function for comparing as mock.call parameter +class EqCmpAllDataFrame(bpd.DataFrame): + def __eq__(self, other): + return self.equals(other) + + +@pytest.mark.skip("b/436340035 test failed") +@pytest.mark.parametrize( + ( + "model_class", + "options", + ), + [ + ( + llm.GeminiTextGenerator, + { + "temperature": 0.9, + "max_output_tokens": 8192, + "top_p": 1.0, + "ground_with_google_search": False, + }, + ), + ( + llm.Claude3TextGenerator, + { + "max_output_tokens": 128, + "top_k": 40, + "top_p": 0.95, + }, + ), + ], +) +def test_text_generator_retry_success( + session, + model_class, + options, + bq_connection, +): + # Requests. + df0 = EqCmpAllDataFrame( + { + "prompt": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ] + }, + index=[0, 1, 2], + session=session, + ) + df1 = EqCmpAllDataFrame( + { + "ml_generate_text_status": ["error", "error"], + "prompt": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ) + df2 = EqCmpAllDataFrame( + { + "ml_generate_text_status": ["error"], + "prompt": [ + "What is BQML?", + ], + }, + index=[1], + 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_generate_text.side_effect = [ + EqCmpAllDataFrame( + { + "ml_generate_text_status": ["", "error", "error"], + "prompt": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_text_status": ["error", ""], + "prompt": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_text_status": [""], + "prompt": [ + "What is BQML?", + ], + }, + index=[1], + session=session, + ), + ] + + text_generator_model = model_class(connection_name=bq_connection, session=session) + text_generator_model._bqml_model = mock_bqml_model + + 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_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", + "options", + ), + [ + ( + llm.GeminiTextGenerator, + { + "temperature": 0.9, + "max_output_tokens": 8192, + "top_p": 1.0, + "ground_with_google_search": False, + }, + ), + ( + llm.Claude3TextGenerator, + { + "max_output_tokens": 128, + "top_k": 40, + "top_p": 0.95, + }, + ), + ], +) +def test_text_generator_retry_no_progress(session, model_class, options, bq_connection): + # Requests. + df0 = EqCmpAllDataFrame( + { + "prompt": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ] + }, + index=[0, 1, 2], + session=session, + ) + df1 = EqCmpAllDataFrame( + { + "ml_generate_text_status": ["error", "error"], + "prompt": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + 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_generate_text.side_effect = [ + EqCmpAllDataFrame( + { + "ml_generate_text_status": ["", "error", "error"], + "prompt": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_text_status": ["error", "error"], + "prompt": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ), + ] + + text_generator_model = model_class(connection_name=bq_connection, session=session) + text_generator_model._bqml_model = mock_bqml_model + + 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_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( + { + "content": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ] + }, + index=[0, 1, 2], + session=session, + ) + df1 = EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["error", "error"], + "content": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ) + df2 = EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["error"], + "content": [ + "What is BQML?", + ], + }, + index=[1], + 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_generate_embedding.side_effect = [ + EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["", "error", "error"], + "content": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["error", ""], + "content": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_embedding_status": [""], + "content": [ + "What is BQML?", + ], + }, + index=[1], + session=session, + ), + ] + options: dict = {} + + text_embedding_model = llm.TextEmbeddingGenerator( + connection_name=bq_connection, session=session + ) + text_embedding_model._bqml_model = mock_bqml_model + + 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): + # Requests. + df0 = EqCmpAllDataFrame( + { + "content": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ] + }, + index=[0, 1, 2], + session=session, + ) + df1 = EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["error", "error"], + "content": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + 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_generate_embedding.side_effect = [ + EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["", "error", "error"], + "content": [ + "What is BigQuery?", + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[0, 1, 2], + session=session, + ), + EqCmpAllDataFrame( + { + "ml_generate_embedding_status": ["error", "error"], + "content": [ + "What is BQML?", + "What is BigQuery DataFrame?", + ], + }, + index=[1, 2], + session=session, + ), + ] + options: dict = {} + + text_embedding_model = llm.TextEmbeddingGenerator( + connection_name=bq_connection, session=session + ) + text_embedding_model._bqml_model = mock_bqml_model + + 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) + + 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.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", + ], + ) + + +@pytest.mark.parametrize( + "model_name", + ("gemini-2.0-flash-exp",), +) +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_class", + [ + llm.TextEmbeddingGenerator, + llm.MultimodalEmbeddingGenerator, + llm.GeminiTextGenerator, + # llm.Claude3TextGenerator, + ], +) +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 ab9c3e4552c..fd5dbef2e30 100644 --- a/tests/system/small/ml/test_metrics.py +++ b/tests/system/small/ml/test_metrics.py @@ -19,7 +19,6 @@ import pytest import bigframes -import bigframes.testing.utils from bigframes.ml import metrics @@ -162,7 +161,7 @@ def test_roc_curve_binary_classification_prediction_returns_expected(session): pd_tpr = tpr.to_pandas() pd_thresholds = thresholds.to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( # skip testing the first value, as it is redundant and inconsistent across sklearn versions pd_thresholds[1:], pd.Series( @@ -172,7 +171,7 @@ def test_roc_curve_binary_classification_prediction_returns_expected(session): ), check_index=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_fpr, pd.Series( [0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 0.75, 1.0], @@ -181,7 +180,7 @@ def test_roc_curve_binary_classification_prediction_returns_expected(session): ), check_index_type=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_tpr, pd.Series( [ @@ -262,7 +261,7 @@ def test_roc_curve_binary_classification_decision_returns_expected(session): pd_tpr = tpr.to_pandas() pd_thresholds = thresholds.to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( # skip testing the first value, as it is redundant and inconsistent across sklearn versions pd_thresholds[1:], pd.Series( @@ -272,7 +271,7 @@ def test_roc_curve_binary_classification_decision_returns_expected(session): ), check_index=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_fpr, pd.Series( [0.0, 0.0, 1.0], @@ -281,7 +280,7 @@ def test_roc_curve_binary_classification_decision_returns_expected(session): ), check_index_type=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_tpr, pd.Series( [ @@ -354,7 +353,7 @@ def test_roc_curve_binary_classification_prediction_series(session): pd_tpr = tpr.to_pandas() pd_thresholds = thresholds.to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( # skip testing the first value, as it is redundant and inconsistent across sklearn versions pd_thresholds[1:], pd.Series( @@ -364,7 +363,7 @@ def test_roc_curve_binary_classification_prediction_series(session): ), check_index=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_fpr, pd.Series( [0.0, 0.0, 0.0, 0.25, 0.25, 0.5, 0.5, 0.75, 0.75, 0.75, 1.0], @@ -373,7 +372,7 @@ def test_roc_curve_binary_classification_prediction_series(session): ), check_index_type=False, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_tpr, pd.Series( [ @@ -506,7 +505,7 @@ def test_confusion_matrix(session): 2: [0, 1, 2], } ).astype("int64") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( confusion_matrix, expected_pd_df, check_index_type=False ) @@ -524,7 +523,7 @@ def test_confusion_matrix_column_index(session): {1: [1, 0, 1, 0], 2: [0, 0, 2, 0], 3: [0, 0, 0, 0], 4: [0, 1, 0, 1]}, index=[1, 2, 3, 4], ).astype("int64") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( confusion_matrix, expected_pd_df, check_index_type=False ) @@ -543,7 +542,7 @@ def test_confusion_matrix_matches_sklearn(session): pd_df[["y_true"]], pd_df[["y_pred"]] ) expected_pd_df = pd.DataFrame(expected_confusion_matrix) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( confusion_matrix, expected_pd_df, check_index_type=False ) @@ -565,7 +564,7 @@ def test_confusion_matrix_str_matches_sklearn(session): expected_confusion_matrix, index=["ant", "bird", "cat"] ) expected_pd_df.columns = pd.Index(["ant", "bird", "cat"]) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( confusion_matrix, expected_pd_df, check_index_type=False ) @@ -586,7 +585,7 @@ def test_confusion_matrix_series(session): 2: [0, 1, 2], } ).astype("int64") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( confusion_matrix, expected_pd_df, check_index_type=False ) @@ -606,9 +605,7 @@ def test_recall_score(session): expected_index = [0, 1, 2] expected_recall = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - recall, expected_recall, check_index_type=False - ) + pd.testing.assert_series_equal(recall, expected_recall, check_index_type=False) def test_recall_score_matches_sklearn(session): @@ -626,9 +623,7 @@ def test_recall_score_matches_sklearn(session): ) expected_index = [0, 1, 2] expected_recall = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - recall, expected_recall, check_index_type=False - ) + pd.testing.assert_series_equal(recall, expected_recall, check_index_type=False) def test_recall_score_str_matches_sklearn(session): @@ -646,9 +641,7 @@ def test_recall_score_str_matches_sklearn(session): ) expected_index = ["ant", "bird", "cat"] expected_recall = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - recall, expected_recall, check_index_type=False - ) + pd.testing.assert_series_equal(recall, expected_recall, check_index_type=False) def test_recall_score_series(session): @@ -664,9 +657,7 @@ def test_recall_score_series(session): expected_index = [0, 1, 2] expected_recall = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - recall, expected_recall, check_index_type=False - ) + pd.testing.assert_series_equal(recall, expected_recall, check_index_type=False) def test_precision_score(session): @@ -684,7 +675,7 @@ def test_precision_score(session): expected_index = [0, 1, 2] expected_precision = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( precision_score, expected_precision, check_index_type=False ) @@ -707,7 +698,7 @@ def test_precision_score_matches_sklearn(session): ) expected_index = [0, 1, 2] expected_precision = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( precision_score, expected_precision, check_index_type=False ) @@ -729,7 +720,7 @@ def test_precision_score_str_matches_sklearn(session): ) expected_index = ["ant", "bird", "cat"] expected_precision = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( precision_score, expected_precision, check_index_type=False ) @@ -747,75 +738,11 @@ def test_precision_score_series(session): expected_index = [0, 1, 2] expected_precision = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( precision_score, expected_precision, check_index_type=False ) -@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( { @@ -831,9 +758,7 @@ def test_f1_score(session): expected_index = [0, 1, 2] expected_f1 = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - f1_score, expected_f1, check_index_type=False - ) + pd.testing.assert_series_equal(f1_score, expected_f1, check_index_type=False) def test_f1_score_matches_sklearn(session): @@ -851,9 +776,7 @@ def test_f1_score_matches_sklearn(session): ) expected_index = [0, 1, 2] expected_f1 = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - f1_score, expected_f1, check_index_type=False - ) + pd.testing.assert_series_equal(f1_score, expected_f1, check_index_type=False) def test_f1_score_str_matches_sklearn(session): @@ -871,9 +794,7 @@ def test_f1_score_str_matches_sklearn(session): ) expected_index = ["ant", "bird", "cat"] expected_f1 = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - f1_score, expected_f1, check_index_type=False - ) + pd.testing.assert_series_equal(f1_score, expected_f1, check_index_type=False) def test_f1_score_series(session): @@ -889,9 +810,7 @@ def test_f1_score_series(session): expected_index = [0, 1, 2] expected_f1 = pd.Series(expected_values, index=expected_index) - bigframes.testing.utils.assert_series_equal( - f1_score, expected_f1, check_index_type=False - ) + pd.testing.assert_series_equal(f1_score, expected_f1, check_index_type=False) def test_mean_squared_error(session: bigframes.Session): diff --git a/tests/system/small/ml/test_metrics_pairwise.py b/tests/system/small/ml/test_metrics_pairwise.py index 44f1ed671b7..d3798f7cae1 100644 --- a/tests/system/small/ml/test_metrics_pairwise.py +++ b/tests/system/small/ml/test_metrics_pairwise.py @@ -15,8 +15,8 @@ import numpy as np import pandas as pd -import bigframes.pandas as bpd from bigframes.ml import metrics +import bigframes.pandas as bpd def test_paired_cosine_distances(): diff --git a/tests/system/small/ml/test_model_selection.py b/tests/system/small/ml/test_model_selection.py index b7764a7d916..ebce6e405a5 100644 --- a/tests/system/small/ml/test_model_selection.py +++ b/tests/system/small/ml/test_model_selection.py @@ -18,9 +18,9 @@ import pandas as pd import pytest +from bigframes.ml import model_selection import bigframes.pandas as bpd import bigframes.session -from bigframes.ml import model_selection @pytest.mark.parametrize( @@ -323,13 +323,7 @@ def test_train_test_split_value_error(penguins_df_default_index, train_size, tes ) def test_train_test_split_stratify(df_fixture, request): df = request.getfixturevalue(df_fixture) - X = df[ - [ - "species", - "island", - "culmen_length_mm", - ] - ].rename( + X = df[["species", "island", "culmen_length_mm",]].rename( columns={"species": "x_species"} ) # Keep "species" col just for easy checking. Rename to avoid conflicts. y = df[["species"]] @@ -417,9 +411,8 @@ def test_KFold_split(df_fixture, n_splits, request): ] y = df["body_mass_g"] - len_test_upper, len_test_lower = ( - math.ceil(len(df) / n_splits), - math.floor(len(df) / n_splits), + len_test_upper, len_test_lower = math.ceil(len(df) / n_splits), math.floor( + len(df) / n_splits ) len_train_upper, len_train_lower = ( len(df) - len_test_lower, @@ -467,9 +460,8 @@ def test_KFold_split_X_only(df_fixture, n_splits, request): ] ] - len_test_upper, len_test_lower = ( - math.ceil(len(df) / n_splits), - math.floor(len(df) / n_splits), + len_test_upper, len_test_lower = math.ceil(len(df) / n_splits), math.floor( + len(df) / n_splits ) len_train_upper, len_train_lower = ( len(df) - len_test_lower, 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 00000000000..48a69f522ce --- /dev/null +++ b/tests/system/small/ml/test_multimodal_llm.py @@ -0,0 +1,110 @@ +# 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", + "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, + ) + + +@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 ec63cc94f23..34be48be1e5 100644 --- a/tests/system/small/ml/test_preprocessing.py +++ b/tests/system/small/ml/test_preprocessing.py @@ -18,7 +18,6 @@ import pyarrow as pa import bigframes.features -import bigframes.pandas as bpd from bigframes.ml import preprocessing from bigframes.testing import utils @@ -63,7 +62,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_normalizes_fit_transform(new_penguins_df): +def test_standard_scaler_normalizeds_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( @@ -115,37 +114,6 @@ 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( @@ -277,7 +245,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.sort_index(), expected.sort_index(), rtol=0.1) + pd.testing.assert_frame_equal(result, expected, rtol=0.1) def test_min_max_scaler_normalized_fit_transform(new_penguins_df): diff --git a/tests/system/small/ml/test_utils.py b/tests/system/small/ml/test_utils.py index ec3bd315b13..b3aa4ed59bc 100644 --- a/tests/system/small/ml/test_utils.py +++ b/tests/system/small/ml/test_utils.py @@ -13,10 +13,10 @@ # limitations under the License. import pandas as pd +import pandas.testing import pytest import bigframes.ml.utils as utils -import bigframes.testing.utils _DATA_FRAME = pd.DataFrame({"column": [1, 2, 3]}) _SERIES = pd.Series([1, 2, 3], name="column") @@ -31,7 +31,7 @@ def test_convert_to_dataframe(session, data): (actual_result,) = utils.batch_convert_to_dataframe(bf_data) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( actual_result.to_pandas(), _DATA_FRAME, check_index_type=False, @@ -46,7 +46,7 @@ def test_convert_to_dataframe(session, data): def test_convert_pandas_to_dataframe(data, session): (actual_result,) = utils.batch_convert_to_dataframe(data, session=session) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( actual_result.to_pandas(), _DATA_FRAME, check_index_type=False, @@ -63,7 +63,7 @@ def test_convert_to_series(session, data): (actual_result,) = utils.batch_convert_to_series(bf_data) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result.to_pandas(), _SERIES, check_index_type=False, check_dtype=False ) @@ -75,6 +75,6 @@ def test_convert_to_series(session, data): def test_convert_pandas_to_series(data, session): (actual_result,) = utils.batch_convert_to_series(data, session=session) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result.to_pandas(), _SERIES, check_index_type=False, check_dtype=False ) diff --git a/tests/system/small/operations/test_ai.py b/tests/system/small/operations/test_ai.py new file mode 100644 index 00000000000..d6ec3cacadc --- /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 index 3554322462b..e183bbfe431 100644 --- a/tests/system/small/operations/test_dates.py +++ b/tests/system/small/operations/test_dates.py @@ -16,10 +16,8 @@ import datetime import pandas as pd -import pytest -from packaging import version +import pandas.testing -import bigframes.testing.utils from bigframes import dtypes @@ -35,7 +33,7 @@ def test_date_diff_between_series(session): 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) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -47,7 +45,7 @@ def test_date_diff_literal_sub_series(scalars_dfs): actual_result = (literal - bf_df["date_col"]).to_pandas() expected_result = (literal - pd_df["date_col"]).astype(dtypes.TIMEDELTA_DTYPE) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -59,7 +57,7 @@ def test_date_diff_series_sub_literal(scalars_dfs): actual_result = (bf_df["date_col"] - literal).to_pandas() expected_result = (pd_df["date_col"] - literal).astype(dtypes.TIMEDELTA_DTYPE) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -70,22 +68,6 @@ def test_date_series_diff_agg(scalars_dfs): actual_result = bf_df["date_col"].diff().to_pandas() expected_result = pd_df["date_col"].diff().astype(dtypes.TIMEDELTA_DTYPE) - bigframes.testing.utils.assert_series_equal( + 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") - ) - - bigframes.testing.utils.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 ebfe0414de0..1462a68b492 100644 --- a/tests/system/small/operations/test_datetimes.py +++ b/tests/system/small/operations/test_datetimes.py @@ -16,13 +16,14 @@ import typing import numpy +from packaging import version +from pandas import testing import pandas as pd import pytest -from packaging import version import bigframes.pandas as bpd import bigframes.series -from bigframes.testing.utils import assert_frame_equal, assert_series_equal +from bigframes.testing.utils import assert_series_equal DATETIME_COL_NAMES = [("datetime_col",), ("timestamp_col",)] DATE_COLUMNS = [ @@ -107,21 +108,6 @@ def test_dt_day_of_week(scalars_dfs, col_name): 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, @@ -137,21 +123,6 @@ def test_dt_dayofyear(scalars_dfs, col_name): 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, @@ -303,7 +274,7 @@ def test_dt_isocalendar(session): actual_result = bf_s.dt.isocalendar().to_pandas() expected_result = pd_s.dt.isocalendar() - assert_frame_equal( + testing.assert_frame_equal( actual_result, expected_result, check_dtype=False, check_index_type=False ) @@ -323,41 +294,6 @@ def test_dt_tz(scalars_dfs, col_name): assert bf_result == pd_result -@pytest.mark.parametrize( - ("col_name", "tz"), - [ - ("datetime_col", None), - ("timestamp_col", None), - ("datetime_col", "UTC"), - ], -) -def test_dt_tz_localize(scalars_dfs, col_name, tz): - pytest.importorskip("pandas", minversion="2.0.0") - scalars_df, scalars_pandas_df = scalars_dfs - bf_series = scalars_df[col_name] - - bf_result = bf_series.dt.tz_localize(tz) - pd_result = scalars_pandas_df[col_name].dt.tz_localize(tz) - - assert_series_equal(bf_result.to_pandas(), pd_result, check_index_type=False) - - -def test_dt_tz_localize_already_localized(scalars_dfs): - pytest.importorskip("pandas", minversion="2.0.0") - scalars_df, _ = scalars_dfs - - with pytest.raises(TypeError): - scalars_df["timestamp_col"].dt.tz_localize("UTC") - - -def test_dt_tz_localize_invalid_timezone(scalars_dfs): - pytest.importorskip("pandas", minversion="2.0.0") - scalars_df, _ = scalars_dfs - - with pytest.raises(ValueError): - scalars_df["datetime_col"].dt.tz_localize("US/Eastern") - - @pytest.mark.parametrize( ("col_name",), DATETIME_COL_NAMES, @@ -387,7 +323,7 @@ def test_dt_strftime(scalars_df_index, scalars_pandas_df_index, column, date_for 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) - assert_series_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) assert bf_result.dtype == "string[pyarrow]" @@ -399,7 +335,7 @@ def test_dt_strftime_date(): expected_result = pd.Series(["08/15/2014", "08/15/2215", "02/29/2016"]) bf_result = bf_series.dt.strftime("%m/%d/%Y").to_pandas() - assert_series_equal( + pd.testing.assert_series_equal( bf_result, expected_result, check_index_type=False, check_dtype=False ) assert bf_result.dtype == "string[pyarrow]" @@ -415,7 +351,7 @@ def test_dt_strftime_time(): ) bf_result = bf_series.dt.strftime("%X").to_pandas() - assert_series_equal( + pd.testing.assert_series_equal( bf_result, expected_result, check_index_type=False, check_dtype=False ) assert bf_result.dtype == "string[pyarrow]" @@ -555,7 +491,7 @@ def test_timestamp_diff_two_dataframes(scalars_dfs): actual_result = (bf_df - bf_df).to_pandas() expected_result = pd_df - pd_df - assert_frame_equal(actual_result, expected_result) + testing.assert_frame_equal(actual_result, expected_result) def test_timestamp_diff_two_series_with_different_types_raise_error(scalars_dfs): @@ -577,12 +513,9 @@ def test_timestamp_diff_series_sub_literal(scalars_dfs, column, value): bf_series = bf_df[column] pd_series = pd_df[column] - # Pandas doesn't handle nulls properly here so we ffill - # overflows for no good reason - # related? https://github.com/apache/arrow/issues/43031 - actual_result = (bf_series.ffill() - value).to_pandas() + actual_result = (bf_series - value).to_pandas() - expected_result = pd_series.ffill() - value + expected_result = pd_series - value assert_series_equal(actual_result, expected_result) @@ -598,12 +531,9 @@ def test_timestamp_diff_literal_sub_series(scalars_dfs, column, value): bf_series = bf_df[column] pd_series = pd_df[column] - # Pandas doesn't handle nulls properly here so we ffill - # overflows for no good reason - # related? https://github.com/apache/arrow/issues/43031 - actual_result = (value - bf_series.ffill()).to_pandas() + actual_result = (value - bf_series).to_pandas() - expected_result = value - pd_series.ffill() + expected_result = value - pd_series assert_series_equal(actual_result, expected_result) @@ -615,12 +545,7 @@ def test_timestamp_series_diff_agg(scalars_dfs, column): actual_result = bf_series.diff().to_pandas() - # overflows for no good reason - # related? https://github.com/apache/arrow/issues/43031 - expected_result = pd_series.ffill().diff() - expected_result = expected_result.mask( - pd_series.isnull() | pd_series.shift(1).isnull() - ) + expected_result = pd_series.diff() assert_series_equal(actual_result, expected_result) @@ -675,6 +600,6 @@ def test_to_datetime(scalars_dfs, col): ).to_pandas() expected_result = pd.Series(pd.to_datetime(pd_df[col])) - assert_series_equal( + 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 16a68025721..fda01a5dae1 100644 --- a/tests/system/small/operations/test_lists.py +++ b/tests/system/small/operations/test_lists.py @@ -106,33 +106,3 @@ 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 e579c90b4df..c2f3ba423f6 100644 --- a/tests/system/small/operations/test_plotting.py +++ b/tests/system/small/operations/test_plotting.py @@ -12,11 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. +from matplotlib.collections import PathCollection import numpy as np import pandas as pd import pandas._testing as tm import pytest -from matplotlib.collections import PathCollection import bigframes.operations._matplotlib.core as bf_mpl import bigframes.pandas as bpd @@ -264,42 +264,6 @@ 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 00000000000..8b520d8c035 --- /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 94285cc7dc4..a7206148924 100644 --- a/tests/system/small/operations/test_strings.py +++ b/tests/system/small/operations/test_strings.py @@ -78,6 +78,8 @@ 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, @@ -234,20 +236,7 @@ def test_reverse(scalars_dfs): @pytest.mark.parametrize( - ["start", "stop"], - [ - (0, 1), - (3, 5), - (100, 101), - (None, 1), - (0, 12), - (0, None), - (None, -1), - (-1, None), - (-5, -1), - (1, -1), - (-10, 10), - ], + ["start", "stop"], [(0, 1), (3, 5), (100, 101), (None, 1), (0, 12), (0, None)] ) def test_slice(scalars_dfs, start, stop): scalars_df, scalars_pandas_df = scalars_dfs @@ -286,7 +275,7 @@ def test_strip(scalars_dfs): ], ) def test_strip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) pd_s = s.to_pandas() bf_result = s.str.strip(to_strip=to_strip).to_pandas() @@ -317,7 +306,7 @@ def test_isnumeric(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -329,7 +318,7 @@ def test_isalpha(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -343,7 +332,7 @@ def test_isdigit(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -355,7 +344,7 @@ def test_isdecimal(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -367,7 +356,7 @@ def test_isalnum(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -379,7 +368,7 @@ def test_isspace(weird_strings, weird_strings_pd): pd.testing.assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -391,7 +380,7 @@ def test_islower(weird_strings, weird_strings_pd): assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -403,7 +392,7 @@ def test_isupper(weird_strings, weird_strings_pd): assert_series_equal( bf_result, - pd_result.astype(pd.BooleanDtype()), + pd_result.astype(pd.BooleanDtype()) # the dtype here is a case of intentional diversion from pandas # see go/bigframes-dtypes ) @@ -432,7 +421,7 @@ def test_rstrip(scalars_dfs): ], ) def test_rstrip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) pd_s = s.to_pandas() bf_result = s.str.rstrip(to_strip=to_strip).to_pandas() @@ -467,7 +456,7 @@ def test_lstrip(scalars_dfs): ], ) def test_lstrip_w_to_strip(to_strip): - s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", pd.NA]) + s = bpd.Series(["1. Ant. ", "2. Bee!\n", "3. Cat?\t", bpd.NA]) pd_s = s.to_pandas() bf_result = s.str.lstrip(to_strip=to_strip).to_pandas() @@ -747,14 +736,3 @@ 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 9512950e168..18c88db8eb5 100644 --- a/tests/system/small/operations/test_timedeltas.py +++ b/tests/system/small/operations/test_timedeltas.py @@ -17,67 +17,51 @@ import operator import numpy as np +from packaging import version import pandas as pd +import pandas.testing import pyarrow as pa import pytest -from packaging import version -import bigframes.testing.utils from bigframes import dtypes -# Some methods/features used by this test don't exist in pandas 1.x -pytest.importorskip("pandas", minversion="2.0.0") - @pytest.fixture(scope="module") def temporal_dfs(session): pandas_df = pd.DataFrame( { - "datetime_col": pd.Series( - [ - pd.Timestamp("2025-02-01 01:00:01"), - pd.Timestamp("2019-01-02 02:00:00"), - pd.Timestamp("1997-01-01 19:00:00"), - ], - dtype=dtypes.DATETIME_DTYPE, - ), - "timestamp_col": pd.Series( - [ - pd.Timestamp("2023-01-01 01:00:01", tz="UTC"), - pd.Timestamp("2024-01-02 02:00:00", tz="UTC"), - pd.Timestamp("2005-03-05 02:00:00", tz="UTC"), - ], - dtype=dtypes.TIMESTAMP_DTYPE, - ), + "datetime_col": [ + pd.Timestamp("2025-02-01 01:00:01"), + pd.Timestamp("2019-01-02 02:00:00"), + pd.Timestamp("1997-01-01 19:00:00"), + ], + "timestamp_col": [ + pd.Timestamp("2023-01-01 01:00:01", tz="UTC"), + 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=dtypes.DATE_DTYPE, - ), - "timedelta_col_1": pd.Series( - [ - pd.Timedelta(5, "s"), - pd.Timedelta(-4, "m"), - pd.Timedelta(5, "h"), - ], - dtype=dtypes.TIMEDELTA_DTYPE, - ), - "timedelta_col_2": pd.Series( - [ - pd.Timedelta(3, "s"), - pd.Timedelta(-4, "m"), - pd.Timedelta(6, "h"), - ], - dtype=dtypes.TIMEDELTA_DTYPE, + dtype=pd.ArrowDtype(pa.date32()), ), - "float_col": pd.Series([1.5, 2, -3], dtype=dtypes.FLOAT_DTYPE), - "int_col": pd.Series([1, 2, -3], dtype="Int64"), - "positive_int_col": pd.Series([1, 2, 3], dtype="Int64"), - }, - index=pd.Index(range(3), dtype="Int64"), + "timedelta_col_1": [ + pd.Timedelta(5, "s"), + pd.Timedelta(-4, "m"), + pd.Timedelta(5, "h"), + ], + "timedelta_col_2": [ + pd.Timedelta(3, "s"), + pd.Timedelta(-4, "m"), + pd.Timedelta(6, "h"), + ], + "float_col": [1.5, 2, -3], + "int_col": [1, 2, -3], + "positive_int_col": [1, 2, 3], + } ) bigframes_df = session.read_pandas(pandas_df) @@ -86,106 +70,89 @@ def temporal_dfs(session): def _assert_series_equal(actual: pd.Series, expected: pd.Series): - """Helper function specifically for timedelta testing. Don't use it outside of this module.""" - bigframes.testing.utils.assert_series_equal( - actual, - expected, - check_index_type=False, - check_dtype=False, - ) + """Helper function specifically for timedelta testsing. Don't use it outside of this module.""" + if actual.dtype == dtypes.FLOAT_DTYPE: + pandas.testing.assert_series_equal( + actual, expected.astype("Float64"), check_index_type=False + ) + elif actual.dtype == dtypes.INT_DTYPE: + pandas.testing.assert_series_equal( + actual, expected.astype("Int64"), check_index_type=False + ) + else: + pandas.testing.assert_series_equal( + actual.astype("timedelta64[ns]"), + expected.dt.floor("us"), # in BF the precision is microsecond + check_index_type=False, + ) @pytest.mark.parametrize( - ("op", "col_1", "col_2", "arrow_supported"), + ("op", "col_1", "col_2"), [ - (operator.add, "timedelta_col_1", "timedelta_col_2", True), - (operator.sub, "timedelta_col_1", "timedelta_col_2", True), - (operator.truediv, "timedelta_col_1", "timedelta_col_2", True), - (operator.floordiv, "timedelta_col_1", "timedelta_col_2", True), - (operator.truediv, "timedelta_col_1", "float_col", False), - (operator.floordiv, "timedelta_col_1", "float_col", False), - (operator.mul, "timedelta_col_1", "float_col", False), - (operator.mul, "float_col", "timedelta_col_1", False), - (operator.mod, "timedelta_col_1", "timedelta_col_2", False), + (operator.add, "timedelta_col_1", "timedelta_col_2"), + (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", "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, arrow_supported -): +def test_timedelta_binary_ops_between_series(temporal_dfs, op, col_1, col_2): bf_df, pd_df = temporal_dfs actual_result = op(bf_df[col_1], bf_df[col_2]).to_pandas() - if not arrow_supported: - expected_result = pd_df.apply(lambda x: op(x[col_1], x[col_2]), axis=1) - else: - expected_result = op(pd_df[col_1], pd_df[col_2]) + expected_result = op(pd_df[col_1], pd_df[col_2]) _assert_series_equal(actual_result, expected_result) @pytest.mark.parametrize( - ("op", "col", "literal", "arrow_supported"), + ("op", "col", "literal"), [ - (operator.add, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.sub, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.truediv, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - ( - operator.floordiv, - "timedelta_col_1", - pd.Timedelta(2, "s").as_unit("us"), - False, - ), - (operator.truediv, "timedelta_col_1", 3, True), - (operator.floordiv, "timedelta_col_1", 3, False), - (operator.mul, "timedelta_col_1", 3, True), - (operator.mul, "float_col", pd.Timedelta(1, "s").as_unit("us"), True), - (operator.mod, "timedelta_col_1", pd.Timedelta(7, "s").as_unit("us"), False), + (operator.add, "timedelta_col_1", pd.Timedelta(2, "s")), + (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, "timedelta_col_1", 3), + (operator.floordiv, "timedelta_col_1", 3), + (operator.mul, "timedelta_col_1", 3), + (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, arrow_supported -): +def test_timedelta_binary_ops_series_and_literal(temporal_dfs, op, col, literal): bf_df, pd_df = temporal_dfs actual_result = op(bf_df[col], literal).to_pandas() - if not arrow_supported: - expected_result = pd_df[col].map(lambda x: op(x, literal)) - else: - expected_result = op(pd_df[col], literal) + expected_result = op(pd_df[col], literal) _assert_series_equal(actual_result, expected_result) @pytest.mark.parametrize( - ("op", "col", "literal", "arrow_supported"), + ("op", "col", "literal"), [ - (operator.add, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.sub, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.truediv, "timedelta_col_1", pd.Timedelta(2, "s").as_unit("us"), True), - ( - operator.floordiv, - "timedelta_col_1", - pd.Timedelta(2, "s").as_unit("us"), - True, - ), - (operator.truediv, "float_col", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.floordiv, "float_col", pd.Timedelta(2, "s").as_unit("us"), True), - (operator.mul, "timedelta_col_1", 3, True), - (operator.mul, "float_col", pd.Timedelta(1, "s").as_unit("us"), False), - (operator.mod, "timedelta_col_1", pd.Timedelta(7, "s").as_unit("us"), False), + (operator.add, "timedelta_col_1", pd.Timedelta(2, "s")), + (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, "float_col", pd.Timedelta(2, "s")), + (operator.floordiv, "float_col", pd.Timedelta(2, "s")), + (operator.mul, "timedelta_col_1", 3), + (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, arrow_supported -): +def test_timedelta_binary_ops_literal_and_series(temporal_dfs, op, col, literal): bf_df, pd_df = temporal_dfs actual_result = op(literal, bf_df[col]).to_pandas() - if not arrow_supported: - expected_result = pd_df[col].map(lambda x: op(literal, x)) - else: - expected_result = op(literal, pd_df[col]) + expected_result = op(literal, pd_df[col]) _assert_series_equal(actual_result, expected_result) @@ -209,10 +176,12 @@ def test_timedelta_unary_ops(temporal_dfs, op): def test_timestamp_add__ts_series_plus_td_series(temporal_dfs, column, pd_dtype): bf_df, pd_df = temporal_dfs - actual_result = (bf_df[column] + bf_df["timedelta_col_1"]).to_pandas() + actual_result = ( + (bf_df[column] + bf_df["timedelta_col_1"]).to_pandas().astype(pd_dtype) + ) expected_result = pd_df[column] + pd_df["timedelta_col_1"] - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -230,7 +199,7 @@ def test_timestamp_add__ts_series_plus_td_series__explicit_cast(temporal_dfs, co @pytest.mark.parametrize( "literal", [ - pytest.param(pd.Timedelta(1, unit="s").as_unit("us"), id="pandas"), + pytest.param(pd.Timedelta(1, unit="s"), id="pandas"), pytest.param(datetime.timedelta(seconds=1), id="python-datetime"), pytest.param(np.timedelta64(1, "s"), id="numpy"), ], @@ -238,10 +207,12 @@ def test_timestamp_add__ts_series_plus_td_series__explicit_cast(temporal_dfs, co def test_timestamp_add__ts_series_plus_td_literal(temporal_dfs, literal): bf_df, pd_df = temporal_dfs - actual_result = (bf_df["timestamp_col"] + literal).to_pandas() + actual_result = ( + (bf_df["timestamp_col"] + literal).to_pandas().astype("datetime64[ns, UTC]") + ) expected_result = pd_df["timestamp_col"] + literal - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -256,22 +227,24 @@ def test_timestamp_add__ts_series_plus_td_literal(temporal_dfs, literal): def test_timestamp_add__td_series_plus_ts_series(temporal_dfs, column, pd_dtype): bf_df, pd_df = temporal_dfs - actual_result = (bf_df["timedelta_col_1"] + bf_df[column]).to_pandas() + actual_result = ( + (bf_df["timedelta_col_1"] + bf_df[column]).to_pandas().astype(pd_dtype) + ) expected_result = pd_df["timedelta_col_1"] + pd_df[column] - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) def test_timestamp_add__td_literal_plus_ts_series(temporal_dfs): bf_df, pd_df = temporal_dfs - timedelta = pd.Timedelta(1, unit="s").as_unit("us") + timedelta = pd.Timedelta(1, unit="s") - actual_result = (timedelta + bf_df["datetime_col"]).to_pandas() + actual_result = (timedelta + bf_df["datetime_col"]).to_pandas().astype(" pd.Timedelta(1, "h")) - ].to_pandas() + actual_result = ( + bf_series[((bf_series - timestamp) > pd.Timedelta(1, "h"))] + .to_pandas() + .astype(" pd.Timedelta(1, "h")] - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -561,30 +540,29 @@ def test_timedelta_filtering(session): def test_timedelta_ordering(session): pd_df = pd.DataFrame( { - "col_1": pd.Series( - [ - pd.Timestamp("2025-01-01 01:00:00"), - pd.Timestamp("2025-01-01 02:00:00"), - pd.Timestamp("2025-01-01 03:00:00"), - ], - dtype=dtypes.TIMESTAMP_DTYPE, - ), - "col_2": pd.Series( - [ - pd.Timestamp("2025-01-01 01:00:02"), - pd.Timestamp("2025-01-01 02:00:01"), - pd.Timestamp("2025-01-01 02:59:59"), - ], - dtype=dtypes.TIMESTAMP_DTYPE, - ), + "col_1": [ + pd.Timestamp("2025-01-01 01:00:00"), + pd.Timestamp("2025-01-01 02:00:00"), + pd.Timestamp("2025-01-01 03:00:00"), + ], + "col_2": [ + pd.Timestamp("2025-01-01 01:00:02"), + pd.Timestamp("2025-01-01 02:00:01"), + pd.Timestamp("2025-01-01 02:59:59"), + ], } ) bf_df = session.read_pandas(pd_df) - actual_result = (bf_df["col_2"] - bf_df["col_1"]).sort_values().to_pandas() + actual_result = ( + (bf_df["col_2"] - bf_df["col_1"]) + .sort_values() + .to_pandas() + .astype("timedelta64[ns]") + ) expected_result = (pd_df["col_2"] - pd_df["col_1"]).sort_values() - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -615,7 +593,7 @@ def test_timedelta_agg__timedelta_result(temporal_dfs, agg_func): actual_result = agg_func(bf_df["timedelta_col_1"]) - expected_result = agg_func(pd_df["timedelta_col_1"]) + expected_result = agg_func(pd_df["timedelta_col_1"]).floor("us") assert actual_result == expected_result @@ -651,6 +629,6 @@ def test_timestamp_diff_after_type_casting(temporal_dfs): expected_result = pd_df["timestamp_col"] - pd_df["positive_int_col"].astype( "datetime64[us, UTC]" ) - bigframes.testing.utils.assert_series_equal( + 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 index beb7a1968fc..5971e47997b 100644 --- a/tests/system/small/pandas/test_describe.py +++ b/tests/system/small/pandas/test_describe.py @@ -15,8 +15,6 @@ import pandas.testing import pytest -import bigframes.pandas as bpd - def test_df_describe_non_temporal(scalars_dfs): # TODO: supply a reason why this isn't compatible with pandas 1.x @@ -232,184 +230,3 @@ def test_series_describe_temporal(scalars_dfs): 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, - ) - - -def test_describe_json_and_obj_ref_returns_count(session): - # Test describe() works on JSON and OBJ_REF types (without nunique, which fails) - import uuid - - import google.cloud.bigquery - - sql = """ - SELECT - PARSE_JSON('{"a": 1}') AS json_col, - 'gs://cloud-samples-data/vision/ocr/sign.jpg' AS uri_col - """ - df_init = session.read_gbq(sql) - - table_id = f"bigframes-dev.bigframes_tests_sys.tmp_obj_ref_{uuid.uuid4().hex}" - df_init.to_gbq(table_id, if_exists="replace") - - client = session.bqclient - table = client.get_table(table_id) - schema = list(table.schema) - for i, field in enumerate(schema): - if field.name == "uri_col": - schema[i] = google.cloud.bigquery.SchemaField( - name=field.name, - field_type=field.field_type, - mode=field.mode, - description="bigframes_dtype: OBJ_REF_DTYPE", - ) - break - table.schema = schema - client.update_table(table, ["schema"]) - - df = session.read_gbq(table_id) - df = df.rename(columns={"uri_col": "obj_ref_col"}) - - res = df.describe(include="all").to_pandas() - - assert "count" in res.index - assert res.loc["count", "json_col"] == 1.0 - assert res.loc["count", "obj_ref_col"] == 1.0 - - -def test_describe_with_unsupported_type_returns_empty_dataframe(session): - df = session.read_gbq("SELECT ST_GEOGPOINT(1.0, 2.0) AS geo_col") - - res = df.describe().to_pandas() - - assert len(res.columns) == 0 - assert len(res.index) == 1 - - -def test_describe_empty_dataframe_returns_empty_dataframe(session): - df = bpd.DataFrame() - - res = df.describe().to_pandas() - - assert len(res.columns) == 0 - assert len(res.index) == 1 diff --git a/tests/system/small/pandas/test_read_gbq_information_schema.py b/tests/system/small/pandas/test_read_gbq_information_schema.py deleted file mode 100644 index 32e2dc4712e..00000000000 --- a/tests/system/small/pandas/test_read_gbq_information_schema.py +++ /dev/null @@ -1,50 +0,0 @@ -# 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 d3486810f7c..1c3b6e4fe3c 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 bigframes.testing.utils import assert_frame_equal +from bigframes.testing.utils import assert_pandas_df_equal @pytest.mark.parametrize( @@ -67,4 +67,4 @@ def test_merge_after_filter(baseball_schedules_df, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) diff --git a/tests/system/small/session/test_read_gbq_colab.py b/tests/system/small/session/test_read_gbq_colab.py index 6ba9c760847..9ace2dbed7a 100644 --- a/tests/system/small/session/test_read_gbq_colab.py +++ b/tests/system/small/session/test_read_gbq_colab.py @@ -48,9 +48,6 @@ def test_read_gbq_colab_to_pandas_batches_preserves_order_by(maybe_ordered_sessi 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 @@ -89,20 +86,11 @@ def test_read_gbq_colab_fresh_session_is_hybrid(): assert len(result) == 100 assert session._executor._enable_polars_execution is True # type: ignore - assert executions_before_python == 1 - assert executions_after == 2 - history = session.execution_history().to_dataframe() - assert history.iloc[-1]["job_type"] == "polars" + assert executions_after == executions_before_python == 1 def test_read_gbq_colab_peek_avoids_requery(maybe_ordered_session): - history_before = maybe_ordered_session.execution_history().to_dataframe() - queries_before = ( - len(history_before[history_before["job_type"] == "query"]) - if "job_type" in history_before.columns - else 0 - ) - + executions_before_sql = maybe_ordered_session._metrics.execution_count df = maybe_ordered_session._read_gbq_colab( """ SELECT @@ -116,36 +104,20 @@ def test_read_gbq_colab_peek_avoids_requery(maybe_ordered_session): LIMIT 300 """ ) - - history_after_read = maybe_ordered_session.execution_history().to_dataframe() - queries_after_read = len( - history_after_read[history_after_read["job_type"] == "query"] - ) - + executions_before_python = maybe_ordered_session._metrics.execution_count result = df.peek(100) - - history_after_peek = maybe_ordered_session.execution_history().to_dataframe() - queries_after_peek = len( - history_after_peek[history_after_peek["job_type"] == "query"] - ) + 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 queries_after_read == queries_before + 1 - assert queries_after_peek == queries_after_read + assert executions_after == executions_before_python == executions_before_sql + 1 def test_read_gbq_colab_repr_avoids_requery(maybe_ordered_session): - history_before = maybe_ordered_session.execution_history().to_dataframe() - queries_before = ( - len(history_before[history_before["job_type"] == "query"]) - if "job_type" in history_before.columns - else 0 - ) - + executions_before_sql = maybe_ordered_session._metrics.execution_count df = maybe_ordered_session._read_gbq_colab( """ SELECT @@ -159,27 +131,16 @@ def test_read_gbq_colab_repr_avoids_requery(maybe_ordered_session): LIMIT 300 """ ) - - history_after_read = maybe_ordered_session.execution_history().to_dataframe() - queries_after_read = len( - history_after_read[history_after_read["job_type"] == "query"] - ) - + executions_before_python = maybe_ordered_session._metrics.execution_count _ = repr(df) - - history_after_repr = maybe_ordered_session.execution_history().to_dataframe() - queries_after_repr = len( - history_after_repr[history_after_repr["job_type"] == "query"] - ) - - assert queries_after_read == queries_before + 1 - assert queries_after_repr == queries_after_read + 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.", + "some_string": "This could be dangerous, but we escape it", # This is not a supported type, but ignored if not referenced. "some_object": object(), } @@ -189,7 +150,7 @@ def test_read_gbq_colab_includes_formatted_scalars(session): df = session._read_gbq_colab( """ SELECT {some_integer} as some_integer, - '{some_string}' as some_string, + {some_string} as some_string, '{{escaped}}' as escaped """, pyformat_args=pyformat_args, @@ -201,7 +162,7 @@ def test_read_gbq_colab_includes_formatted_scalars(session): { "some_integer": pandas.Series([123], dtype=pandas.Int64Dtype()), "some_string": pandas.Series( - ["This could be dangerous."], + ["This could be dangerous, but we escape it"], dtype="string[pyarrow]", ), "escaped": pandas.Series(["{escaped}"], dtype="string[pyarrow]"), diff --git a/tests/system/small/session/test_read_gbq_query.py b/tests/system/small/session/test_read_gbq_query.py deleted file mode 100644 index bb9026dc705..00000000000 --- a/tests/system/small/session/test_read_gbq_query.py +++ /dev/null @@ -1,113 +0,0 @@ -# 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/session/test_session_logging.py b/tests/system/small/session/test_session_logging.py deleted file mode 100644 index 4618e110687..00000000000 --- a/tests/system/small/session/test_session_logging.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 bigframes.session._io.bigquery as bq_io -from bigframes.core.logging import data_types - - -def test_data_type_logging(scalars_df_index): - s = scalars_df_index["int64_col"] + 1.5 - - # We want to check the job_config passed to _query_and_wait_bigframes - with mock.patch( - "bigframes.session._io.bigquery.start_query_job_optional", - wraps=bq_io.start_query_job_optional, - ) as mock_query: - s.to_pandas() - - # Fetch job labels sent to the BQ client and verify their values - assert mock_query.called - call_args = mock_query.call_args - job_config = call_args.kwargs.get("job_config") - assert job_config is not None - job_labels = job_config.labels - assert "bigframes-dtypes" in job_labels - assert job_labels["bigframes-dtypes"] == data_types.encode_type_refs( - s._block._expr.node - ) diff --git a/tests/system/small/test_anywidget.py b/tests/system/small/test_anywidget.py index 70106f490b6..8a91176dd9e 100644 --- a/tests/system/small/test_anywidget.py +++ b/tests/system/small/test_anywidget.py @@ -12,18 +12,10 @@ # 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") @@ -38,16 +30,19 @@ 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], + "id": [0, 1, 2, 3, 4, 5], "page_indicator": [ - "page_3_row_2", - "page_3_row_1", - "page_2_row_2", - "page_2_row_1", - "page_1_row_2", + # Page 1 (rows 1-2) "page_1_row_1", + "page_1_row_2", + # Page 2 (rows 3-4) + "page_2_row_1", + "page_2_row_2", + # Page 3 (rows 5-6) + "page_3_row_1", + "page_3_row_2", ], - "value": [5, 4, 3, 2, 1, 0], + "value": [0, 1, 2, 3, 4, 5], } ) return test_data @@ -56,24 +51,21 @@ def paginated_pandas_df() -> pd.DataFrame: @pytest.fixture(scope="module") def paginated_bf_df( session: bf.Session, paginated_pandas_df: pd.DataFrame -) -> bigframes.dataframe.DataFrame: +) -> bf.dataframe.DataFrame: return session.read_pandas(paginated_pandas_df) @pytest.fixture -def table_widget(paginated_bf_df: bigframes.dataframe.DataFrame): +def table_widget(paginated_bf_df: bf.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 import display - from bigframes.display import TableWidget - - with bigframes.option_context( - "display.render_mode", "anywidget", "display.max_rows", 2 - ): + with bf.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) + yield display.TableWidget(paginated_bf_df) @pytest.fixture(scope="module") @@ -98,39 +90,10 @@ def small_bf_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.render_mode", "anywidget", "display.max_rows", 5): - yield TableWidget(small_bf_df) + from bigframes import display - -@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.render_mode", "anywidget", "display.max_rows", 2 - ): - widget = TableWidget(bf_df) - yield widget + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 5): + yield display.TableWidget(small_bf_df) @pytest.fixture(scope="module") @@ -146,38 +109,6 @@ def empty_bf_df( 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, sample_rate=None) -> 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, @@ -203,25 +134,19 @@ def _assert_html_matches_pandas_slice( 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 + from bigframes import display - with bigframes.option_context( - "display.render_mode", "anywidget", "display.max_rows", 2 - ): - widget = TableWidget(paginated_bf_df) + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 2): + widget = display.TableWidget(paginated_bf_df) assert widget.row_count == EXPECTED_ROW_COUNT -def test_widget_initialization_should_default_to_page_zero( +def test_widget_initialization_should_set_default_pagination( table_widget, ): - """ - Given a new TableWidget, when it is initialized, - then its page number should default to 0. - """ + """A TableWidget should initialize with page 0 and the correct page size.""" # The `table_widget` fixture already creates the widget. # Assert its state. assert table_widget.page == 0 @@ -270,31 +195,35 @@ def test_widget_navigation_should_display_correct_page( _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_setting_negative_page_should_raise_error( - table_widget, +def test_widget_navigation_should_clamp_to_zero_for_negative_input( + table_widget, paginated_pandas_df: pd.DataFrame ): """ Given a widget, when a negative page number is set, - then a ValueError should be raised. + then the page number should be clamped to 0 and display the first page. """ - with pytest.raises(ValueError, match="Page number cannot be negative."): - table_widget.page = -1 + expected_slice = paginated_pandas_df.iloc[0:2] + + table_widget.page = -1 + html = table_widget.table_html + + assert table_widget.page == 0 + _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) -def test_setting_page_beyond_max_should_clamp_to_last_page( +def test_widget_navigation_should_clamp_to_last_page_for_out_of_bounds_input( table_widget, paginated_pandas_df: pd.DataFrame ): """ - Given a widget, - when a page number greater than the max is set, + 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 + expected_slice = paginated_pandas_df.iloc[4:6] - table_widget.page = 100 # Set page far beyond the total of 3 pages + table_widget.page = 100 html = table_widget.table_html - assert table_widget.page == 2 # Page is clamped to the last valid page (0-indexed) + assert table_widget.page == 2 _assert_html_matches_pandas_slice(html, expected_slice, paginated_pandas_df) @@ -316,10 +245,10 @@ def test_widget_pagination_should_work_with_custom_page_size( start_row: int, end_row: int, ): - """Test that a widget paginates correctly with a custom page size.""" - with bigframes.option_context( - "display.render_mode", "anywidget", "display.max_rows", 3 - ): + """ + A widget should paginate correctly with a custom page size of 3. + """ + with bf.option_context("display.repr_mode", "anywidget", "display.max_rows", 3): from bigframes.display import TableWidget widget = TableWidget(paginated_bf_df) @@ -344,83 +273,65 @@ def test_widget_with_few_rows_should_display_all_rows(small_widget, small_pandas _assert_html_matches_pandas_slice(html, small_pandas_df, small_pandas_df) -def test_navigation_beyond_last_page_should_be_clamped(small_widget): +def test_widget_with_few_rows_should_have_only_one_page(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). + Given a DataFrame smaller than the page size, the widget should + clamp page navigation, effectively having only one page. """ - # 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. + # Attempt to navigate past the end small_widget.page = 1 + + # Should be clamped back to the only valid page assert small_widget.page == 0 -def test_global_options_change_should_not_affect_existing_widget_page_size( +def test_widget_page_size_should_be_immutable_after_creation( 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. + A widget's page size should be fixed on creation and not be affected + by subsequent changes to global options. """ - with bigframes.option_context( - "display.render_mode", "anywidget", "display.max_rows", 2 - ): + with bf.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_size == 2 + + # Navigate to second page to ensure widget is in a non-default state + widget.page = 1 assert widget.page == 1 - bf.options.display.max_rows = 10 # Change global setting + # Change global max_rows - widget should not be affected + bf.options.display.max_rows = 10 - assert widget.page_size == initial_page_size # Should remain unchanged - assert widget.page == 1 # Page should not be reset + assert widget.page_size == 2 # Should remain unchanged + assert widget.page == 1 # Should remain on same page -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.render_mode", "anywidget"): +def test_empty_widget_should_have_zero_row_count(empty_bf_df: bf.dataframe.DataFrame): + """Given an empty DataFrame, the widget's row count should be 0.""" + with bf.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) - assert widget.row_count == 0 + 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.render_mode", "anywidget"): +def test_empty_widget_should_render_table_headers(empty_bf_df: bf.dataframe.DataFrame): + """Given an empty DataFrame, the widget should still render table headers.""" + with bf.option_context("display.repr_mode", "anywidget"): from bigframes.display import TableWidget widget = TableWidget(empty_bf_df) + html = widget.table_html + assert "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.render_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.render_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.render_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.render_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 +# TODO(shuowei): Add tests for custom index and 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.render_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.render_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 index 99d2dfece3b..7aad19bd8fd 100644 --- a/tests/system/small/test_bq_sessions.py +++ b/tests/system/small/test_bq_sessions.py @@ -12,15 +12,15 @@ # See the License for the specific language governing permissions and # limitations under the License. -import time from concurrent.futures import ThreadPoolExecutor +import time import google import google.api_core.exceptions -import pytest +import google.cloud from google.cloud import bigquery +import pytest -import bigframes.core.events from bigframes.session import bigquery_session TEST_SCHEMA = [ @@ -39,14 +39,12 @@ def session_resource_manager( bigquery_client, ) -> bigquery_session.SessionResourceManager: - return bigquery_session.SessionResourceManager( - bigquery_client, "US", publisher=bigframes.core.events.Publisher() - ) + return bigquery_session.SessionResourceManager(bigquery_client, "US") 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() + bigquery_client, "US" ) cluster_cols = ["string field", "bool field"] @@ -70,7 +68,7 @@ def test_bq_session_create_temp_table_clustered(bigquery_client: bigquery.Client 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() + bigquery_client, "US" ) def create_table(): diff --git a/tests/system/small/test_dataframe.py b/tests/system/small/test_dataframe.py index a109c33ffa6..c7f96275312 100644 --- a/tests/system/small/test_dataframe.py +++ b/tests/system/small/test_dataframe.py @@ -33,10 +33,9 @@ import bigframes.dtypes as dtypes import bigframes.pandas as bpd import bigframes.series as series -import bigframes.testing from bigframes.testing.utils import ( assert_dfs_equivalent, - assert_frame_equal, + assert_pandas_df_equal, assert_series_equal, assert_series_equivalent, ) @@ -84,8 +83,7 @@ def test_df_construct_pandas_default(scalars_dfs): ("bigquery_inline"), ("bigquery_load"), ("bigquery_streaming"), - # TODO(b/502298527): Reenable bigquery_write test - # ("bigquery_write"), + ("bigquery_write"), ], ) def test_read_pandas_all_nice_types( @@ -135,21 +133,11 @@ def test_df_construct_structs(session): ] ).to_frame() bf_series = session.read_pandas(pd_frame) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_series.to_pandas(), pd_frame, check_index_type=False, check_dtype=False ) -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) - - bigframes.testing.utils.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", @@ -265,7 +253,7 @@ 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) + assert_pandas_df_equal(bf_result, pd_result) def test_hasattr(scalars_dfs): @@ -292,7 +280,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): @@ -320,7 +308,7 @@ def test_df_nlargest(scalars_df_index, scalars_pandas_df_index, keep): 3, ["bool_col", "int64_too"], keep=keep ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -338,7 +326,7 @@ def test_df_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): bf_result = scalars_df_index.nsmallest(6, ["bool_col"], keep=keep) pd_result = scalars_pandas_df_index.nsmallest(6, ["bool_col"], keep=keep) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -357,7 +345,7 @@ def test_get_columns(scalars_dfs): col_names = ["bool_col", "float64_col", "int64_col"] df_subset = scalars_df.get(col_names) df_pandas = df_subset.to_pandas() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df[col_names].columns ) @@ -404,9 +392,7 @@ def test_insert(scalars_dfs, loc, column, value, allow_duplicates): bf_df.insert(loc, column, value, allow_duplicates) pd_df.insert(loc, column, value, allow_duplicates) - bigframes.testing.utils.assert_frame_equal( - bf_df.to_pandas(), pd_df, check_dtype=False - ) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df, check_dtype=False) def test_mask_series_cond(scalars_df_index, scalars_pandas_df_index): @@ -584,23 +570,11 @@ def func(x): 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" df_pandas = scalars_df.drop(columns=col_name).to_pandas() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df.drop(columns=col_name).columns ) @@ -609,7 +583,7 @@ 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() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df.drop(columns=col_names).columns ) @@ -621,7 +595,7 @@ def test_drop_labels_axis_1(scalars_dfs): pd_result = scalars_pandas_df.drop(labels=labels, axis=1) bf_result = scalars_df.drop(labels=labels, axis=1).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_with_custom_column_labels(scalars_dfs): @@ -639,7 +613,7 @@ def test_drop_with_custom_column_labels(scalars_dfs): pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( columns=dropped_columns ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_memory_usage(scalars_dfs): @@ -648,7 +622,7 @@ def test_df_memory_usage(scalars_dfs): pd_result = scalars_pandas_df.memory_usage() bf_result = scalars_df.memory_usage() - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, rtol=1.5) + pd.testing.assert_series_equal(pd_result, bf_result, rtol=1.5) def test_df_info(scalars_dfs): @@ -675,55 +649,11 @@ def test_df_info(scalars_dfs): "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() - - -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]) + scalars_df, _ = scalars_dfs bf_result = io.StringIO() - df.info(buf=bf_result) - - assert expected == bf_result.getvalue() - -def test_df_info_no_cols_no_rows(session): - expected = ( - "\nIndex: 0 entries\nEmpty DataFrame\n" - ) - df = session.DataFrame({}) - - bf_result = io.StringIO() - df.info(buf=bf_result) + scalars_df.info(buf=bf_result) assert expected == bf_result.getvalue() @@ -745,7 +675,7 @@ def test_select_dtypes(scalars_dfs, include, exclude): pd_result = scalars_pandas_df.select_dtypes(include=include, exclude=exclude) bf_result = scalars_df.select_dtypes(include=include, exclude=exclude).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_index(scalars_dfs): @@ -754,7 +684,7 @@ def test_drop_index(scalars_dfs): pd_result = scalars_pandas_df.drop(index=[4, 1, 2]) bf_result = scalars_df.drop(index=[4, 1, 2]).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_pandas_index(scalars_dfs): @@ -764,7 +694,7 @@ def test_drop_pandas_index(scalars_dfs): pd_result = scalars_pandas_df.drop(index=drop_index) bf_result = scalars_df.drop(index=drop_index).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_bigframes_index(scalars_dfs): @@ -775,12 +705,10 @@ def test_drop_bigframes_index(scalars_dfs): pd_result = scalars_pandas_df.drop(index=drop_pandas_index) bf_result = scalars_df.drop(index=drop_index).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_bigframes_index_with_na(scalars_dfs): - if pd.__version__.startswith("3"): - pytest.skip("Pandas 3.0 doesn't doesn't support drop with pd.NA values") scalars_df, scalars_pandas_df = scalars_dfs scalars_df = scalars_df.copy() scalars_pandas_df = scalars_pandas_df.copy() @@ -792,7 +720,7 @@ def test_drop_bigframes_index_with_na(scalars_dfs): pd_result = scalars_pandas_df.drop(index=drop_pandas_index) # drop_pandas_index) bf_result = scalars_df.drop(index=drop_index).to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_bigframes_multiindex(scalars_dfs): @@ -813,7 +741,7 @@ def test_drop_bigframes_multiindex(scalars_dfs): bf_result = scalars_df.drop(index=drop_index).to_pandas() pd_result = scalars_pandas_df.drop(index=drop_pandas_index) - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_labels_axis_0(scalars_dfs): @@ -822,7 +750,7 @@ def test_drop_labels_axis_0(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() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + pd.testing.assert_frame_equal(pd_result, bf_result) def test_drop_index_and_columns(scalars_dfs): @@ -831,14 +759,14 @@ def test_drop_index_and_columns(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() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result) + 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() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns ) @@ -848,9 +776,7 @@ def test_df_peek(scalars_dfs_maybe_ordered): peek_result = scalars_df.peek(n=3, force=False, allow_large_results=True) - bigframes.testing.utils.assert_index_equal( - scalars_pandas_df.columns, peek_result.columns - ) + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) assert len(peek_result) == 3 @@ -859,18 +785,14 @@ def test_df_peek_with_large_results_not_allowed(scalars_dfs_maybe_ordered): peek_result = scalars_df.peek(n=3, force=False, allow_large_results=False) - bigframes.testing.utils.assert_index_equal( - scalars_pandas_df.columns, peek_result.columns - ) + 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) - bigframes.testing.utils.assert_index_equal( - scalars_pandas_df.columns, peek_result.columns - ) + pd.testing.assert_index_equal(scalars_pandas_df.columns, peek_result.columns) assert len(peek_result) == 3 @@ -885,7 +807,7 @@ def test_df_peek_exception(scalars_dfs): 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) - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( scalars_pandas_df[["int64_col", "int64_too"]].columns, peek_result.columns ) assert len(peek_result) == 3 @@ -896,7 +818,7 @@ def test_df_peek_reset_index(scalars_dfs): peek_result = ( scalars_df[["int64_col", "int64_too"]].reset_index(drop=True).peek(n=3) ) - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( scalars_pandas_df[["int64_col", "int64_too"]].columns, peek_result.columns ) assert len(peek_result) == 3 @@ -944,56 +866,18 @@ def test_join_repr(scalars_dfs_maybe_ordered): assert actual == expected -def test_repr_w_display_options(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 - - history_pre = session.execution_history().to_dataframe() - queries_pre = ( - len(history_pre[history_pre["job_type"] == "query"]) - if "job_type" in history_pre.columns - else 0 - ) - - 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__() - - history_post = session.execution_history().to_dataframe() - queries_post = len(history_post[history_post["job_type"] == "query"]) - - with display_options.pandas_repr(bigframes.options.display): - pandas_repr = pandas_df.head(10).__repr__() - - assert actual == pandas_repr - assert (queries_post - queries_pre) <= 2 - - -def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): +def test_repr_html_w_all_rows(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 - history_pre = session.execution_history().to_dataframe() - queries_pre = ( - len(history_pre[history_pre["job_type"] == "query"]) - if "job_type" in history_pre.columns - else 0 - ) - + executions_pre = metrics.execution_count # 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"] - - history_post = session.execution_history().to_dataframe() - queries_post = len(history_post[history_post["job_type"] == "query"]) + actual = scalars_df.head(10)._repr_html_() + executions_post = metrics.execution_count with display_options.pandas_repr(bigframes.options.display): pandas_repr = pandas_df.head(10)._repr_html_() @@ -1003,14 +887,14 @@ def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): + f"[{len(pandas_df.index)} rows x {len(pandas_df.columns)} columns in total]" ) assert actual == expected - assert (queries_post - queries_pre) <= 2 + assert (executions_post - executions_pre) <= 3 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() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns ) @@ -1019,7 +903,7 @@ 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() - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df_pandas.columns, scalars_pandas_df.rename(columns=col_name_dict).columns ) @@ -1030,7 +914,7 @@ def test_get_df_column_name_duplicate(scalars_dfs): 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"] - bigframes.testing.utils.assert_index_equal(bf_result.columns, pd_result.columns) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) @pytest.mark.parametrize( @@ -1048,7 +932,7 @@ def test_take_df(scalars_dfs, indices, axis): 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) + assert_pandas_df_equal(bf_result, pd_result) def test_filter_df(scalars_dfs): @@ -1060,13 +944,7 @@ def test_filter_df(scalars_dfs): 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_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 + assert_pandas_df_equal(bf_result, pd_result) def test_df_to_pandas_batches(scalars_dfs): @@ -1081,7 +959,7 @@ def test_df_to_pandas_batches(scalars_dfs): assert 6 == capped_unfiltered_batches.total_rows assert len(pd_result) == filtered_batches.total_rows - assert_frame_equal(pd.concat(filtered_batches), pd_result) + assert_pandas_df_equal(pd.concat(filtered_batches), pd_result) @pytest.mark.parametrize( @@ -1132,7 +1010,7 @@ def test_assign_new_column_w_literal(scalars_dfs, literal, expected_dtype): pd_result = scalars_pandas_df.assign(new_col=new_col_pd) pd_result["new_col"] = pd_result["new_col"].astype(expected_dtype) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_new_column_w_loc(scalars_dfs): @@ -1147,7 +1025,7 @@ def test_assign_new_column_w_loc(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1169,7 +1047,7 @@ def test_assign_new_column_w_setitem(scalars_dfs, scalar): # Convert default pandas dtypes `float64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Float64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_setitem_dataframe(scalars_dfs): @@ -1182,7 +1060,7 @@ def test_assign_new_column_w_setitem_dataframe(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_df["int64_col"] = pd_df["int64_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df) def test_assign_new_column_w_setitem_dataframe_error(scalars_dfs): @@ -1208,7 +1086,7 @@ def test_assign_new_column_w_setitem_list(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_setitem_list_repeated(scalars_dfs): @@ -1226,7 +1104,7 @@ def test_assign_new_column_w_setitem_list_repeated(scalars_dfs): pd_result["new_col"] = pd_result["new_col"].astype("Int64") pd_result["new_col_2"] = pd_result["new_col_2"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_setitem_list_custom_index(scalars_dfs): @@ -1246,7 +1124,7 @@ def test_assign_new_column_w_setitem_list_custom_index(scalars_dfs): # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["new_col"] = pd_result["new_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_new_column_w_setitem_list_error(scalars_dfs): @@ -1273,11 +1151,6 @@ def test_assign_new_column_w_setitem_list_error(scalars_dfs): 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): @@ -1288,9 +1161,7 @@ def test_setitem_multicolumn_with_literals(scalars_dfs, key, value): bf_result[key] = value pd_result[key] = value - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result.to_pandas(), check_dtype=False - ) + 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): @@ -1309,9 +1180,7 @@ def test_setitem_multicolumn_with_dataframes(scalars_dfs): 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 - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result.to_pandas(), check_dtype=False - ) + 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): @@ -1340,7 +1209,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_listlike_to_empty_df(session): @@ -1352,7 +1221,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_frame_equal(bf_result.to_pandas(), pd_result) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result) def test_assign_to_empty_df_multiindex_error(session): @@ -1386,7 +1255,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) def test_assign_series_overwrite(scalars_dfs): @@ -1398,7 +1267,7 @@ def test_assign_series_overwrite(scalars_dfs): **{column_name: scalars_pandas_df[column_name] + 3} ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_sequential(scalars_dfs): @@ -1413,7 +1282,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) # Require an index so that the self-join is consistent each time. @@ -1447,7 +1316,7 @@ def test_assign_different_df( new_col=scalars_pandas_df_index[column_name] ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_different_df_w_loc( @@ -1466,7 +1335,7 @@ def test_assign_different_df_w_loc( # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_different_df_w_setitem( @@ -1485,7 +1354,7 @@ def test_assign_different_df_w_setitem( # Convert default pandas dtypes `int64` to match BigQuery DataFrames dtypes. pd_result["int64_col"] = pd_result["int64_col"].astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_assign_callable_lambda(scalars_dfs): @@ -1498,7 +1367,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1555,7 +1424,7 @@ def test_df_dropna_by_thresh(scalars_dfs, axis, ignore_index, subset, thresh): bf_result = df_result.to_pandas() # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_df_dropna_range_columns(scalars_dfs): @@ -1603,7 +1472,7 @@ def test_df_fillna(scalars_dfs, col, fill_value): bf_result = scalars_df[col].fillna(fill_value).to_pandas() pd_result = scalars_pandas_df[col].fillna(fill_value) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_df_replace_scalar_scalar(scalars_dfs): @@ -1612,7 +1481,7 @@ def test_df_replace_scalar_scalar(scalars_dfs): pd_result = scalars_pandas_df.replace(555.555, 3) # pandas has narrower result types as they are determined dynamically - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) def test_df_replace_regex_scalar(scalars_dfs): @@ -1620,7 +1489,7 @@ def test_df_replace_regex_scalar(scalars_dfs): bf_result = scalars_df.replace("^H.l", "Howdy, Planet!", regex=True).to_pandas() pd_result = scalars_pandas_df.replace("^H.l", "Howdy, Planet!", regex=True) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -1632,7 +1501,7 @@ def test_df_replace_list_scalar(scalars_dfs): pd_result = scalars_pandas_df.replace([555.555, 3.2], 3) # pandas has narrower result types as they are determined dynamically - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, check_dtype=False, @@ -1644,7 +1513,7 @@ def test_df_replace_value_dict(scalars_dfs): bf_result = scalars_df.replace(1, {"int64_col": 100, "int64_too": 200}).to_pandas() pd_result = scalars_pandas_df.replace(1, {"int64_col": 100, "int64_too": 200}) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -1861,9 +1730,7 @@ def test_df_cross_merge(scalars_dfs): ), "cross", ) - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) @pytest.mark.parametrize( @@ -1897,7 +1764,9 @@ def test_df_merge(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1930,7 +1799,9 @@ def test_df_merge_multi_key(scalars_dfs, left_on, right_on): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1960,7 +1831,9 @@ 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_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1993,7 +1866,9 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) def test_self_merge_self_w_on_args(): @@ -2016,9 +1891,7 @@ def test_self_merge_self_w_on_args(): bf_result = bf_df1.merge( bf_df2, left_on=["A", "C"], right_on=["B", "C"], how="inner" ).to_pandas() - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) @pytest.mark.parametrize( @@ -2037,7 +1910,7 @@ def test_dataframe_round(scalars_dfs, decimals): bf_result = scalars_df.round(decimals).to_pandas() pd_result = scalars_pandas_df.round(decimals) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_get_dtypes(scalars_df_default_index): @@ -2059,7 +1932,7 @@ def test_get_dtypes(scalars_df_default_index): "timestamp_col": pd.ArrowDtype(pa.timestamp("us", tz="UTC")), "duration_col": pd.ArrowDtype(pa.duration("us")), } - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( dtypes, pd.Series(dtypes_dict), ) @@ -2075,7 +1948,7 @@ def test_get_dtypes_array_struct_query(session): ) dtypes = df.dtypes - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( dtypes, pd.Series( { @@ -2095,7 +1968,7 @@ def test_get_dtypes_array_struct_query(session): def test_get_dtypes_array_struct_table(nested_df): dtypes = nested_df.dtypes - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( dtypes, pd.Series( { @@ -2194,8 +2067,7 @@ def test_len(scalars_dfs): ) @pytest.mark.parametrize( "write_engine", - # TODO(b/502298527): Reenable bigquery_write test - ["bigquery_load", "bigquery_streaming"], + ["bigquery_load", "bigquery_streaming", "bigquery_write"], ) def test_df_len_local(session, n_rows, write_engine): assert ( @@ -2477,19 +2349,13 @@ def test_set_index_key_error(scalars_dfs): ("na_position",), (("first",), ("last",)), ) -@pytest.mark.parametrize( - ("axis",), - ((0,), ("columns",)), -) -def test_sort_index(scalars_dfs, ascending, na_position, axis): +def test_sort_index(scalars_dfs, ascending, na_position): 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() + bf_result = df.sort_index(ascending=ascending, na_position=na_position).to_pandas() pd_result = scalars_pandas_df.set_index(index_column).sort_index( - ascending=ascending, na_position=na_position, axis=axis + ascending=ascending, na_position=na_position ) pandas.testing.assert_frame_equal(bf_result, pd_result) @@ -2520,7 +2386,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_frame_equal(pd_result, bf_result) + assert_pandas_df_equal(pd_result, bf_result) def test_df_neg(scalars_dfs): @@ -2528,17 +2394,7 @@ 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_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) + assert_pandas_df_equal(pd_result, bf_result) def test_df_invert(scalars_dfs): @@ -2548,7 +2404,7 @@ def test_df_invert(scalars_dfs): bf_result = (~scalars_df[columns]).to_pandas() pd_result = ~scalars_pandas_df[columns] - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_isnull(scalars_dfs): @@ -2565,7 +2421,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_notnull(scalars_dfs): @@ -2582,7 +2438,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2634,7 +2490,7 @@ def test_combine( ) # Some dtype inconsistency for all-NULL columns - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -2672,7 +2528,7 @@ def test_df_update(overwrite, filter_func): bf_df1.update(bf_df2, overwrite=overwrite, filter_func=filter_func) pd_df1.update(pd_df2, overwrite=overwrite, filter_func=filter_func) - bigframes.testing.utils.assert_frame_equal(bf_df1.to_pandas(), pd_df1) + pd.testing.assert_frame_equal(bf_df1.to_pandas(), pd_df1) def test_df_idxmin(): @@ -2684,7 +2540,7 @@ def test_df_idxmin(): bf_result = bf_df.idxmin().to_pandas() pd_result = pd_df.idxmin() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_dtype=False ) @@ -2698,7 +2554,7 @@ def test_df_idxmax(): bf_result = bf_df.idxmax().to_pandas() pd_result = pd_df.idxmax() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_dtype=False ) @@ -2716,6 +2572,7 @@ def test_df_idxmax(): ], ) 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") @@ -2738,12 +2595,8 @@ def test_df_align(join, axis): assert isinstance(bf_result1, dataframe.DataFrame) and isinstance( bf_result2, dataframe.DataFrame ) - bigframes.testing.utils.assert_frame_equal( - bf_result1.to_pandas(), pd_result1, check_dtype=False - ) - bigframes.testing.utils.assert_frame_equal( - bf_result2.to_pandas(), pd_result2, check_dtype=False - ) + 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( @@ -2768,7 +2621,7 @@ def test_combine_first( pd_result = pd_df_a.combine_first(pd_df_b) # Some dtype inconsistency for all-NULL columns - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -2795,9 +2648,9 @@ def test_df_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. - bigframes.testing.utils.assert_index_equal(bf_result.columns, pd_result.columns) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) # Only check row order in ordered mode. - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, @@ -2839,9 +2692,9 @@ 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. - bigframes.testing.utils.assert_index_equal(bf_result.columns, pd_result.columns) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) # Only check row order in ordered mode. - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, @@ -2862,7 +2715,7 @@ def test_df_corrwith_df(scalars_dfs_maybe_ordered): # 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. - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -2883,7 +2736,7 @@ def test_df_corrwith_df_numeric_only(scalars_dfs): # 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. - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -2912,7 +2765,7 @@ def test_df_corrwith_series(scalars_dfs_maybe_ordered): # 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. - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -2958,7 +2811,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_dataframe_string_radd_const(scalars_dfs): @@ -2974,7 +2827,7 @@ def test_dataframe_string_radd_const(scalars_dfs): bf_result = ("prefix" + scalars_df[columns]).to_pandas() pd_result = "prefix" + scalars_pandas_df[columns] - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize(("other_scalar"), [1, -2]) @@ -2986,7 +2839,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_scalar_binop_str_exception(scalars_dfs): @@ -3042,7 +2895,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -3070,7 +2923,7 @@ 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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_df_reverse_binop_pandas(scalars_dfs): @@ -3085,7 +2938,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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_listlike_binop_axis_1_bf_index(scalars_dfs): @@ -3100,7 +2953,7 @@ 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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate(scalars_dfs_maybe_ordered): @@ -3108,24 +2961,19 @@ def test_binop_with_self_aggregate(scalars_dfs_maybe_ordered): df_columns = ["int64_col", "float64_col", "int64_too"] - history_before = scalars_df._session.execution_history().to_dataframe() - queries_before = ( - len(history_before[history_before["job_type"] == "query"]) - if "job_type" in history_before.columns - else 0 - ) - + # 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] bf_result = (bf_df - bf_df.mean()).to_pandas() - - history_after = scalars_df._session.execution_history().to_dataframe() - queries_after = len(history_after[history_after["job_type"] == "query"]) + execution_count_after = scalars_df._session._metrics.execution_count pd_df = scalars_pandas_df[df_columns] pd_result = pd_df - pd_df.mean() - assert (queries_after - queries_before) == 1 - assert_frame_equal(bf_result, pd_result, check_dtype=False) + executions = execution_count_after - execution_count_before + + assert executions == 1 + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate_w_index_reset(scalars_dfs_maybe_ordered): @@ -3133,25 +2981,22 @@ def test_binop_with_self_aggregate_w_index_reset(scalars_dfs_maybe_ordered): df_columns = ["int64_col", "float64_col", "int64_too"] - history_before = scalars_df._session.execution_history().to_dataframe() - queries_before = ( - len(history_before[history_before["job_type"] == "query"]) - if "job_type" in history_before.columns - else 0 - ) - + # 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() - - history_after = scalars_df._session.execution_history().to_dataframe() - queries_after = len(history_after[history_after["job_type"] == "query"]) + 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() - assert (queries_after - queries_before) == 1 + 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) + assert_pandas_df_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False + ) @pytest.mark.parametrize( @@ -3193,7 +3038,7 @@ def test_binop_df_df_binary_op( pd_result = pd_df_a - pd_df_b # Some dtype inconsistency for all-NULL columns - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + 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. @@ -3219,7 +3064,7 @@ def test_series_binop_add_different_table( scalars_pandas_df_index[series_column], axis="index" ) - assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) # TODO(garrettwu): Test series binop with different index @@ -3233,6 +3078,8 @@ 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() @@ -3252,22 +3099,7 @@ def test_join_same_table(scalars_dfs_maybe_ordered, how): pd_result = pd_df_a.join(pd_df_b, how=how) - 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") + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -3280,7 +3112,7 @@ 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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -3303,11 +3135,9 @@ def test_join_different_table_with_duplicate_column_name( pd_result = pd_df_a.join(pd_df_b, how=how, lsuffix="_l", rsuffix="_r") # Ensure no inplace changes - bigframes.testing.utils.assert_index_equal(bf_df_a.columns, pd_df_a.columns) - bigframes.testing.utils.assert_index_equal(bf_df_b.index.to_pandas(), pd_df_b.index) - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + 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 @@ -3335,14 +3165,14 @@ def test_join_param_on_with_duplicate_column_name_not_on_col( pd_result = pd_df_a.join( pd_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.sort_index(), pd_result.sort_index(), check_like=True, check_index_type=False, check_names=False, ) - bigframes.testing.utils.assert_index_equal(bf_result.columns, pd_result.columns) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) @pytest.mark.skipif( @@ -3373,14 +3203,14 @@ def test_join_param_on_with_duplicate_column_name_on_col( pd_result = pd_df_a.join( pd_df_b, on="int64_too", how=how, lsuffix="_l", rsuffix="_r" ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.sort_index(), pd_result.sort_index(), check_like=True, check_index_type=False, check_names=False, ) - bigframes.testing.utils.assert_index_equal(bf_result.columns, pd_result.columns) + pd.testing.assert_index_equal(bf_result.columns, pd_result.columns) @all_joins @@ -3401,7 +3231,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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -3422,7 +3252,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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -3525,7 +3355,7 @@ def test_dataframe_numeric_analytic_op( bf_series = operator(scalars_df_index[columns]) pd_series = operator(scalars_pandas_df_index[columns]) bf_result = bf_series.to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_series, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_series, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -3550,7 +3380,7 @@ def test_dataframe_general_analytic_op( bf_series = operator(scalars_df_index[col_names]) pd_series = operator(scalars_pandas_df_index[col_names]) bf_result = bf_series.to_pandas() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_series, bf_result, ) @@ -3568,7 +3398,7 @@ 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) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -3585,9 +3415,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() - # pandas 3.0 does not automatically ffill anymore - pd_result = scalars_pandas_df_index[col_names].ffill().pct_change(periods=periods) - bigframes.testing.utils.assert_frame_equal( + pd_result = scalars_pandas_df_index[col_names].pct_change(periods=periods) + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -3601,7 +3430,7 @@ def test_dataframe_agg_single_string(scalars_dfs): pd_result = scalars_pandas_df[numeric_cols].agg("sum") assert bf_result.dtype == "Float64" - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @@ -3621,7 +3450,7 @@ def test_dataframe_agg_int_single_string(scalars_dfs, agg): pd_result = scalars_pandas_df[numeric_cols].agg(agg) assert bf_result.dtype == "Int64" - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @@ -3676,7 +3505,7 @@ def test_dataframe_agg_int_multi_string(scalars_dfs): # Pandas may produce narrower numeric types # Pandas has object index type - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @@ -3702,7 +3531,7 @@ def test_df_transpose(): 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) # type: ignore + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) def test_df_transpose_error(): @@ -3719,7 +3548,7 @@ def test_df_transpose_repeated_uses_cache(): bf_df = bf_df.transpose() + i pd_df = pd_df.transpose() + i - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_df, bf_df.to_pandas(), check_dtype=False, check_index_type=False ) @@ -3762,7 +3591,7 @@ def test_df_melt_default(scalars_dfs): pd_result = scalars_pandas_df[columns].melt() # Pandas produces int64 index, Bigframes produces Int64 (nullable) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -3791,7 +3620,7 @@ def test_df_melt_parameterized(scalars_dfs): ) # Pandas produces int64 index, Bigframes produces Int64 (nullable) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, check_dtype=False ) @@ -3844,7 +3673,7 @@ def test_df_pivot(scalars_dfs, values, index, columns): # Pandas produces NaN, where bq dataframes produces pd.NA bf_result = bf_result.fillna(float("nan")) pd_result = pd_result.fillna(float("nan")) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -3865,22 +3694,16 @@ def test_df_pivot_hockey(hockey_df, hockey_pandas_df, values, index, columns): ) # Pandas produces NaN, where bq dataframes produces pd.NA - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( - ("values", "index", "columns", "aggfunc", "fill_value"), + ("values", "index", "columns", "aggfunc"), [ - (("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), + (("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"), ], ) def test_df_pivot_table( @@ -3890,23 +3713,14 @@ 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, - fill_value=fill_value, + values=values, index=index, columns=columns, aggfunc=aggfunc ).to_pandas() pd_result = penguins_pandas_df_default_index.pivot_table( - values=values, - index=index, - columns=columns, - aggfunc=aggfunc, - fill_value=fill_value, + values=values, index=index, columns=columns, aggfunc=aggfunc ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_column_type=False ) @@ -3986,7 +3800,7 @@ def test__dir__with_rename(scalars_dfs): 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"]] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4010,7 +3824,7 @@ def test_loc_select_columns_w_repeats(scalars_df_index, scalars_pandas_df_index) 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] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4028,7 +3842,7 @@ def test_iloc_slice_after_cache( 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] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4050,7 +3864,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) @pytest.mark.parametrize( @@ -4061,7 +3875,7 @@ 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] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -4086,14 +3900,14 @@ def test_iloc_tuple_multi_columns(scalars_df_index, scalars_pandas_df_index, ind bf_result = scalars_df_index.iloc[index].to_pandas() pd_result = scalars_pandas_df_index.iloc[index] - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + 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] - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -4147,7 +3961,7 @@ 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] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4158,7 +3972,7 @@ def test_loc_list_select_rows_and_columns(scalars_df_index, scalars_pandas_df_in 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"]] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4167,7 +3981,7 @@ def test_loc_list_select_rows_and_columns(scalars_df_index, scalars_pandas_df_in 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"] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -4178,7 +3992,7 @@ def test_loc_select_with_column_condition(scalars_df_index, scalars_pandas_df_in pd_result = scalars_pandas_df_index.loc[ :, scalars_pandas_df_index.dtypes == "Int64" ] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4201,7 +4015,7 @@ def test_loc_select_with_column_condition_bf_series( pd_result = scalars_pandas_df_index.loc[ :, scalars_pandas_df_index.nunique() > size_half ] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4215,7 +4029,7 @@ def test_loc_single_index_with_duplicate(scalars_df_index, scalars_pandas_df_ind index = "Hello, World!" bf_result = scalars_df_index.loc[index] pd_result = scalars_pandas_df_index.loc[index] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -4227,7 +4041,7 @@ def test_loc_single_index_no_duplicate(scalars_df_index, scalars_pandas_df_index index = -2345 bf_result = scalars_df_index.loc[index] pd_result = scalars_pandas_df_index.loc[index] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -4241,7 +4055,7 @@ def test_at_with_duplicate(scalars_df_index, scalars_pandas_df_index): index = "Hello, World!" bf_result = scalars_df_index.at[index, "int64_too"] pd_result = scalars_pandas_df_index.at[index, "int64_too"] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4266,7 +4080,7 @@ def test_loc_setitem_bool_series_scalar_new_col(scalars_dfs): # pandas uses float64 instead pd_df["new_col"] = pd_df["new_col"].astype("Float64") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_df.to_pandas(), pd_df, ) @@ -4290,7 +4104,7 @@ def test_loc_setitem_bool_series_scalar_existing_col(scalars_dfs, col, value): bf_df.loc[bf_df["int64_too"] == 1, col] = value pd_df.loc[pd_df["int64_too"] == 1, col] = value - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_df.to_pandas(), pd_df, ) @@ -4415,8 +4229,10 @@ 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 - assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) def test_dataframe_aggregates_median(scalars_df_index, scalars_pandas_df_index): @@ -4443,9 +4259,7 @@ def test_dataframe_aggregates_quantile_mono(scalars_df_index, scalars_pandas_df_ # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) def test_dataframe_aggregates_quantile_multi(scalars_df_index, scalars_pandas_df_index): @@ -4458,7 +4272,7 @@ def test_dataframe_aggregates_quantile_multi(scalars_df_index, scalars_pandas_df pd_result = pd_result.astype("Float64") pd_result.index = pd_result.index.astype("Float64") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -4484,9 +4298,7 @@ def test_dataframe_bool_aggregates(scalars_df_index, scalars_pandas_df_index, op bf_result = bf_series.to_pandas() pd_series.index = pd_series.index.astype(bf_result.index.dtype) - bigframes.testing.utils.assert_series_equal( - pd_series, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_series, bf_result, check_index_type=False) def test_dataframe_prod(scalars_df_index, scalars_pandas_df_index): @@ -4498,9 +4310,7 @@ def test_dataframe_prod(scalars_df_index, scalars_pandas_df_index): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_series = pd_series.astype("Float64") # Pandas has object index type - bigframes.testing.utils.assert_series_equal( - pd_series, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_series, bf_result, check_index_type=False) def test_df_skew_too_few_values(scalars_dfs): @@ -4512,9 +4322,7 @@ def test_df_skew_too_few_values(scalars_dfs): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -4547,9 +4355,7 @@ def test_df_kurt_too_few_values(scalars_dfs): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) def test_df_kurt(scalars_dfs): @@ -4561,9 +4367,7 @@ def test_df_kurt(scalars_dfs): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -4583,7 +4387,7 @@ def test_df_kurt(scalars_dfs): "n_default", ], ) -def test_df_to_pandas_sample(scalars_dfs, frac, n, random_state): +def test_sample(scalars_dfs, frac, n, random_state): scalars_df, _ = scalars_dfs df = scalars_df.sample(frac=frac, n=n, random_state=random_state) bf_result = df.to_pandas() @@ -4594,7 +4398,7 @@ def test_df_to_pandas_sample(scalars_dfs, frac, n, random_state): assert bf_result.shape[1] == scalars_df.shape[1] -def test_df_to_pandas_sample_determinism(penguins_df_default_index): +def test_sample_determinism(penguins_df_default_index): df = penguins_df_default_index.sample(n=100, random_state=12345).head(15) bf_result = df.to_pandas() bf_result2 = df.to_pandas() @@ -4602,7 +4406,7 @@ def test_df_to_pandas_sample_determinism(penguins_df_default_index): pandas.testing.assert_frame_equal(bf_result, bf_result2) -def test_df_to_pandas_sample_raises_value_error(scalars_dfs): +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." @@ -4647,7 +4451,7 @@ def test_df_add_prefix(scalars_df_index, scalars_pandas_df_index, axis): pd_result = scalars_pandas_df_index.add_prefix("prefix_", axis) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -4668,7 +4472,7 @@ def test_df_add_suffix(scalars_df_index, scalars_pandas_df_index, axis): pd_result = scalars_pandas_df_index.add_suffix("_suffix", axis) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -4688,7 +4492,7 @@ def test_df_columns_filter_items(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.filter(items=["string_col", "int64_col"]) # Ignore column ordering as pandas order differently depending on version - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.sort_index(axis=1), pd_result.sort_index(axis=1), ) @@ -4699,7 +4503,7 @@ def test_df_columns_filter_like(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.filter(like="64_col") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4710,7 +4514,7 @@ def test_df_columns_filter_regex(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.filter(regex="^[^_]+$") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4726,7 +4530,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_frame_equal( + assert_pandas_df_equal( bf_result, pd_result, ignore_order=True, @@ -4742,7 +4546,7 @@ def test_df_rows_filter_like(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.filter(like="ello", axis=0) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4756,7 +4560,7 @@ def test_df_rows_filter_regex(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.filter(regex="^[GH].*", axis=0) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4787,7 +4591,7 @@ def test_df_reindex_rows_index(scalars_df_index, scalars_pandas_df_index): # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4812,7 +4616,7 @@ def test_df_reindex_columns(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4827,7 +4631,7 @@ def test_df_reindex_columns_with_same_order(scalars_df_index, scalars_pandas_df_ bf_result = bf.reindex(columns=columns).to_pandas() pd_result = pd_df.reindex(columns=columns) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4917,7 +4721,7 @@ def test_df_reindex_like(scalars_df_index, scalars_pandas_df_index): 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()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -4928,7 +4732,7 @@ def test_df_values(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.values # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False ) @@ -4938,7 +4742,7 @@ def test_df_to_numpy(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.to_numpy() # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False ) @@ -4948,7 +4752,7 @@ def test_df___array__(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.__array__() # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd.DataFrame(bf_result), pd.DataFrame(pd_result), check_dtype=False ) @@ -4998,7 +4802,7 @@ def test_df_setattr_index(): pd_df.index = pandas.Index([4, 5]) bf_df.index = [4, 5] - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -5013,7 +4817,7 @@ def test_df_setattr_columns(): bf_df.columns = pandas.Index([4, 5, 6]) - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -5026,7 +4830,7 @@ def test_df_setattr_modify_column(): pd_df.my_column = [4, 5] bf_df.my_column = [4, 5] - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -5040,7 +4844,7 @@ def test_loc_list_string_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.loc[index_list].to_pandas() pd_result = scalars_pandas_df_index.loc[index_list] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -5052,7 +4856,7 @@ def test_loc_list_integer_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.loc[index_list] pd_result = scalars_pandas_df_index.loc[index_list] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5087,7 +4891,7 @@ 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] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5107,7 +4911,7 @@ def test_iloc_list_partial_ordering( bf_result = scalars_df_partial_ordering.iloc[index_list] pd_result = scalars_pandas_df_index.iloc[index_list] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5125,13 +4929,14 @@ def test_iloc_list_multiindex(scalars_dfs): bf_result = scalars_df.iloc[index_list] pd_result = scalars_pandas_df.iloc[index_list] - bigframes.testing.utils.assert_frame_equal( + 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] @@ -5145,7 +4950,7 @@ 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") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5155,7 +4960,7 @@ 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,)) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5171,7 +4976,7 @@ def test_loc_bf_series_string_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.loc[bf_string_series] pd_result = scalars_pandas_df_index.loc[pd_string_series] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5189,7 +4994,7 @@ def test_loc_bf_series_multiindex(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_multiindex.loc[bf_string_series] pd_result = scalars_pandas_df_multiindex.loc[pd_string_series] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5202,7 +5007,7 @@ def test_loc_bf_index_integer_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.loc[bf_index] pd_result = scalars_pandas_df_index.loc[pd_index] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5222,7 +5027,7 @@ def test_loc_bf_index_integer_index_renamed_col( bf_result = scalars_df_index.loc[bf_index] pd_result = scalars_pandas_df_index.loc[pd_index] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result.to_pandas(), pd_result, ) @@ -5248,7 +5053,7 @@ def test_df_drop_duplicates(scalars_df_index, scalars_pandas_df_index, keep, sub 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) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_df, bf_df, ) @@ -5275,7 +5080,7 @@ def test_df_drop_duplicates_w_json(json_df, keep): pd_df = json_pandas_df.drop_duplicates(keep=keep) pd_df["json_col"] = pd_df["json_col"].astype(dtypes.JSON_DTYPE) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_df, bf_df, ) @@ -5300,14 +5105,16 @@ 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) - bigframes.testing.utils.assert_series_equal(pd_series, bf_series, check_dtype=False) + 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) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_dict_index_orient(): @@ -5316,7 +5123,9 @@ 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_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_dict_tight_orient(): @@ -5330,7 +5139,9 @@ 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_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_records(): @@ -5340,7 +5151,9 @@ def test_df_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) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): @@ -5353,10 +5166,7 @@ def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): def test_df_to_excel(scalars_df_index, scalars_pandas_df_index): unsupported = ["timestamp_col"] - with ( - tempfile.TemporaryFile() as bf_result_file, - tempfile.TemporaryFile() as pd_result_file, - ): + with tempfile.TemporaryFile() as bf_result_file, tempfile.TemporaryFile() as pd_result_file: scalars_df_index.drop(columns=unsupported).to_excel(bf_result_file) scalars_pandas_df_index.drop(columns=unsupported).to_excel(pd_result_file) bf_result = bf_result_file.read() @@ -5374,12 +5184,9 @@ def test_df_to_latex(scalars_df_index, scalars_pandas_df_index): def test_df_to_json_local_str(scalars_df_index, scalars_pandas_df_index): - # pandas 3.0 bugged for serializing date col - bf_result = scalars_df_index.drop(columns="date_col").to_json() + bf_result = scalars_df_index.to_json() # default_handler for arrow types that have no default conversion - pd_result = scalars_pandas_df_index.drop(columns="date_col").to_json( - default_handler=str - ) + pd_result = scalars_pandas_df_index.to_json(default_handler=str) assert bf_result == pd_result @@ -5390,10 +5197,7 @@ def test_df_to_json_local_file(scalars_df_index, scalars_pandas_df_index): # 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, - ): + 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( @@ -5415,10 +5219,7 @@ def test_df_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -5442,10 +5243,7 @@ def test_df_to_parquet_local_bytes(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -5492,10 +5290,7 @@ def test_df_to_markdown(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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() @@ -5544,7 +5339,7 @@ def test_df_eval(scalars_dfs, expr): bf_result = scalars_df.eval(expr).to_pandas() pd_result = scalars_pandas_df.eval(expr) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -5565,7 +5360,7 @@ def test_df_query(scalars_dfs, expr): bf_result = scalars_df.query(expr).to_pandas() pd_result = scalars_pandas_df.query(expr) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -5590,23 +5385,19 @@ def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): subset, normalize=normalize, ascending=ascending, dropna=dropna ) - bigframes.testing.utils.assert_series_equal( - bf_result, - pd_result, - check_dtype=False, - check_index_type=False, - ignore_order=True, # different pandas versions inconsistent for tie-handling + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False ) @pytest.mark.parametrize( - ("na_option", "method", "ascending", "numeric_only", "pct"), + ("na_option", "method", "ascending", "numeric_only"), [ - ("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), + ("keep", "average", True, True), + ("top", "min", False, False), + ("bottom", "max", False, False), + ("top", "first", False, False), + ("bottom", "dense", False, False), ], ) def test_df_rank_with_nulls( @@ -5616,7 +5407,6 @@ def test_df_rank_with_nulls( method, ascending, numeric_only, - pct, ): unsupported_columns = ["geography_col"] bf_result = ( @@ -5626,7 +5416,6 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, - pct=pct, ) .to_pandas() ) @@ -5637,12 +5426,11 @@ def test_df_rank_with_nulls( method=method, ascending=ascending, numeric_only=numeric_only, - pct=pct, ) .astype(pd.Float64Dtype()) ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -5676,23 +5464,6 @@ 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( { @@ -5709,7 +5480,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_frame_equal(bf_df.to_pandas(), pd_df) + assert_pandas_df_equal(bf_df.to_pandas(), pd_df) def test_df_cache_with_implicit_join(scalars_df_index): @@ -5739,7 +5510,7 @@ def test_df_dot_inline(session): pd_result[name] = pd_result[name].astype(pd.Int64Dtype()) pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -5756,7 +5527,7 @@ def test_df_dot( for name in pd_result.columns: pd_result[name] = pd_result[name].astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -5773,7 +5544,7 @@ def test_df_dot_operator( for name in pd_result.columns: pd_result[name] = pd_result[name].astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, ) @@ -5796,7 +5567,7 @@ def test_df_dot_series_inline(): pd_result = pd_result.astype(pd.Int64Dtype()) pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -5812,7 +5583,7 @@ def test_df_dot_series( # Pandas result is object instead of Int64 (nullable) dtype. pd_result = pd_result.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -5828,15 +5599,22 @@ def test_df_dot_operator_series( # Pandas result is object instead of Int64 (nullable) dtype. pd_result = pd_result.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) +# TODO(tswast): We may be able to re-enable this test after we break large +# queries up in https://github.com/googleapis/python-bigquery-dataframes/pull/427 +@pytest.mark.skipif( + sys.version_info >= (3, 12), + # See: https://github.com/python/cpython/issues/112282 + reason="setrecursionlimit has no effect on the Python C stack since Python 3.12.", +) def test_recursion_limit(scalars_df_index): scalars_df_index = scalars_df_index[["int64_too", "int64_col", "float64_col"]] - for i in range(250): + for i in range(400): scalars_df_index = scalars_df_index + 4 scalars_df_index.to_pandas() @@ -5871,7 +5649,7 @@ def test_query_complexity_repeated_joins( bf_result = bf_df.to_pandas() pd_result = pd_df - assert_frame_equal(bf_result, pd_result, check_index_type=False) + assert_pandas_df_equal(bf_result, pd_result, check_index_type=False) def test_query_complexity_repeated_subtrees( @@ -5885,7 +5663,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.skipif( @@ -5902,7 +5680,7 @@ def test_query_complexity_repeated_analytic(scalars_df_index, scalars_pandas_df_ pd_df = pd_df.diff() bf_result = bf_df.to_pandas() pd_result = pd_df - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_to_gbq_and_create_dataset(session, scalars_df_index, dataset_id_not_created): @@ -5941,45 +5719,6 @@ def test_to_gbq_table_labels(scalars_df_index): assert table.labels["test"] == "labels" -def test_to_gbq_obj_ref_persists(session): - # Test that saving and loading an Object Reference retains its dtype - import uuid - - import google.cloud.bigquery - - sql = """ - SELECT STRUCT('gs://cloud-samples-data/vision/ocr/sign.jpg' AS uri, CAST(NULL AS STRING) AS version, CAST(NULL AS STRING) AS authorizer, PARSE_JSON('{}') AS details) AS uris - """ - df_init = session.read_gbq(sql) - - tmp_table_id = f"bigframes-dev.bigframes_tests_sys.tmp_obj_ref_{uuid.uuid4().hex}" - df_init.to_gbq(tmp_table_id, if_exists="replace") - - client = session.bqclient - table = client.get_table(tmp_table_id) - schema = list(table.schema) - for i, field in enumerate(schema): - if field.name == "uris": - schema[i] = google.cloud.bigquery.SchemaField( - name=field.name, - field_type=field.field_type, - mode=field.mode, - description="bigframes_dtype: OBJ_REF_DTYPE", - fields=field.fields, - ) - break - table.schema = schema - client.update_table(table, ["schema"]) - - bdf = session.read_gbq(tmp_table_id) - - destination_table = "bigframes-dev.bigframes_tests_sys.test_obj_ref_persistence" - bdf.to_gbq(destination_table, if_exists="replace") - - loaded_df = session.read_gbq(destination_table) - assert loaded_df["uris"].dtype == dtypes.OBJ_REF_DTYPE - - @pytest.mark.parametrize( ("col_names", "ignore_index"), [ @@ -5998,24 +5737,18 @@ def test_dataframe_explode(col_names, ignore_index, session): "C": [["a", "b", "c"], np.nan, ["d", "e"]], } + metrics = session._metrics 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) - history_pre = session.execution_history().to_dataframe() - queries_pre = ( - len(history_pre[history_pre["job_type"] == "query"]) - if "job_type" in history_pre.columns - else 0 - ) - + # Check that to_pandas() results in at most a single query execution + execs_pre = metrics.execution_count bf_materialized = bf_result.to_pandas() + execs_post = metrics.execution_count - history_post = session.execution_history().to_dataframe() - queries_post = len(history_post[history_post["job_type"] == "query"]) - - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_materialized, pd_result, check_index_type=False, @@ -6023,7 +5756,7 @@ def test_dataframe_explode(col_names, ignore_index, session): ) # we test this property on this method in particular as compilation # is non-deterministic and won't use the query cache as implemented - assert (queries_post - queries_pre) <= 1 + assert execs_post - execs_pre <= 1 @pytest.mark.parametrize( @@ -6046,7 +5779,7 @@ def test_dataframe_explode_reserve_order(ignore_index, ordered): pd_res = pd_df.explode(["a", "b"], ignore_index=ignore_index).astype( pd.Int64Dtype() ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( res if ordered else res.sort_index(), pd_res, check_index_type=False, @@ -6076,20 +5809,21 @@ 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.2.0") - # TODO: supply a reason why this isn't compatible with pandas 1.x - if pandas.__version__.startswith("3"): - pytest.skip( - "pandas 3.0 behavior diverges for day offsets: https://github.com/pandas-dev/pandas/pull/61985" - ) + 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() @@ -6098,70 +5832,38 @@ def test_resample_with_column( pd_result = scalars_pandas_df_index.resample(rule=rule, on=on, origin=origin)[ ["int64_col", "int64_too"] ].max() - # TODO: (b/484364312) - pd_result.index.names = bf_result.index.names - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) -@pytest.mark.parametrize("index_col", ["timestamp_col", "datetime_col"]) -@pytest.mark.parametrize( - ("index_append", "level"), - [(True, 1), (False, None), (False, 0)], -) @pytest.mark.parametrize( - "rule", + ("append", "level", "col", "rule"), [ - # TODO(tswast): support timedeltas and dataoffsets. - # TODO(tswast): support bins that default to "right". - "100d", - "1200h", + pytest.param(False, None, "timestamp_col", "100d"), + pytest.param(True, 1, "timestamp_col", "1200h"), + pytest.param(False, None, "datetime_col", "100d"), ], ) -# 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, +def test__resample_with_index( + scalars_df_index, scalars_pandas_df_index, append, level, col, rule ): # TODO: supply a reason why this isn't compatible with pandas 1.x - if rule == "100d" and pandas.__version__.startswith("3"): - pytest.skip( - "pandas 3.0 behavior diverges for day offsets: https://github.com/pandas-dev/pandas/pull/61985" - ) 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 - ) + scalars_df_index = scalars_df_index.set_index(col, append=append) + scalars_pandas_df_index = scalars_pandas_df_index.set_index(col, append=append) bf_result = ( scalars_df_index[["int64_col", "int64_too"]] - .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) + ._resample(rule=rule, level=level) .min() .to_pandas() ) pd_result = ( scalars_pandas_df_index[["int64_col", "int64_too"]] - .resample(rule=rule, level=level, closed=closed, origin=origin, label=label) + .resample(rule=rule, level=level) .min() ) - # TODO: (b/484364312) - pd_result.index.names = bf_result.index.names - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -6202,7 +5904,7 @@ 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" @@ -6210,13 +5912,11 @@ def test_resample_start_time(rule, origin, data): 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() - # TODO: (b/484364312) - pd_result.index.names = bf_result.index.names - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -6241,9 +5941,7 @@ def test_df_astype(scalars_dfs, dtype): bf_result = bf_df.astype(dtype).to_pandas() pd_result = pd_df.astype(dtype) - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) def test_df_astype_python_types(scalars_dfs): @@ -6257,19 +5955,17 @@ def test_df_astype_python_types(scalars_dfs): {"bool_col": "string[pyarrow]", "int64_col": pd.Float64Dtype()} ) - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) def test_astype_invalid_type_fail(scalars_dfs): bf_df, _ = scalars_dfs - with pytest.raises(TypeError, match=r".*Share your use case with.*"): + with pytest.raises(TypeError, match=r".*Share your usecase with.*"): bf_df.astype(123) -def test_agg_with_dict_lists_strings(scalars_dfs): +def test_agg_with_dict_lists(scalars_dfs): bf_df, pd_df = scalars_dfs agg_funcs = { "int64_too": ["min", "max"], @@ -6279,27 +5975,7 @@ def test_agg_with_dict_lists_strings(scalars_dfs): bf_result = bf_df.agg(agg_funcs).to_pandas() pd_result = pd_df.agg(agg_funcs) - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False - ) - - -@pytest.mark.skipif( - pandas.__version__.startswith("3"), - # See: https://github.com/python/cpython/issues/112282 - reason="pandas 3.0 miscaculates variance", -) -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) - - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -6314,7 +5990,7 @@ def test_agg_with_dict_list_and_str(scalars_dfs): bf_result = bf_df.agg(agg_funcs).to_pandas() pd_result = pd_df.agg(agg_funcs) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -6331,29 +6007,7 @@ def test_agg_with_dict_strs(scalars_dfs): pd_result = pd_df.agg(agg_funcs) pd_result.index = pd_result.index.astype("string[pyarrow]") - bigframes.testing.utils.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]}) - ) - - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -6367,20 +6021,3 @@ def test_agg_with_dict_containing_non_existing_col_raise_key_error(scalars_dfs): with pytest.raises(KeyError): bf_df.agg(agg_funcs) - - -def test_empty_agg_projection_succeeds(): - # Tests that the compiler generates a SELECT 1 fallback for empty aggregations, - # protecting against BigQuery syntax errors when both groups and metrics are empty. - import importlib - - bq = importlib.import_module( - "bigframes_vendored.ibis.backends.sql.compilers.bigquery" - ) - sg = importlib.import_module("bigframes_vendored.sqlglot") - - compiler = bq.BigQueryCompiler() - res = compiler.visit_Aggregate( - "op", parent=sg.table("parent_table"), groups=[], metrics=[] - ) - assert "SELECT 1" in res.sql() diff --git a/tests/system/small/test_dataframe_io.py b/tests/system/small/test_dataframe_io.py index ef21e929afa..1d6ae370c59 100644 --- a/tests/system/small/test_dataframe_io.py +++ b/tests/system/small/test_dataframe_io.py @@ -12,27 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -import typing from typing import Tuple import google.api_core.exceptions import numpy import numpy.testing import pandas as pd +import pandas.testing import pyarrow as pa import pytest + +import bigframes.dtypes as dtypes +from bigframes.testing import utils + +try: + import pandas_gbq # type: ignore +except ImportError: # pragma: NO COVER + # TODO(b/332758806): Run system tests without "extras" + pandas_gbq = None + +import typing + from google.cloud import bigquery import bigframes import bigframes.dataframe -import bigframes.dtypes as dtypes import bigframes.enums import bigframes.features import bigframes.pandas as bpd -import bigframes.testing -from bigframes.testing import utils - -pandas_gbq = pytest.importorskip("pandas_gbq") def test_sql_executes(scalars_df_default_index, bigquery_client): @@ -62,8 +69,7 @@ def test_sql_executes(scalars_df_default_index, bigquery_client): .sort_values("rowindex") .reset_index(drop=True) ) - bq_result["bytes_col"] = bq_result["bytes_col"].astype(dtypes.BYTES_DTYPE) - bigframes.testing.utils.assert_frame_equal(bf_result, bq_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, bq_result, check_dtype=False) def test_sql_executes_and_includes_named_index( @@ -94,8 +100,7 @@ def test_sql_executes_and_includes_named_index( .set_index("string_col") .sort_values("rowindex") ) - bq_result["bytes_col"] = bq_result["bytes_col"].astype(dtypes.BYTES_DTYPE) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, bq_result, check_dtype=False, check_index_type=False ) @@ -128,8 +133,7 @@ def test_sql_executes_and_includes_named_multiindex( .set_index(["string_col", "bool_col"]) .sort_values("rowindex") ) - bq_result["bytes_col"] = bq_result["bytes_col"].astype(dtypes.BYTES_DTYPE) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, bq_result, check_dtype=False, check_index_type=False ) @@ -253,6 +257,7 @@ 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 @@ -323,6 +328,7 @@ def test_to_pandas_dry_run(session, scalars_pandas_df_multi_index): 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 @@ -333,13 +339,6 @@ def test_to_arrow_override_global_option(scalars_df_index): 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 @@ -357,8 +356,8 @@ def test_to_pandas_batches_w_empty_dataframe(session): { "idx1": [], "idx2": [], - "col1": pd.Series([], dtype="string[pyarrow]"), - "col2": pd.Series([], dtype="Int64"), + "col1": pandas.Series([], dtype="string[pyarrow]"), + "col2": pandas.Series([], dtype="Int64"), }, session=session, ).set_index(["idx1", "idx2"], drop=True) @@ -367,93 +366,7 @@ def test_to_pandas_batches_w_empty_dataframe(session): assert len(results) == 1 assert list(results[0].index.names) == ["idx1", "idx2"] assert list(results[0].columns) == ["col1", "col2"] - bigframes.testing.utils.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 '{"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 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_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()) - - 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) - - -@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 - 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") - - # 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.""" - - sql = """ - SELECT - 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") - - # 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) + pandas.testing.assert_series_equal(results[0].dtypes, empty.dtypes) @pytest.mark.parametrize("allow_large_results", (True, False)) @@ -508,9 +421,8 @@ def test_to_csv_index( dtype = scalars_df.reset_index().dtypes.to_dict() dtype.pop("geography_col") dtype.pop("rowindex") - # read_csv will decode into bytes, numeric inproperly, convert_pandas_dtypes will encode properly from string + # read_csv will decode into bytes inproperly, convert_pandas_dtypes will encode properly from string dtype.pop("bytes_col") - dtype.pop("numeric_col") gcs_df = pd.read_csv( utils.get_first_file_from_wildcard(path), dtype=dtype, @@ -547,9 +459,8 @@ def test_to_csv_tabs( dtype = scalars_df.reset_index().dtypes.to_dict() dtype.pop("geography_col") dtype.pop("rowindex") - # read_csv will decode into bytes, numeric inproperly, convert_pandas_dtypes will encode properly from string + # read_csv will decode into bytes inproperly, convert_pandas_dtypes will encode properly from string dtype.pop("bytes_col") - dtype.pop("numeric_col") gcs_df = pd.read_csv( utils.get_first_file_from_wildcard(path), sep="\t", @@ -571,7 +482,7 @@ def test_to_csv_tabs( ("index"), [True, False], ) -@pytest.mark.skipif(pandas_gbq is None, reason="required by pandas_gbq.read_gbq") +@pytest.mark.skipif(pandas_gbq is None, reason="required by pd.read_gbq") 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 @@ -584,7 +495,7 @@ def test_to_gbq_w_index(scalars_dfs, dataset_id, index): index_col = None df_in.to_gbq(destination_table, if_exists="replace", index=index) - df_out = pandas_gbq.read_gbq(destination_table, index_col=index_col) + df_out = pd.read_gbq(destination_table, index_col=index_col) if index: df_out = df_out.sort_index() @@ -592,7 +503,7 @@ def test_to_gbq_w_index(scalars_dfs, dataset_id, index): df_out = df_out.sort_values("rowindex_2").reset_index(drop=True) utils.convert_pandas_dtypes(df_out, bytes_col=False) - # pandas_gbq.read_gbq interprets bytes_col as object, reconvert to pyarrow binary + # pd.read_gbq interprets bytes_col as object, reconvert to pyarrow binary df_out["bytes_col"] = df_out["bytes_col"].astype(pd.ArrowDtype(pa.binary())) expected = scalars_pandas_df.copy() expected.index.name = index_col @@ -604,7 +515,7 @@ def test_to_gbq_if_exists_is_fail(scalars_dfs, dataset_id): destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_fails" scalars_df.to_gbq(destination_table) - gcs_df = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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) @@ -621,27 +532,20 @@ def test_to_gbq_if_exists_is_replace(scalars_dfs, dataset_id): destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_replace" scalars_df.to_gbq(destination_table) - gcs_df = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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 = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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 but different column order - reordered_df = scalars_df[scalars_df.columns[::-1]] - reordered_df.to_gbq(destination_table, if_exists="replace") - gcs_df = pandas_gbq.read_gbq(destination_table, index_col="rowindex") - assert len(gcs_df) == len(scalars_pandas_df) - pd.testing.assert_index_equal(gcs_df.columns, reordered_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 = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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) @@ -651,20 +555,20 @@ def test_to_gbq_if_exists_is_append(scalars_dfs, dataset_id): destination_table = f"{dataset_id}.test_to_gbq_if_exists_is_append" scalars_df.to_gbq(destination_table) - gcs_df = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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 = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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 = pandas_gbq.read_gbq(destination_table, index_col="rowindex") + 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) @@ -849,8 +753,6 @@ def test_to_gbq_w_None_column_names( """Test the `to_gbq` API with None as a column name.""" destination_table = f"{dataset_id}.test_to_gbq_w_none_column_names" - # pandas 3.0 str datatypes produces nan instead of None, so cast to object - # scalars_df_index.columns = scalars_df_index.columns.astype(object) scalars_df_index = scalars_df_index.rename(columns={"int64_too": None}) scalars_df_index.to_gbq(destination_table, if_exists="replace") @@ -1009,51 +911,6 @@ def test_to_gbq_timedelta_tag_ignored_when_appending(bigquery_client, dataset_id assert table.schema[0].description is None -def test_to_gbq_obj_ref(session, dataset_id: str, bigquery_client): - import uuid - - import google.cloud.bigquery - - destination_table = f"{dataset_id}.test_to_gbq_obj_ref" - sql = """ - SELECT STRUCT('gs://cloud-samples-data/vision/ocr/sign.jpg' AS uri, CAST(NULL AS STRING) AS version, CAST(NULL AS STRING) AS authorizer, PARSE_JSON('{}') AS details) AS uri_col - """ - df_init = session.read_gbq(sql) - - tmp_table_id = f"{dataset_id}.tmp_obj_ref_{uuid.uuid4().hex}" - df_init.to_gbq(tmp_table_id, if_exists="replace") - - client = session.bqclient - table = client.get_table(tmp_table_id) - schema = list(table.schema) - for i, field in enumerate(schema): - if field.name == "uri_col": - schema[i] = google.cloud.bigquery.SchemaField( - name=field.name, - field_type=field.field_type, - mode=field.mode, - description="bigframes_dtype: OBJ_REF_DTYPE", - fields=field.fields, - ) - break - table.schema = schema - client.update_table(table, ["schema"]) - - df = session.read_gbq(tmp_table_id) - df = df.rename(columns={"uri_col": "obj_ref_col"}) - - df.to_gbq(destination_table, if_exists="replace") - - table = bigquery_client.get_table(destination_table) - obj_ref_field = next(f for f in table.schema if f.name == "obj_ref_col") - assert obj_ref_field.field_type == "RECORD" - assert obj_ref_field.description == "bigframes_dtype: OBJ_REF_DTYPE" - - reloaded_df = session.read_gbq(destination_table) - assert reloaded_df["obj_ref_col"].dtype == dtypes.OBJ_REF_DTYPE - assert len(reloaded_df) == 1 - - @pytest.mark.parametrize( ("index"), [True, False], @@ -1176,7 +1033,7 @@ def test_to_sql_query_unnamed_index_included( ) roundtrip = session.read_gbq(sql, index_col=idx_ids) roundtrip.index.names = [None] - utils.assert_frame_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) + utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df, check_index_type=False) def test_to_sql_query_named_index_included( @@ -1197,7 +1054,7 @@ def test_to_sql_query_named_index_included( columns="duration_col" ) roundtrip = session.read_gbq(sql, index_col=idx_ids) - utils.assert_frame_equal(roundtrip.to_pandas(), pd_df) + utils.assert_pandas_df_equal(roundtrip.to_pandas(), pd_df) def test_to_sql_query_unnamed_index_excluded( @@ -1214,7 +1071,7 @@ def test_to_sql_query_unnamed_index_excluded( columns="duration_col" ) roundtrip = session.read_gbq(sql) - utils.assert_frame_equal( + utils.assert_pandas_df_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) @@ -1237,7 +1094,7 @@ def test_to_sql_query_named_index_excluded( .drop(columns="duration_col") ) roundtrip = session.read_gbq(sql) - utils.assert_frame_equal( + utils.assert_pandas_df_equal( roundtrip.to_pandas(), pd_df, check_index_type=False, ignore_order=True ) diff --git a/tests/system/small/test_encryption.py b/tests/system/small/test_encryption.py index db87184371b..1f30df451d2 100644 --- a/tests/system/small/test_encryption.py +++ b/tests/system/small/test_encryption.py @@ -15,9 +15,9 @@ import random +from google.cloud import bigquery import pandas import pytest -from google.cloud import bigquery import bigframes import bigframes.ml.linear_model diff --git a/tests/system/small/test_groupby.py b/tests/system/small/test_groupby.py index 8dde3146434..5c89363e9bc 100644 --- a/tests/system/small/test_groupby.py +++ b/tests/system/small/test_groupby.py @@ -12,12 +12,11 @@ # 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 -import bigframes.testing.utils +from bigframes.testing.utils import assert_pandas_df_equal # ================= # DataFrame.groupby @@ -51,25 +50,14 @@ def test_dataframe_groupby_numeric_aggregate( pd_result = operator(scalars_pandas_df_index[col_names].groupby("string_col")) bf_result_computed = bf_result.to_pandas() # Pandas std function produces float64, not matching Float64 from bigframes - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_head(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("bool_col").head(2).to_pandas() pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").head(2) - bigframes.testing.utils.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 + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) def test_dataframe_groupby_median(scalars_df_index, scalars_pandas_df_index): @@ -101,47 +89,74 @@ def test_dataframe_groupby_quantile(scalars_df_index, scalars_pandas_df_index, q scalars_df_index[col_names].groupby("string_col").quantile(q) ).to_pandas() pd_result = scalars_pandas_df_index[col_names].groupby("string_col").quantile(q) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @pytest.mark.parametrize( - ("na_option", "method", "ascending", "pct"), + ("na_option", "method", "ascending"), [ ( "keep", "average", True, + ), + ( + "top", + "min", + False, + ), + ( + "bottom", + "max", + False, + ), + ( + "top", + "first", + False, + ), + ( + "bottom", + "dense", 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 + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, ): # TODO: supply a reason why this isn't compatible with pandas 1.x - pytest.importorskip("pandas", minversion="2.2.0") + 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) + .rank( + na_option=na_option, + method=method, + ascending=ascending, + ) ).to_pandas() pd_result = ( ( scalars_pandas_df_index[col_names] .groupby("string_col") - .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + .rank( + na_option=na_option, + method=method, + ascending=ascending, + ) ) .astype("float64") .astype("Float64") ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @@ -169,29 +184,7 @@ def test_dataframe_groupby_aggregate( pd_result = operator(scalars_pandas_df_index[col_names].groupby("string_col")) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.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() - - bigframes.testing.utils.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() - - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result, check_dtype=False, check_index_type=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @pytest.mark.parametrize( @@ -209,7 +202,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) - bigframes.testing.utils.assert_frame_equal( + assert_pandas_df_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) @@ -219,29 +212,22 @@ def test_dataframe_groupby_agg_size_string(scalars_df_index, scalars_pandas_df_i bf_result = scalars_df_index[col_names].groupby("string_col").agg("size") pd_result = scalars_pandas_df_index[col_names].groupby("string_col").agg("size") - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result.to_pandas(), check_dtype=False - ) + pd.testing.assert_series_equal(pd_result, bf_result.to_pandas(), check_dtype=False) 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", np.min, "size"]) + scalars_df_index[col_names].groupby("string_col").agg(["count", "min", "size"]) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(["count", np.min, "size"]) + .agg(["count", "min", "size"]) ) bf_result_computed = bf_result.to_pandas() - # 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") - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False, check_index_type=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_agg_list_w_column_multi_index( @@ -254,13 +240,11 @@ 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", np.min, "size"]) - pd_result = pd_df.groupby(level=0).agg(["count", np.min, "size"]) + bf_result = bf_df.groupby(level=0).agg(["count", "min", "size"]) + pd_result = pd_df.groupby(level=0).agg(["count", "min", "size"]) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @pytest.mark.parametrize( @@ -277,20 +261,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": [np.mean, np.max], "string_col": "count", "bool_col": "size"} - ) + .agg({"int64_too": ["mean", "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": [np.mean, np.max], "string_col": "count", "bool_col": "size"} - ) + .agg({"int64_too": ["mean", "max"], "string_col": "count", "bool_col": "size"}) ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, check_index_type=False ) @@ -300,18 +280,16 @@ 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": np.mean, "string_col": "count"}) + .agg({"int64_too": "mean", "string_col": "count"}) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg({"int64_too": np.mean, "string_col": "count"}) + .agg({"int64_too": "mean", "string_col": "count"}) ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_agg_named(scalars_df_index, scalars_pandas_df_index): @@ -320,7 +298,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", np.sum), + agg1=bpd.NamedAgg("int64_too", "sum"), agg2=bpd.NamedAgg("float64_col", "max"), ) ) @@ -328,15 +306,12 @@ 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", np.sum), - agg2=pd.NamedAgg("float64_col", "max"), + agg1=pd.NamedAgg("int64_too", "sum"), agg2=pd.NamedAgg("float64_col", "max") ) ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_agg_kw_tuples(scalars_df_index, scalars_pandas_df_index): @@ -345,20 +320,18 @@ 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", np.sum), + agg1=("int64_too", "sum"), agg2=("float64_col", "max"), ) ) pd_result = ( scalars_pandas_df_index[col_names] .groupby("string_col") - .agg(agg1=("int64_too", np.sum), agg2=("float64_col", "max")) + .agg(agg1=("int64_too", "sum"), agg2=("float64_col", "max")) ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @pytest.mark.parametrize( @@ -403,7 +376,7 @@ def test_dataframe_groupby_multi_sum( # BigQuery DataFrames default indices use nullable Int64 always pd_series.index = pd_series.index.astype("Int64") - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_series, bf_result, ) @@ -442,9 +415,7 @@ def test_dataframe_groupby_analytic( ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result_computed, check_dtype=False) @pytest.mark.parametrize( @@ -465,9 +436,7 @@ def test_dataframe_groupby_cumcount( ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_series_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_size_as_index_false( @@ -477,7 +446,7 @@ def test_dataframe_groupby_size_as_index_false( bf_result_computed = bf_result.to_pandas() pd_result = scalars_pandas_df_index.groupby("string_col", as_index=False).size() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, check_index_type=False ) @@ -489,9 +458,7 @@ def test_dataframe_groupby_size_as_index_true( pd_result = scalars_pandas_df_index.groupby("string_col", as_index=True).size() bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_series_equal(pd_result, bf_result_computed, check_dtype=False) def test_dataframe_groupby_skew(scalars_df_index, scalars_pandas_df_index): @@ -499,20 +466,21 @@ def test_dataframe_groupby_skew(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index[col_names].groupby("bool_col").skew().to_pandas() pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").skew() - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) -@pytest.mark.skipif( - not pd.__version__.startswith("3"), - reason="groupby.kurt not supported on legacy pandas versions", -) def test_dataframe_groupby_kurt(scalars_df_index, scalars_pandas_df_index): col_names = ["float64_col", "int64_col", "bool_col"] bf_result = scalars_df_index[col_names].groupby("bool_col").kurt().to_pandas() # Pandas doesn't have groupby.kurt yet: https://github.com/pandas-dev/pandas/issues/40139 - pd_result = scalars_pandas_df_index[col_names].groupby("bool_col").kurt() + pd_result = ( + scalars_pandas_df_index[col_names] + .groupby("bool_col") + .apply(pd.Series.kurt) + .drop("bool_col", axis=1) + ) - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -528,7 +496,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) - bigframes.testing.utils.assert_frame_equal( + assert_pandas_df_equal( pd_result, bf_result_computed, check_dtype=False, ignore_order=not ordered ) @@ -545,7 +513,7 @@ def test_dataframe_groupby_getitem( scalars_pandas_df_index[col_names].groupby("string_col")["int64_col"].min() ) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) def test_dataframe_groupby_getitem_error( @@ -576,7 +544,7 @@ def test_dataframe_groupby_getitem_list( scalars_pandas_df_index[col_names].groupby("string_col")[col_names].min() ) - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) def test_dataframe_groupby_getitem_list_error( @@ -609,15 +577,11 @@ def test_dataframe_groupby_nonnumeric_with_mean(): bf_result = bpd.DataFrame(df).groupby(["key1", "key2"]).mean().to_pandas() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, check_index_type=False, check_dtype=False ) -@pytest.mark.skipif( - pd.__version__.startswith("3"), - reason="value_counts behavior change b/485962498", -) @pytest.mark.parametrize( ("subset", "normalize", "ascending", "dropna", "as_index"), [ @@ -654,14 +618,10 @@ def test_dataframe_groupby_value_counts( ) if as_index: - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_dtype=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) else: pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_frame_equal( - pd_result, bf_result, check_dtype=False - ) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -687,7 +647,7 @@ def test_dataframe_groupby_first( .groupby(scalars_pandas_df_index.int64_col % 2) .first(numeric_only=numeric_only, min_count=min_count) ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -711,7 +671,7 @@ def test_dataframe_groupby_last( pd_result = scalars_pandas_df_index.groupby( scalars_pandas_df_index.int64_col % 2 ).last(numeric_only=numeric_only, min_count=min_count) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result, ) @@ -722,13 +682,6 @@ def test_dataframe_groupby_last( # ============== -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"), [ @@ -747,7 +700,7 @@ def test_series_groupby_agg_string(scalars_df_index, scalars_pandas_df_index, ag ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result_computed, check_dtype=False, check_names=False ) @@ -756,52 +709,56 @@ 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", np.mean, "size"]) + .agg(["sum", "mean", "size"]) ) pd_result = ( scalars_pandas_df_index["int64_col"] .groupby(scalars_pandas_df_index["string_col"]) - .agg(["sum", np.mean, "size"]) + .agg(["sum", "mean", "size"]) ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result, bf_result_computed, check_dtype=False, check_names=False ) @pytest.mark.parametrize( - ("na_option", "method", "ascending", "pct"), + ("na_option", "method", "ascending"), [ - ("keep", "average", True, False), + ( + "keep", + "average", + True, + ), ( "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 + scalars_df_index, + scalars_pandas_df_index, + na_option, + method, + ascending, ): # TODO: supply a reason why this isn't compatible with pandas 1.x pytest.importorskip("pandas", minversion="2.0.0") @@ -809,18 +766,26 @@ def test_series_groupby_rank( bf_result = ( scalars_df_index[col_names] .groupby("string_col")["int64_col"] - .rank(na_option=na_option, method=method, ascending=ascending, pct=pct) + .rank( + na_option=na_option, + method=method, + ascending=ascending, + ) ).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) + .rank( + na_option=na_option, + method=method, + ascending=ascending, + ) ) .astype("float64") .astype("Float64") ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) @@ -835,7 +800,7 @@ def test_series_groupby_head(scalars_df_index, scalars_pandas_df_index, dropna): pd_result = scalars_pandas_df_index.groupby("bool_col", dropna=dropna)[ "int64_too" ].head(1) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) def test_series_groupby_kurt(scalars_df_index, scalars_pandas_df_index): @@ -850,7 +815,7 @@ def test_series_groupby_kurt(scalars_df_index, scalars_pandas_df_index): pd.Series.kurt ) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) def test_series_groupby_size(scalars_df_index, scalars_pandas_df_index): @@ -864,9 +829,7 @@ def test_series_groupby_size(scalars_df_index, scalars_pandas_df_index): ) bf_result_computed = bf_result.to_pandas() - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result_computed, check_dtype=False - ) + pd.testing.assert_series_equal(pd_result, bf_result_computed, check_dtype=False) def test_series_groupby_skew(scalars_df_index, scalars_pandas_df_index): @@ -882,7 +845,7 @@ def test_series_groupby_skew(scalars_df_index, scalars_pandas_df_index): .skew() ) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -897,15 +860,11 @@ def test_series_groupby_quantile(scalars_df_index, scalars_pandas_df_index, q): scalars_df_index.groupby("string_col")["int64_col"].quantile(q) ).to_pandas() pd_result = scalars_pandas_df_index.groupby("string_col")["int64_col"].quantile(q) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, check_dtype=False, check_index_type=False ) -@pytest.mark.skipif( - pd.__version__.startswith("3"), - reason="Pandas 3 change value_counts behavior", -) @pytest.mark.parametrize( ("normalize", "ascending", "dropna"), [ @@ -938,7 +897,7 @@ def test_series_groupby_value_counts( pd_result = scalars_pandas_df_index.groupby("bool_col")["string_col"].value_counts( normalize=normalize, ascending=ascending, dropna=dropna ) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize( @@ -959,7 +918,7 @@ def test_series_groupby_first( pd_result = scalars_pandas_df_index.groupby("string_col")["int64_col"].first( numeric_only=numeric_only, min_count=min_count ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -983,92 +942,4 @@ def test_series_groupby_last( pd_result = scalars_pandas_df_index.groupby("string_col")["int64_col"].last( numeric_only=numeric_only, min_count=min_count ) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result) - - -def test_series_groupby_agg_transpile_system(scalars_df_index, scalars_pandas_df_index): - def custom_agg(s): - return s.sum() - s.mean() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna(subset=["int64_col", "bool_col"]) - - with bpd.option_context("experiments.enable_python_transpiler", True): - bf_result = bf_df.groupby("bool_col")["int64_col"].agg(custom_agg).to_pandas() - pd_result = pd_df.groupby("bool_col")["int64_col"].agg(custom_agg) - - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) - - -def test_dataframe_groupby_agg_transpile_system( - scalars_df_index, scalars_pandas_df_index -): - def custom_agg(s): - return (s.max() - s.min()) / s.count() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "int64_too", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna( - subset=["int64_col", "int64_too", "bool_col"] - ) - - with bpd.option_context("experiments.enable_python_transpiler", True): - bf_result = ( - bf_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .agg(custom_agg) - .to_pandas() - ) - pd_result = ( - pd_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .agg(custom_agg) - ) - - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) - - -def test_series_groupby_transform_transpile_system( - scalars_df_index, scalars_pandas_df_index -): - def custom_transform(s): - return s - s.mean() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna(subset=["int64_col", "bool_col"]) - - with bpd.option_context("experiments.enable_python_transpiler", True): - bf_result = ( - bf_df.groupby("bool_col")["int64_col"] - .transform(custom_transform) - .to_pandas() - ) - pd_result = pd_df.groupby("bool_col")["int64_col"].transform(custom_transform) - - bigframes.testing.utils.assert_series_equal(pd_result, bf_result, check_dtype=False) - - -def test_dataframe_groupby_transform_transpile_system( - scalars_df_index, scalars_pandas_df_index -): - def custom_transform(s): - return (s - s.min()) / (s.max() - s.min()) - - bf_df = scalars_df_index.dropna(subset=["int64_col", "int64_too", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna( - subset=["int64_col", "int64_too", "bool_col"] - ) - - with bpd.option_context("experiments.enable_python_transpiler", True): - bf_result = ( - bf_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .transform(custom_transform) - .to_pandas() - ) - pd_result = ( - pd_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .transform(custom_transform) - ) - - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + 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 26ac609b3c6..a82bdf76351 100644 --- a/tests/system/small/test_index.py +++ b/tests/system/small/test_index.py @@ -18,8 +18,8 @@ import pandas as pd import pytest -import bigframes.pandas as bpd from bigframes import dtypes +import bigframes.pandas as bpd from bigframes.testing.utils import assert_pandas_index_equal_ignore_index_type @@ -638,12 +638,6 @@ def test_index_item_with_empty(session): 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"), [ @@ -668,56 +662,3 @@ def test_custom_index_setitem_error(): 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 index b4d7c06da52..306b15e67a2 100644 --- a/tests/system/small/test_index_io.py +++ b/tests/system/small/test_index_io.py @@ -18,6 +18,7 @@ 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), @@ -42,6 +43,7 @@ def test_to_pandas_dry_run(scalars_df_index): 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), diff --git a/tests/system/small/test_ipython.py b/tests/system/small/test_ipython.py index 2d233907181..be98ce00674 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 == 0 + assert test_df._block.retrieve_repr_request_results.cache_info().hits >= 1 diff --git a/tests/system/small/test_large_local_data.py b/tests/system/small/test_large_local_data.py index 39885ea853c..0c03a8b6a3b 100644 --- a/tests/system/small/test_large_local_data.py +++ b/tests/system/small/test_large_local_data.py @@ -17,7 +17,7 @@ import pytest import bigframes -from bigframes.testing.utils import assert_frame_equal +from bigframes.testing.utils import assert_pandas_df_equal large_dataframe = pd.DataFrame(np.random.rand(10000, 10), dtype="Float64") large_dataframe.index = large_dataframe.index.astype("Int64") @@ -27,7 +27,7 @@ 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()) + assert_pandas_df_equal(large_dataframe, bf_df.to_pandas()) def test_read_pandas_defer_cumsum(session: bigframes.Session): @@ -35,7 +35,7 @@ def test_read_pandas_defer_cumsum(session: bigframes.Session): 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()) + assert_pandas_df_equal(large_dataframe.cumsum(), bf_df.to_pandas()) def test_read_pandas_defer_cache_cumsum_cumsum(session: bigframes.Session): @@ -43,7 +43,7 @@ def test_read_pandas_defer_cache_cumsum_cumsum(session: bigframes.Session): 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()) + assert_pandas_df_equal(large_dataframe.cumsum().cumsum(), bf_df.to_pandas()) def test_read_pandas_defer_peek(session: bigframes.Session): @@ -52,4 +52,4 @@ def test_read_pandas_defer_peek(session: bigframes.Session): bf_result = bf_df.peek(15) assert len(bf_result) == 15 - assert_frame_equal(large_dataframe.loc[bf_result.index], bf_result) + assert_pandas_df_equal(large_dataframe.loc[bf_result.index], bf_result) diff --git a/tests/system/small/test_magics.py b/tests/system/small/test_magics.py deleted file mode 100644 index eac0f233f98..00000000000 --- a/tests/system/small/test_magics.py +++ /dev/null @@ -1,100 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 - -import bigframes -import bigframes.pandas as bpd - -IPython = pytest.importorskip("IPython") - - -MAGIC_NAME = "bqsql" - - -@pytest.fixture(scope="module") -def ip(): - """Provides a persistent IPython shell instance for the test session.""" - from IPython.testing.globalipapp import get_ipython - - shell = get_ipython() - shell.extension_manager.load_extension("bigframes") - return shell - - -def test_magic_select_lit_to_var(ip): - bigframes.close_session() - - line = "dst_var" - cell_body = "SELECT 3" - - ip.run_cell_magic(MAGIC_NAME, line, cell_body) - - assert "dst_var" in ip.user_ns - result_df = ip.user_ns["dst_var"] - assert result_df.shape == (1, 1) - assert result_df.to_pandas().iloc[0, 0] == 3 - - -def test_magic_select_lit_dry_run(ip): - bigframes.close_session() - - line = "dst_var --dry_run" - cell_body = "SELECT 3" - - ip.run_cell_magic(MAGIC_NAME, line, cell_body) - - assert "dst_var" in ip.user_ns - result_df = ip.user_ns["dst_var"] - assert result_df.totalBytesProcessed == 0 - - -def test_magic_select_lit_display(ip): - from IPython.utils.capture import capture_output - - bigframes.close_session() - - cell_body = "SELECT 3" - - with capture_output() as io: - ip.run_cell_magic(MAGIC_NAME, "", cell_body) - assert len(io.outputs) > 0 - # Check that the output has data, regardless of the format (html, plain, etc) - available_formats = io.outputs[0].data.keys() - assert len(available_formats) > 0 - - -def test_magic_select_interpolate(ip): - bigframes.close_session() - df = bpd.read_pandas( - pd.DataFrame({"col_a": [1, 2, 3, 4, 5, 6], "col_b": [1, 2, 1, 3, 1, 2]}) - ) - const_val = 1 - - ip.push({"df": df, "const_val": const_val}) - - query = """ - SELECT - SUM(col_a) AS total - FROM - {df} - WHERE col_b={const_val} - """ - - ip.run_cell_magic(MAGIC_NAME, "dst_var", query) - - assert "dst_var" in ip.user_ns - result_df = ip.user_ns["dst_var"] - assert result_df.shape == (1, 1) - assert result_df.loc[0, "total"] == 9 diff --git a/tests/system/small/test_multiindex.py b/tests/system/small/test_multiindex.py index 18368fc5126..f15b8d8b21a 100644 --- a/tests/system/small/test_multiindex.py +++ b/tests/system/small/test_multiindex.py @@ -17,7 +17,7 @@ import pytest import bigframes.pandas as bpd -import bigframes.testing.utils +from bigframes.testing.utils import assert_pandas_df_equal # Sample MultiIndex for testing DataFrames where() method. _MULTI_INDEX = pandas.MultiIndex.from_tuples( @@ -58,7 +58,7 @@ def test_multi_index_from_arrays(): names=[" 1index 1", "_1index 2"], ) assert bf_idx.names == pd_idx.names - bigframes.testing.utils.assert_index_equal(bf_idx.to_pandas(), pd_idx) + pandas.testing.assert_index_equal(bf_idx.to_pandas(), pd_idx) def test_read_pandas_multi_index_axes(): @@ -90,7 +90,7 @@ def test_read_pandas_multi_index_axes(): bf_df = bpd.DataFrame(pandas_df) bf_df_computed = bf_df.to_pandas() - bigframes.testing.utils.assert_frame_equal(bf_df_computed, pandas_df) + pandas.testing.assert_frame_equal(bf_df_computed, pandas_df) # Row Multi-index tests @@ -98,7 +98,7 @@ def test_set_multi_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.set_index(["bool_col", "int64_too"]).to_pandas() pd_result = scalars_pandas_df_index.set_index(["bool_col", "int64_too"]) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -110,7 +110,6 @@ def test_set_multi_index(scalars_df_index, scalars_pandas_df_index): ("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): @@ -125,9 +124,9 @@ def test_df_reset_multi_index(scalars_df_index, scalars_pandas_df_index, level, # Pandas uses int64 instead of Int64 (nullable) dtype. if pd_result.index.dtype != bf_result.index.dtype: - pd_result.index = pd_result.index.astype(bf_result.index.dtype) + pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -160,9 +159,9 @@ def test_series_reset_multi_index( pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) if drop: - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) else: - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_series_multi_index_idxmin(scalars_df_index, scalars_pandas_df_index): @@ -187,7 +186,7 @@ def test_binop_series_series_matching_multi_indices( bf_result = bf_left["int64_col"] + bf_right["int64_too"] pd_result = pd_left["int64_col"] + pd_right["int64_too"] - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( bf_result.sort_index().to_pandas(), pd_result.sort_index() ) @@ -203,7 +202,7 @@ def test_binop_df_series_matching_multi_indices( bf_result = bf_left[["int64_col", "int64_too"]].add(bf_right["int64_too"], axis=0) pd_result = pd_left[["int64_col", "int64_too"]].add(pd_right["int64_too"], axis=0) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result.sort_index().to_pandas(), pd_result.sort_index() ) @@ -217,7 +216,7 @@ def test_binop_multi_index_mono_index(scalars_df_index, scalars_pandas_df_index) bf_result = bf_left["int64_col"] + bf_right["int64_too"] pd_result = pd_left["int64_col"] + pd_right["int64_too"] - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pandas.testing.assert_series_equal(bf_result.to_pandas(), pd_result) def test_binop_overlapping_multi_indices(scalars_df_index, scalars_pandas_df_index): @@ -229,7 +228,7 @@ def test_binop_overlapping_multi_indices(scalars_df_index, scalars_pandas_df_ind bf_result = bf_left["int64_col"] + bf_right["int64_too"] pd_result = pd_left["int64_col"] + pd_right["int64_too"] - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( bf_result.sort_index().to_pandas(), pd_result.sort_index() ) @@ -245,7 +244,7 @@ def test_concat_compatible_multi_indices(scalars_df_index, scalars_pandas_df_ind bf_result = bpd.concat([bf_left, bf_right]) pd_result = pandas.concat([pd_left, pd_right]) - bigframes.testing.utils.assert_frame_equal(bf_result.to_pandas(), pd_result) + pandas.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) def test_concat_multi_indices_ignore_index(scalars_df_index, scalars_pandas_df_index): @@ -260,7 +259,7 @@ def test_concat_multi_indices_ignore_index(scalars_df_index, scalars_pandas_df_i # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal(bf_result.to_pandas(), pd_result) + pandas.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -277,7 +276,7 @@ def test_multi_index_loc_multi_row(scalars_df_index, scalars_pandas_df_index, ke ) pd_result = scalars_pandas_df_index.set_index(["int64_too", "string_col"]).loc[key] - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_multi_index_loc_single_row(scalars_df_index, scalars_pandas_df_index): @@ -288,7 +287,7 @@ def test_multi_index_loc_single_row(scalars_df_index, scalars_pandas_df_index): (2, "capitalize, This ") ] - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) def test_multi_index_getitem_bool(scalars_df_index, scalars_pandas_df_index): @@ -298,7 +297,7 @@ def test_multi_index_getitem_bool(scalars_df_index, scalars_pandas_df_index): bf_result = bf_frame[bf_frame["int64_col"] > 0].to_pandas() pd_result = pd_frame[pd_frame["int64_col"] > 0] - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -318,7 +317,7 @@ def test_df_multi_index_droplevel(scalars_df_index, scalars_pandas_df_index, lev bf_result = bf_frame.droplevel(level).to_pandas() pd_result = pd_frame.droplevel(level) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -338,7 +337,7 @@ def test_series_multi_index_droplevel(scalars_df_index, scalars_pandas_df_index, bf_result = bf_frame["string_col"].droplevel(level).to_pandas() pd_result = pd_frame["string_col"].droplevel(level) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -357,7 +356,7 @@ def test_multi_index_drop(scalars_df_index, scalars_pandas_df_index, labels, lev bf_result = bf_frame.drop(labels=labels, axis="index", level=level).to_pandas() pd_result = pd_frame.drop(labels=labels, axis="index", level=level) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -382,7 +381,7 @@ def test_df_multi_index_reorder_levels( bf_result = bf_frame.reorder_levels(order).to_pandas() pd_result = pd_frame.reorder_levels(order) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -407,7 +406,7 @@ def test_series_multi_index_reorder_levels( bf_result = bf_frame["string_col"].reorder_levels(order).to_pandas() pd_result = pd_frame["string_col"].reorder_levels(order) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) def test_df_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index): @@ -417,7 +416,7 @@ def test_df_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index): bf_result = bf_frame.swaplevel().to_pandas() pd_result = pd_frame.swaplevel() - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_series_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index): @@ -427,7 +426,7 @@ def test_series_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index) bf_result = bf_frame["string_col"].swaplevel(0, 2).to_pandas() pd_result = pd_frame["string_col"].swaplevel(0, 2) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) def test_multi_index_series_groupby(scalars_df_index, scalars_pandas_df_index): @@ -443,7 +442,7 @@ def test_multi_index_series_groupby(scalars_df_index, scalars_pandas_df_index): pd_frame["float64_col"].groupby([pd_frame.int64_col % 2, "bool_col"]).mean() ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -470,7 +469,7 @@ def test_multi_index_series_groupby_level( .mean() ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) def test_multi_index_dataframe_groupby(scalars_df_index, scalars_pandas_df_index): @@ -485,7 +484,7 @@ def test_multi_index_dataframe_groupby(scalars_df_index, scalars_pandas_df_index numeric_only=True ) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -514,16 +513,13 @@ def test_multi_index_dataframe_groupby_level_aggregate( ) # For as_index=False, pandas will drop index levels used as groupings # In the future, it will include this in the result, bigframes already does this behavior - if not pandas.__version__.startswith("3"): - if not as_index: - for col in index_cols: - if col in bf_result.columns: - bf_result = bf_result.drop(col, axis=1) + if not as_index: + for col in index_cols: + if col in bf_result.columns: + bf_result = bf_result.drop(col, axis=1) # Pandas will have int64 index, while bigquery will have Int64 when resetting - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) @pytest.mark.parametrize( @@ -556,7 +552,7 @@ def test_multi_index_dataframe_groupby_level_analytic( .cumsum(numeric_only=True) ) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) all_joins = pytest.mark.parametrize( @@ -586,7 +582,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) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -607,7 +603,7 @@ 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) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) def test_multi_index_dataframe_where_series_cond_none_other( @@ -635,7 +631,7 @@ def test_multi_index_dataframe_where_series_cond_none_other( bf_result = dataframe_bf.where(series_cond_bf).to_pandas() pd_result = dataframe_pd.where(series_cond_pd) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -671,7 +667,7 @@ def test_multi_index_dataframe_where_series_cond_dataframe_other( bf_result = dataframe_bf.where(series_cond_bf, dataframe_other_bf).to_pandas() pd_result = dataframe_pd.where(series_cond_pd, dataframe_other_pd) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -703,7 +699,7 @@ def test_multi_index_dataframe_where_dataframe_cond_constant_other( bf_result = dataframe_bf.where(dataframe_cond_bf, other).to_pandas() pd_result = dataframe_pd.where(dataframe_cond_pd, other) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -736,7 +732,7 @@ def test_multi_index_dataframe_where_dataframe_cond_dataframe_other( bf_result = dataframe_bf.where(dataframe_cond_bf, dataframe_other_bf).to_pandas() pd_result = dataframe_pd.where(dataframe_cond_pd, dataframe_other_pd) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, @@ -768,7 +764,7 @@ def test_multi_index_series_groupby_level_aggregate( .mean() ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -795,7 +791,7 @@ def test_multi_index_series_groupby_level_analytic( .cumsum() ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) def test_multi_index_series_rename_dict_same_type( @@ -810,7 +806,7 @@ def test_multi_index_series_rename_dict_same_type( "string_col" ].rename({1: 100, 2: 200}) - bigframes.testing.utils.assert_series_equal( + pandas.testing.assert_series_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -828,7 +824,7 @@ def test_multi_index_df_reindex(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index.set_index(["rowindex_2", "string_col"]).reindex( index=new_index ) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -846,15 +842,15 @@ def test_column_multi_index_getitem(scalars_df_index, scalars_pandas_df_index): bf_a = bf_df["a"].to_pandas() pd_a = pd_df["a"] - bigframes.testing.utils.assert_frame_equal(bf_a, pd_a) + pandas.testing.assert_frame_equal(bf_a, pd_a) bf_b = bf_df["b"].to_pandas() pd_b = pd_df["b"] - bigframes.testing.utils.assert_frame_equal(bf_b, pd_b) + pandas.testing.assert_frame_equal(bf_b, pd_b) bf_fullkey = bf_df[("a", "int64_too")].to_pandas() pd_fullkey = pd_df[("a", "int64_too")] - bigframes.testing.utils.assert_series_equal(bf_fullkey, pd_fullkey) + pandas.testing.assert_series_equal(bf_fullkey, pd_fullkey) def test_column_multi_index_concat(scalars_df_index, scalars_pandas_df_index): @@ -879,7 +875,7 @@ def test_column_multi_index_concat(scalars_df_index, scalars_pandas_df_index): bf_result = bpd.concat([bf_df1, bf_df2, bf_df1]).to_pandas() pd_result = pandas.concat([pd_df1, pd_df2, pd_df1]) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_column_multi_index_drop(scalars_df_index, scalars_pandas_df_index): @@ -892,7 +888,7 @@ def test_column_multi_index_drop(scalars_df_index, scalars_pandas_df_index): bf_a = bf_df.drop(("a", "int64_too"), axis=1).to_pandas() pd_a = pd_df.drop(("a", "int64_too"), axis=1) - bigframes.testing.utils.assert_frame_equal(bf_a, pd_a) + pandas.testing.assert_frame_equal(bf_a, pd_a) @pytest.mark.parametrize( @@ -916,7 +912,7 @@ def test_column_multi_index_assign(scalars_df_index, scalars_pandas_df_index, ke pd_result = pd_df.assign(**kwargs) # Pandas assign results in non-nullable dtype - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_column_multi_index_rename(scalars_df_index, scalars_pandas_df_index): @@ -930,7 +926,7 @@ def test_column_multi_index_rename(scalars_df_index, scalars_pandas_df_index): bf_result = bf_df.rename(columns={"b": "c"}).to_pandas() pd_result = pd_df.rename(columns={"b": "c"}) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -960,7 +956,7 @@ def test_column_multi_index_reset_index( # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pandas.Int64Dtype()) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_column_multi_index_binary_op(scalars_df_index, scalars_pandas_df_index): @@ -974,7 +970,7 @@ def test_column_multi_index_binary_op(scalars_df_index, scalars_pandas_df_index) bf_result = (bf_df[("a", "a")] + 3).to_pandas() pd_result = pd_df[("a", "a")] + 3 - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pandas.testing.assert_series_equal(bf_result, pd_result) def test_column_multi_index_any(): @@ -991,7 +987,7 @@ def test_column_multi_index_any(): pd_result = pd_df.isna().any() bf_result = bf_df.isna().any().to_pandas() - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result.reset_index(drop=False), pd_result.reset_index(drop=False), check_dtype=False, @@ -1011,9 +1007,7 @@ def test_column_multi_index_agg(scalars_df_index, scalars_pandas_df_index): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_frame_equal( - bf_result, pd_result, check_index_type=False - ) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_index_type=False) def test_column_multi_index_prefix_suffix(scalars_df_index, scalars_pandas_df_index): @@ -1027,7 +1021,7 @@ def test_column_multi_index_prefix_suffix(scalars_df_index, scalars_pandas_df_in bf_result = bf_df.add_prefix("prefixed_").add_suffix("_suffixed").to_pandas() pd_result = pd_df.add_prefix("prefixed_").add_suffix("_suffixed") - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_column_multi_index_cumsum(scalars_df_index, scalars_pandas_df_index): @@ -1043,7 +1037,7 @@ def test_column_multi_index_cumsum(scalars_df_index, scalars_pandas_df_index): bf_result = bf_df.cumsum().to_pandas() pd_result = pd_df.cumsum() - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -1076,7 +1070,7 @@ def test_column_multi_index_stack(level): # Pandas produces NaN, where bq dataframes produces pd.NA # Column ordering seems to depend on pandas version assert isinstance(pd_result, pandas.DataFrame) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1104,7 +1098,7 @@ def test_column_multi_index_melt(): pd_result = pd_df.melt() # BigFrames uses different string and int types, but values are identical - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_index_type=False, check_dtype=False ) @@ -1126,7 +1120,7 @@ def test_column_multi_index_unstack(scalars_df_index, scalars_pandas_df_index): # Pandas produces NaN, where bq dataframes produces pd.NA # Column ordering seems to depend on pandas version - bigframes.testing.utils.assert_series_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) def test_corr_w_multi_index(scalars_df_index, scalars_pandas_df_index): @@ -1147,7 +1141,7 @@ def test_corr_w_multi_index(scalars_df_index, scalars_pandas_df_index): # 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. - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1170,7 +1164,7 @@ def test_cov_w_multi_index(scalars_df_index, scalars_pandas_df_index): # BigFrames and Pandas differ in their data type handling: # - Column types: BigFrames uses Float64, Pandas uses float64. # - Index types: BigFrames uses string, Pandas uses object. - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -1249,7 +1243,7 @@ def test_column_multi_index_droplevel(scalars_df_index, scalars_pandas_df_index) bf_result = bf_df.droplevel(1, axis=1).to_pandas() pd_result = pd_df.droplevel(1, axis=1) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_df_column_multi_index_reindex(scalars_df_index, scalars_pandas_df_index): @@ -1271,7 +1265,7 @@ def test_df_column_multi_index_reindex(scalars_df_index, scalars_pandas_df_index # Pandas uses float64 as default for newly created empty column, bf uses Float64 pd_result[("z", "a")] = pd_result[("z", "a")].astype(pandas.Float64Dtype()) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result, pd_result, ) @@ -1290,7 +1284,7 @@ def test_column_multi_index_reorder_levels(scalars_df_index, scalars_pandas_df_i bf_result = bf_df.reorder_levels([-2, -1, 0], axis=1).to_pandas() pd_result = pd_df.reorder_levels([-2, -1, 0], axis=1) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1307,7 +1301,7 @@ def test_df_multi_index_unstack(hockey_df, hockey_pandas_df, level): ["team_name", "position"], append=True ).unstack(level=level) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -1324,7 +1318,7 @@ def test_series_multi_index_unstack(hockey_df, hockey_pandas_df, level): "number" ].unstack(level=level) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) def test_column_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index): @@ -1340,7 +1334,7 @@ def test_column_multi_index_swaplevel(scalars_df_index, scalars_pandas_df_index) bf_result = bf_df.swaplevel(-3, -1, axis=1).to_pandas() pd_result = pd_df.swaplevel(-3, -1, axis=1) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pandas.testing.assert_frame_equal(bf_result, pd_result) def test_df_multi_index_dot_not_supported(): @@ -1414,7 +1408,7 @@ def test_explode_w_column_multi_index(): assert isinstance(pd_df, pandas.DataFrame) assert isinstance(pd_df["col0"], pandas.DataFrame) - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( df["col0"].explode("col00").to_pandas(), pd_df["col0"].explode("col00"), check_dtype=False, @@ -1432,7 +1426,7 @@ def test_explode_w_multi_index(): df = bpd.DataFrame(data, index=multi_index, columns=columns) pd_df = df.to_pandas() - bigframes.testing.utils.assert_frame_equal( + pandas.testing.assert_frame_equal( df.explode("col00").to_pandas(), pd_df.explode("col00"), check_dtype=False, @@ -1456,7 +1450,7 @@ 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) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, check_dtype=False) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -1480,44 +1474,3 @@ def test_multi_index_contains(scalars_df_index, scalars_pandas_df_index, key): 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) - - bigframes.testing.utils.assert_index_equal( - pandas.Index(pd_result, dtype="boolean"), bf_result.to_pandas() - ) - - -def test_count_empty_multiindex_columns(session): - df = pandas.DataFrame( - [], index=[1, 2], columns=pandas.MultiIndex.from_tuples([], names=["a", "b"]) - ) - bdf = session.read_pandas(df) - - # count() operation unpivots columns, triggering the empty MultiIndex bug internally - count_df = bdf.count() - - # The local fix ensures that empty unpivoted columns generate properly typed NULLs - # rather than failing syntax validation downstream in BigQuery. - # We compile to `.sql` to verify it succeeds locally without evaluating on BigQuery natively. - _ = count_df.to_frame().sql - - # Assert structural layout is correct - assert count_df.index.nlevels == 2 - assert list(count_df.index.names) == ["a", "b"] - - -def test_dataframe_melt_multiindex(session): - # Tests that `melt` operations via count do not cause MultiIndex drops in Arrow - df = pandas.DataFrame({"A": [1], "B": ["string"], "C": [3]}) - df.columns = pandas.MultiIndex.from_tuples( - [("Group1", "A"), ("Group2", "B"), ("Group1", "C")] - ) - bdf = session.read_pandas(df) - - count_df = bdf.count().to_pandas() - assert count_df.shape[0] == 3 diff --git a/tests/system/small/test_null_index.py b/tests/system/small/test_null_index.py index eb9dc114dde..a1c7c0f1a3f 100644 --- a/tests/system/small/test_null_index.py +++ b/tests/system/small/test_null_index.py @@ -13,8 +13,6 @@ # limitations under the License. -import io - import pandas as pd import pytest @@ -46,38 +44,6 @@ 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 = ( @@ -381,6 +347,7 @@ def test_null_index_df_concat(scalars_df_null_index, scalars_pandas_df_default_i def test_null_index_map_dict_input( scalars_df_null_index, scalars_pandas_df_default_index ): + local_map = dict() # construct a local map, incomplete to cover behavior for s in scalars_pandas_df_default_index.string_col[:-3]: diff --git a/tests/system/small/test_numpy.py b/tests/system/small/test_numpy.py index 774f72bef4a..37a707b9d08 100644 --- a/tests/system/small/test_numpy.py +++ b/tests/system/small/test_numpy.py @@ -16,8 +16,6 @@ import pandas as pd import pytest -import bigframes.testing.utils - @pytest.mark.parametrize( ("opname",), @@ -39,17 +37,13 @@ ("log10",), ("sqrt",), ("abs",), - ("isnan",), - ("isfinite",), ], ) def test_series_ufuncs(floats_pd, floats_bf, opname): bf_result = getattr(np, opname)(floats_bf).to_pandas() pd_result = getattr(np, opname)(floats_pd) - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, nulls_are_nan=True - ) + pd.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -83,7 +77,7 @@ def test_df_ufuncs(scalars_dfs, opname): ): pd_result["int64_col"] = pd_result["int64_col"].astype(pd.Float64Dtype()) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, nulls_are_nan=True) + pd.testing.assert_frame_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -103,7 +97,7 @@ def test_df_binary_ufuncs(scalars_dfs, opname): bf_result = op(scalars_df[["float64_col", "int64_col"]], 5.1).to_pandas() pd_result = op(scalars_pandas_df[["float64_col", "int64_col"]], 5.1) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, nulls_are_nan=True) + pd.testing.assert_frame_equal(bf_result, pd_result) # Operations tested here don't work on full dataframe in numpy+pandas @@ -135,9 +129,7 @@ def test_series_binary_ufuncs(scalars_dfs, x, y, opname): bf_result = op(scalars_df[x], scalars_df[y]).to_pandas() pd_result = op(scalars_pandas_df[x], scalars_pandas_df[y]) - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, nulls_are_nan=True - ) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_binary_ufuncs_reverse(scalars_dfs): @@ -147,9 +139,7 @@ def test_series_binary_ufuncs_reverse(scalars_dfs): bf_result = np.subtract(5.1, scalars_df["int64_col"]).to_pandas() pd_result = np.subtract(5.1, scalars_pandas_df["int64_col"]) - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, nulls_are_nan=True - ) + pd.testing.assert_series_equal(bf_result, pd_result) def test_df_binary_ufuncs_reverse(scalars_dfs): @@ -162,4 +152,4 @@ def test_df_binary_ufuncs_reverse(scalars_dfs): scalars_pandas_df[["float64_col", "int64_col"]], ) - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result, nulls_are_nan=True) + pd.testing.assert_frame_equal(bf_result, pd_result) diff --git a/tests/system/small/test_pandas.py b/tests/system/small/test_pandas.py index 356e498021b..550a75e1bba 100644 --- a/tests/system/small/test_pandas.py +++ b/tests/system/small/test_pandas.py @@ -12,8 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. -import typing from datetime import datetime +import re +import typing import pandas as pd import pyarrow as pa @@ -21,8 +22,7 @@ import pytz import bigframes.pandas as bpd -import bigframes.testing -from bigframes.testing.utils import assert_frame_equal, assert_series_equal +from bigframes.testing.utils import assert_pandas_df_equal @pytest.mark.parametrize( @@ -38,7 +38,7 @@ def test_concat_dataframe(scalars_dfs, ordered): bf_result = bf_result.to_pandas(ordered=ordered) pd_result = pd.concat(11 * [scalars_pandas_df]) - assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) def test_concat_dataframe_w_struct_cols(nested_structs_df, nested_structs_pandas_df): @@ -51,84 +51,6 @@ def test_concat_dataframe_w_struct_cols(nested_structs_df, nested_structs_pandas pd.testing.assert_frame_equal(bf_result, pd_result) -def test_nested_structs_dtypes_and_edge_cases(nested_structs_df): - """Explicitly verify dtypes and edge case values for all supported types.""" - import datetime as dt - import decimal - - import numpy as np - import pandas as pd - - import bigframes.dtypes as bfd - - # 1. Verify BigFrames dtypes - expected_bf_dtypes = { - "person": nested_structs_df["person"].dtype, - "bool_col": bfd.BOOL_DTYPE, - "int64_col": bfd.INT_DTYPE, - "float64_col": bfd.FLOAT_DTYPE, - "string_col": bfd.STRING_DTYPE, - "json_col": bfd.JSON_DTYPE, - "date_col": bfd.DATE_DTYPE, - "time_col": bfd.TIME_DTYPE, - "datetime_col": bfd.DATETIME_DTYPE, - "timestamp_col": bfd.TIMESTAMP_DTYPE, - "bytes_col": bfd.BYTES_DTYPE, - "numeric_col": bfd.NUMERIC_DTYPE, - "bignumeric_col": bfd.BIGNUMERIC_DTYPE, - "geography_col": bfd.GEO_DTYPE, - "duration_col": bfd.TIMEDELTA_DTYPE, - } - - for col_name, expected_dtype in expected_bf_dtypes.items(): - assert nested_structs_df[col_name].dtype == expected_dtype, ( - f"Dtype mismatch for {col_name}" - ) - - # 2. Convert to pandas for value assertions - pd_df = nested_structs_df.to_pandas() - - # Verify we have 6 rows - assert len(pd_df) == 6 - - # Row 1: Normal typical values - assert pd_df.loc[1, "bool_col"] == True - assert pd_df.loc[1, "int64_col"] == 123456789 - assert pd_df.loc[1, "float64_col"] == 1.25 - assert pd_df.loc[1, "string_col"] == "Hello World" - assert pd_df.loc[1, "json_col"] == '{"a":1,"b":[1,2]}' - assert pd_df.loc[1, "date_col"] == dt.date(2026, 6, 24) - - # Row 2: Min bounds / negative infinity - assert pd_df.loc[2, "int64_col"] == -9223372036854775808 - assert pd_df.loc[2, "float64_col"] == float("-inf") - assert pd_df.loc[2, "numeric_col"] == decimal.Decimal( - "-99999999999999999999999999999.999999999" - ) - - # Row 3: Max bounds / infinity - assert pd_df.loc[3, "int64_col"] == 9223372036854775807 - assert pd_df.loc[3, "float64_col"] == float("inf") - - # Row 4: SQL NULLs (omitted keys) - assert pd.isna(pd_df.loc[4, "bool_col"]) - assert pd.isna(pd_df.loc[4, "int64_col"]) - assert pd.isna(pd_df.loc[4, "float64_col"]) - assert pd.isna(pd_df.loc[4, "json_col"]) - assert pd.isna(pd_df.loc[4, "geography_col"]) - - # Row 5: Special edge cases (NaN, empty, multiline) - assert np.isnan(pd_df.loc[5, "float64_col"]) - assert pd_df.loc[5, "float64_col"] is not pd.NA - assert not pd_df["float64_col"].isna().loc[5] - assert pd_df.loc[5, "string_col"] == 'Line 1\nLine 2\n"Quotes"' - assert pd_df.loc[5, "bytes_col"] == b"\x00" - - # Row 6: JSON null literal - assert pd_df.loc[6, "json_col"] == "null" - assert not pd_df["json_col"].isna().loc[6] - - def test_concat_series(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs bf_result = bpd.concat( @@ -143,7 +65,7 @@ def test_concat_series(scalars_dfs): ] ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -180,7 +102,7 @@ def test_get_dummies_dataframe(scalars_dfs, kwargs): # dtype argument above is needed for pandas v1 only # adjust for expected dtype differences - for column_name, type_name in zip(pd_result.columns, pd_result.dtypes): + for (column_name, type_name) in zip(pd_result.columns, pd_result.dtypes): if type_name == "bool": pd_result[column_name] = pd_result[column_name].astype("boolean") @@ -194,30 +116,22 @@ def test_get_dummies_dataframe_duplicate_labels(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs scalars_renamed_df = scalars_df.rename( - columns={ - "int64_too": "int64_col", - "float64_col": "dup_col", - "string_col": "dup_col", - } + columns={"int64_too": "int64_col", "float64_col": None, "string_col": None} ) scalars_renamed_pandas_df = scalars_pandas_df.rename( - columns={ - "int64_too": "int64_col", - "float64_col": "dup_col", - "string_col": "dup_col", - } + columns={"int64_too": "int64_col", "float64_col": None, "string_col": None} ) bf_result = bpd.get_dummies( - scalars_renamed_df, columns=["int64_col", "dup_col"], dtype=bool + scalars_renamed_df, columns=["int64_col", None], dtype=bool ) pd_result = pd.get_dummies( - scalars_renamed_pandas_df, columns=["int64_col", "dup_col"], dtype=bool + scalars_renamed_pandas_df, columns=["int64_col", None], dtype=bool ) # dtype argument above is needed for pandas v1 only # adjust for expected dtype differences - for column_name, type_name in zip(pd_result.columns, pd_result.dtypes): + for (column_name, type_name) in zip(pd_result.columns, pd_result.dtypes): if type_name == "bool": pd_result[column_name] = pd_result[column_name].astype("boolean") @@ -234,7 +148,7 @@ def test_get_dummies_series(scalars_dfs): # dtype argument above is needed for pandas v1 only # adjust for expected dtype differences - for column_name, type_name in zip(pd_result.columns, pd_result.dtypes): + for (column_name, type_name) in zip(pd_result.columns, pd_result.dtypes): if type_name == "bool": # pragma: NO COVER pd_result[column_name] = pd_result[column_name].astype("boolean") pd_result.columns = pd_result.columns.astype(object) @@ -255,7 +169,7 @@ def test_get_dummies_series_nameless(scalars_dfs): # dtype argument above is needed for pandas v1 only # adjust for expected dtype differences - for column_name, type_name in zip(pd_result.columns, pd_result.dtypes): + for (column_name, type_name) in zip(pd_result.columns, pd_result.dtypes): if type_name == "bool": # pragma: NO COVER pd_result[column_name] = pd_result[column_name].astype("boolean") pd_result.columns = pd_result.columns.astype(object) @@ -393,7 +307,7 @@ def test_merge(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -427,7 +341,7 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) def test_merge_cross(scalars_dfs): @@ -482,7 +396,7 @@ def test_merge_series(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) def test_merge_w_common_columns(scalars_dfs): @@ -500,7 +414,7 @@ def test_merge_w_common_columns(scalars_dfs): "inner", sort=True, ) - assert_frame_equal(df.to_pandas(), pd_result, ignore_order=True) + assert_pandas_df_equal(df.to_pandas(), pd_result, ignore_order=True) def test_merge_raises_error_when_no_common_columns(scalars_dfs): @@ -526,7 +440,10 @@ def test_merge_raises_error_when_left_right_on_set(scalars_dfs): left = scalars_df[left_columns] right = scalars_df[right_columns] - with pytest.raises(ValueError): + with pytest.raises( + ValueError, + match=re.escape("Can not pass both `on` and `left_on` + `right_on` params."), + ): bpd.merge( left, right, @@ -537,72 +454,6 @@ def test_merge_raises_error_when_left_right_on_set(scalars_dfs): ) -def test_crosstab_aligned_series(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - 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() - - 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 @@ -619,9 +470,7 @@ def _convert_pandas_category(pd_s: pd.Series): 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 - ) or pd.api.types.is_string_dtype(pd_s.cat.categories.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): @@ -637,9 +486,9 @@ def _convert_pandas_category(pd_s: pd.Series): right_key = "right_inclusive" subtype = pd_s.cat.categories.dtype.subtype # type: ignore - if pd.api.types.is_float_dtype(subtype): # type: ignore + if pd.api.types.is_float_dtype(subtype): interval_dtype = pa.float64() - elif pd.api.types.is_integer_dtype(subtype): # type: ignore + elif pd.api.types.is_integer_dtype(subtype): interval_dtype = pa.int64() else: raise ValueError(f"Unknown category type: {subtype}") @@ -671,18 +520,6 @@ def _convert_pandas_category(pd_s: pd.Series): ) -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) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) - - @pytest.mark.parametrize( ("right", "labels"), [ @@ -699,7 +536,7 @@ def test_cut_by_int_bins(scalars_dfs, labels, right): bf_result = bpd.cut(scalars_df["float64_col"], 5, labels=labels, right=right) pd_result = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) def test_cut_by_int_bins_w_labels(scalars_dfs): @@ -710,7 +547,7 @@ def test_cut_by_int_bins_w_labels(scalars_dfs): bf_result = bpd.cut(scalars_df["float64_col"], 5, labels=labels) pd_result = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -753,7 +590,7 @@ def test_cut_by_numeric_breaks(scalars_dfs, breaks, right, labels): ).to_pandas() pd_result_converted = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result_converted) + pd.testing.assert_series_equal(bf_result, pd_result_converted) def test_cut_by_numeric_breaks_w_labels(scalars_dfs): @@ -765,7 +602,7 @@ def test_cut_by_numeric_breaks_w_labels(scalars_dfs): bf_result = bpd.cut(scalars_df["float64_col"], bins, labels=labels) pd_result = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -805,7 +642,7 @@ def test_cut_by_interval_bins(scalars_dfs, bins, right, labels): pd_result = pd.cut(scalars_pandas_df["int64_too"], bins, labels=labels, right=right) pd_result_converted = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result_converted) + pd.testing.assert_series_equal(bf_result, pd_result_converted) def test_cut_by_interval_bins_w_labels(scalars_dfs): @@ -817,7 +654,7 @@ def test_cut_by_interval_bins_w_labels(scalars_dfs): bf_result = bpd.cut(scalars_df["float64_col"], bins, labels=labels) pd_result = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -834,7 +671,7 @@ def test_cut_by_edge_cases_bins(scalars_dfs, bins, labels): pd_result = pd.cut(scalars_pandas_df["int64_too"], bins, labels=labels) pd_result_converted = _convert_pandas_category(pd_result) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result_converted) + pd.testing.assert_series_equal(bf_result, pd_result_converted) def test_cut_empty_array_raises_error(): @@ -863,7 +700,7 @@ def test_qcut(scalars_dfs, q): bf_result = bpd.qcut(scalars_df["float64_col"], q, labels=False, duplicates="drop") pd_result = pd_result.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -905,12 +742,10 @@ def test_to_datetime_iterable(arg, utc, unit, format): .to_pandas() .astype("datetime64[ns, UTC]" if utc else "datetime64[ns]") ) - pd_result = ( - pd.Series(pd.to_datetime(arg, utc=utc, unit=unit, format=format)) - .dt.floor("us") - .astype("datetime64[ns, UTC]" if utc else "datetime64[ns]") - ) - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series( + pd.to_datetime(arg, utc=utc, unit=unit, format=format) + ).dt.floor("us") + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -921,10 +756,8 @@ def test_to_datetime_series(scalars_dfs): bf_result = ( bpd.to_datetime(scalars_df[col], unit="s").to_pandas().astype("datetime64[s]") ) - pd_result = pd.Series(pd.to_datetime(scalars_pandas_df[col], unit="s")).astype( - "datetime64[s]" - ) - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series(pd.to_datetime(scalars_pandas_df[col], unit="s")) + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -945,12 +778,8 @@ def test_to_datetime_series(scalars_dfs): ) def test_to_datetime_unit_param(arg, unit): bf_result = bpd.to_datetime(arg, unit=unit).to_pandas().astype("datetime64[ns]") - pd_result = ( - pd.Series(pd.to_datetime(arg, unit=unit)) - .dt.floor("us") - .astype("datetime64[ns]") - ) - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series(pd.to_datetime(arg, unit=unit)).dt.floor("us") + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -970,12 +799,8 @@ def test_to_datetime_format_param(arg, utc, format): .to_pandas() .astype("datetime64[ns, UTC]" if utc else "datetime64[ns]") ) - pd_result = ( - pd.Series(pd.to_datetime(arg, utc=utc, format=format)) - .dt.floor("us") - .astype("datetime64[ns, UTC]" if utc else "datetime64[ns]") - ) - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series(pd.to_datetime(arg, utc=utc, format=format)).dt.floor("us") + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -1022,18 +847,13 @@ def test_to_datetime_format_param(arg, utc, format): ], ) def test_to_datetime_string_inputs(arg, utc, output_in_utc, format): - normalized_type = "datetime64[ns, UTC]" if output_in_utc else "datetime64[ns]" - bf_result = ( - bpd.to_datetime(arg, utc=utc, format=format).to_pandas().astype(normalized_type) - ) - pd_result = ( - pd.Series(pd.to_datetime(arg, utc=utc, format=format)) - .dt.floor("us") - .astype(normalized_type) + bpd.to_datetime(arg, utc=utc, format=format) + .to_pandas() + .astype("datetime64[ns, UTC]" if output_in_utc else "datetime64[ns]") ) - - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series(pd.to_datetime(arg, utc=utc, format=format)).dt.floor("us") + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -1070,14 +890,13 @@ def test_to_datetime_string_inputs(arg, utc, output_in_utc, format): ], ) def test_to_datetime_timestamp_inputs(arg, utc, output_in_utc): - normalized_type = "datetime64[ns, UTC]" if output_in_utc else "datetime64[ns]" - - bf_result = bpd.to_datetime(arg, utc=utc).to_pandas().astype(normalized_type) - pd_result = ( - pd.Series(pd.to_datetime(arg, utc=utc)).dt.floor("us").astype(normalized_type) + bf_result = ( + bpd.to_datetime(arg, utc=utc) + .to_pandas() + .astype("datetime64[ns, UTC]" if output_in_utc else "datetime64[ns]") ) - - bigframes.testing.utils.assert_series_equal( + pd_result = pd.Series(pd.to_datetime(arg, utc=utc)).dt.floor("us") + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, check_names=False ) @@ -1126,8 +945,10 @@ def test_to_timedelta_with_bf_integer_series(session, unit): .astype("timedelta64[ns]") ) - expected_result = pd.to_timedelta(pd_series, unit).astype("timedelta64[ns]") - assert_series_equal(actual_result, expected_result, check_index_type=False) + expected_result = pd.to_timedelta(pd_series, unit) + pd.testing.assert_series_equal( + actual_result, expected_result, check_index_type=False + ) def test_to_timedelta_with_bf_float_series_value_rounded_down(session): @@ -1139,10 +960,8 @@ def test_to_timedelta_with_bf_float_series_value_rounded_down(session): .astype("timedelta64[ns]") ) - expected_result = pd.Series([pd.Timedelta(1, "us"), pd.Timedelta(2, "us")]).astype( - "timedelta64[ns]" - ) - bigframes.testing.utils.assert_series_equal( + expected_result = pd.Series([pd.Timedelta(1, "us"), pd.Timedelta(2, "us")]) + pd.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -1162,8 +981,8 @@ def test_to_timedelta_with_list_like_input(session, input): .astype("timedelta64[ns]") ) - expected_result = pd.Series(pd.to_timedelta(input, "s")).astype("timedelta64[ns]") - bigframes.testing.utils.assert_series_equal( + expected_result = pd.Series(pd.to_timedelta(input, "s")) + pd.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) @@ -1193,7 +1012,7 @@ def test_to_timedelta_on_timedelta_series__should_be_no_op(scalars_dfs): bpd.to_timedelta(bf_series, unit="s").to_pandas().astype("timedelta64[ns]") ) - expected_result = pd.to_timedelta(pd_series, unit="s").astype("timedelta64[ns]") - bigframes.testing.utils.assert_series_equal( + expected_result = pd.to_timedelta(pd_series, unit="s") + pd.testing.assert_series_equal( actual_result, expected_result, check_index_type=False ) diff --git a/tests/system/small/test_pandas_options.py b/tests/system/small/test_pandas_options.py index a9ec4355f07..1d360e0d4f7 100644 --- a/tests/system/small/test_pandas_options.py +++ b/tests/system/small/test_pandas_options.py @@ -14,8 +14,8 @@ import datetime import re -import warnings from unittest import mock +import warnings import google.api_core.exceptions import pandas.testing @@ -50,6 +50,7 @@ def test_read_gbq_start_sets_session_location( 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"] @@ -279,17 +280,6 @@ def test_credentials_need_reauthentication( session = bpd.get_global_session() 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( @@ -313,12 +303,19 @@ def test_credentials_need_reauthentication( 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 - # The test forces a failure during cleanup and asserts that one or more warning is generated - # when/if multiple temp tables might have been left over. - assert len(warned) >= 1 + assert len(warned) == 1 assert warned[0].category == bigframes.exceptions.CleanupFailedWarning assert ( @@ -342,9 +339,8 @@ def test_max_rows_normal_execution_within_limit( 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"), + 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 @@ -363,14 +359,12 @@ def test_max_rows_normal_execution_within_limit( 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"), + 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"), + 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 index fad8d9dba2f..916780b1cea 100644 --- a/tests/system/small/test_polars_execution.py +++ b/tests/system/small/test_polars_execution.py @@ -11,13 +11,10 @@ # WITHOUT 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 +from bigframes.testing.utils import assert_pandas_df_equal polars = pytest.importorskip("polars") @@ -39,8 +36,8 @@ def test_polar_execution_sorted(session_w_polars, scalars_pandas_df_index): ] bf_result = bf_df.sort_index(ascending=False)[["int64_too", "bool_col"]].to_pandas() - assert session_w_polars._metrics.execution_count == execution_count_before + 1 - assert_frame_equal(bf_result, pd_result) + assert session_w_polars._metrics.execution_count == execution_count_before + assert_pandas_df_equal(bf_result, pd_result) def test_polar_execution_sorted_filtered(session_w_polars, scalars_pandas_df_index): @@ -56,8 +53,8 @@ def test_polar_execution_sorted_filtered(session_w_polars, scalars_pandas_df_ind .to_pandas() ) - assert session_w_polars._metrics.execution_count == execution_count_before + 1 - assert_frame_equal(bf_result, pd_result) + assert session_w_polars._metrics.execution_count == execution_count_before + assert_pandas_df_equal(bf_result, pd_result) def test_polar_execution_unsupported_sql_fallback( @@ -66,32 +63,13 @@ def test_polar_execution_unsupported_sql_fallback( 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 + 2) - assert math.isclose(bf_result.geo_area.sum(), 70.52332050, rel_tol=0.00001) - - -def test_polars_execution_history(session_w_polars): - import pandas as pd + pd_df = scalars_pandas_df_index.copy() + pd_df["str_len_col"] = pd_df.string_col.str.len() + pd_result = pd_df - # Create a small local DataFrame - pdf = pd.DataFrame({"col_a": [1, 2, 3], "col_b": ["x", "y", "z"]}) - - # Read simple local data - df = session_w_polars.read_pandas(pdf) - - # Trigger execution - _ = df.to_pandas() - - # Verify the execution history captured the local job - history = session_w_polars.execution_history().to_dataframe() - - # Verify we have at least one job and logged as polars - assert len(history) > 0 - last_job = history.iloc[-1] + bf_df["str_len_col"] = bf_df.string_col.str.len() + bf_result = bf_df.to_pandas() - assert last_job["job_type"] == "polars" - assert last_job["status"] == "DONE" + # str len not supported by polar engine yet, so falls back to bq execution + assert session_w_polars._metrics.execution_count == (execution_count_before + 1) + assert_pandas_df_equal(bf_result, pd_result) diff --git a/tests/system/small/test_progress_bar.py b/tests/system/small/test_progress_bar.py index a179e18332a..8a323831b5b 100644 --- a/tests/system/small/test_progress_bar.py +++ b/tests/system/small/test_progress_bar.py @@ -23,7 +23,7 @@ import bigframes.formatting_helpers as formatting_helpers from bigframes.session import MAX_INLINE_DF_BYTES -job_load_message_regex = r"Query" +job_load_message_regex = r"\w+ job [\w-]+ is \w+\." EXPECTED_DRY_RUN_MESSAGE = "Computation deferred. Computation will process" @@ -56,7 +56,7 @@ def test_progress_bar_scalar(penguins_df_default_index: bf.dataframe.DataFrame, with bf.option_context("display.progress_bar", "terminal"): penguins_df_default_index["body_mass_g"].head(10).mean() - assert_loading_msg_exist(capsys.readouterr().out) + assert capsys.readouterr().out == "" def test_progress_bar_scalar_allow_large_results( @@ -92,45 +92,45 @@ def test_progress_bar_load_jobs( while len(df) < MAX_INLINE_DF_BYTES: df = pd.DataFrame(np.repeat(df.values, 2, axis=0)) - with ( - bf.option_context("display.progress_bar", "terminal"), - tempfile.TemporaryDirectory() as dir, - ): + with bf.option_context( + "display.progress_bar", "terminal" + ), tempfile.TemporaryDirectory() as dir: path = dir + "/test_read_csv_progress_bar*.csv" df.to_csv(path, index=False) capsys.readouterr() # clear output session.read_csv(path) - assert_loading_msg_exist(capsys.readouterr().out, pattern="Load") - - -def test_progress_bar_uniqueness_check(session: bf.Session, capsys): - # Ensure strictly_ordered is True (default) to trigger uniqueness check - assert session._strictly_ordered - - capsys.readouterr() # clear output - - with bf.option_context("display.progress_bar", "terminal"): - # Read a table and specify a non-unique index_col to trigger the check. - # We use a public table to make it a "real" test. - session.read_gbq_table( - "bigquery-public-data.ml_datasets.penguins", - index_col="island", - ) - assert_loading_msg_exist(capsys.readouterr().out) -def assert_loading_msg_exist(capstdout: str, pattern=job_load_message_regex): - num_loading_msg = 0 - lines = capstdout.split("\n") +def assert_loading_msg_exist(capystOut: str, pattern=job_load_message_regex): + numLoadingMsg = 0 + lines = capystOut.split("\n") lines = [line for line in lines if len(line) > 0] assert len(lines) > 0 for line in lines: - if re.search(pattern, line) is not None: - num_loading_msg += 1 - assert num_loading_msg > 0 + 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(allow_large_results=True) + 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 def test_query_job_repr(penguins_df_default_index: bf.dataframe.DataFrame): @@ -169,19 +169,14 @@ def test_query_job_dry_run_series(penguins_df_default_index: bf.dataframe.DataFr def test_repr_anywidget_dataframe(penguins_df_default_index: bf.dataframe.DataFrame): pytest.importorskip("anywidget") - with bf.option_context("display.render_mode", "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 + assert EXPECTED_DRY_RUN_MESSAGE in actual_repr def test_repr_anywidget_index(penguins_df_default_index: bf.dataframe.DataFrame): pytest.importorskip("anywidget") - with bf.option_context("display.render_mode", "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 + assert EXPECTED_DRY_RUN_MESSAGE in actual_repr diff --git a/tests/system/small/test_series.py b/tests/system/small/test_series.py index 2e80b75c0b4..165e3b6df0f 100644 --- a/tests/system/small/test_series.py +++ b/tests/system/small/test_series.py @@ -22,20 +22,18 @@ 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 -from packaging.version import Version import bigframes.dtypes as dtypes import bigframes.features import bigframes.pandas import bigframes.series as series -import bigframes.testing -import bigframes.testing.utils from bigframes.testing.utils import ( - assert_frame_equal, + assert_pandas_df_equal, assert_series_equal, get_first_file_from_wildcard, ) @@ -49,7 +47,7 @@ def test_series_construct_copy(scalars_dfs): pd_result = pd.Series( scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_nullable_ints(): @@ -64,7 +62,7 @@ def test_series_construct_nullable_ints(): ) expected = pd.Series([1, 3, pd.NA], dtype=pd.Int64Dtype(), index=expected_index) - bigframes.testing.utils.assert_series_equal(bf_result, expected) + pd.testing.assert_series_equal(bf_result, expected) def test_series_construct_timestamps(): @@ -76,9 +74,7 @@ def test_series_construct_timestamps(): bf_result = series.Series(datetimes).to_pandas() pd_result = pd.Series(datetimes, dtype=pd.ArrowDtype(pa.timestamp("us"))) - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) def test_series_construct_copy_with_index(scalars_dfs): @@ -95,7 +91,7 @@ def test_series_construct_copy_with_index(scalars_dfs): dtype="Float64", index=scalars_pandas_df["int64_too"], ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_copy_index(scalars_dfs): @@ -112,7 +108,7 @@ def test_series_construct_copy_index(scalars_dfs): dtype="Float64", index=scalars_pandas_df["int64_too"], ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_pandas(scalars_dfs): @@ -124,7 +120,7 @@ def test_series_construct_pandas(scalars_dfs): scalars_pandas_df["int64_col"], name="test_series", dtype="Float64" ) assert bf_result.shape == pd_result.shape - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) def test_series_construct_from_list(): @@ -134,7 +130,7 @@ def test_series_construct_from_list(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_reindex(): @@ -145,7 +141,7 @@ def test_series_construct_reindex(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_from_list_w_index(): @@ -159,7 +155,7 @@ def test_series_construct_from_list_w_index(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_empty(session: bigframes.Session): @@ -180,7 +176,7 @@ def test_series_construct_scalar_no_index(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_scalar_w_index(): @@ -192,7 +188,7 @@ def test_series_construct_scalar_w_index(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_nan(): @@ -202,7 +198,7 @@ def test_series_construct_nan(): pd_result.index = pd_result.index.astype("Int64") pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_scalar_w_bf_index(): @@ -213,7 +209,7 @@ def test_series_construct_scalar_w_bf_index(): pd_result = pd_result.astype("string[pyarrow]") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_construct_from_list_escaped_strings(): @@ -229,7 +225,7 @@ def test_series_construct_from_list_escaped_strings(): # BigQuery DataFrame default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) def test_series_construct_geodata(): @@ -244,7 +240,7 @@ def test_series_construct_geodata(): series = bigframes.pandas.Series(pd_series) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series, series.to_pandas(), check_index_type=False ) @@ -262,7 +258,7 @@ def test_series_construct_w_dtype(dtype): expected = pd.Series(data, dtype=dtype) expected.index = expected.index.astype("Int64") series = bigframes.pandas.Series(data, dtype=dtype) - bigframes.testing.utils.assert_series_equal(series.to_pandas(), expected) + pd.testing.assert_series_equal(series.to_pandas(), expected) def test_series_construct_w_dtype_for_struct(): @@ -279,7 +275,7 @@ def test_series_construct_w_dtype_for_struct(): series = bigframes.pandas.Series(data, dtype=dtype) expected = pd.Series(data, dtype=dtype) expected.index = expected.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(series.to_pandas(), expected) + pd.testing.assert_series_equal(series.to_pandas(), expected) def test_series_construct_w_dtype_for_array_string(): @@ -297,7 +293,7 @@ def test_series_construct_w_dtype_for_array_string(): else: check_dtype = False - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( series.to_pandas(), expected, check_dtype=check_dtype ) @@ -317,7 +313,7 @@ def test_series_construct_w_dtype_for_array_struct(): else: check_dtype = False - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( series.to_pandas(), expected, check_dtype=check_dtype ) @@ -327,7 +323,7 @@ def test_series_construct_local_unordered_has_sequential_index(unordered_session ["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()) - bigframes.testing.utils.assert_index_equal(series.index.to_pandas(), expected) + pd.testing.assert_index_equal(series.index.to_pandas(), expected) @pytest.mark.parametrize( @@ -357,46 +353,11 @@ def test_series_construct_w_json_dtype(json_type): 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)])), - ), - ) - - bigframes.testing.utils.assert_series_equal(s.to_pandas(), s2.to_pandas()) - - 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() - bigframes.testing.utils.assert_index_equal(bf_result, pd_result) + pd.testing.assert_index_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -541,7 +502,7 @@ def test_series___getitem__(scalars_dfs, index_col, key): 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] - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -554,8 +515,6 @@ def test_series___getitem__(scalars_dfs, index_col, key): ), ) 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 @@ -595,7 +554,7 @@ def test_series___setitem__(scalars_dfs, index_col, key, value): bf_series[key] = value pd_series[key] = value - bigframes.testing.utils.assert_series_equal(bf_series.to_pandas(), pd_series) + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) @pytest.mark.parametrize( @@ -620,7 +579,7 @@ def test_series___setitem___with_int_key_numeric(scalars_dfs, key, value): bf_series[key] = value pd_series[key] = value - bigframes.testing.utils.assert_series_equal(bf_series.to_pandas(), pd_series) + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) def test_series___setitem___with_default_index(scalars_dfs): @@ -717,7 +676,7 @@ def test_series_replace_scalar_scalar(scalars_dfs): ) pd_result = scalars_pandas_df[col_name].replace("Hello, World!", "Howdy, Planet!") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -733,7 +692,7 @@ def test_series_replace_regex_scalar(scalars_dfs): "^H.l", "Howdy, Planet!", regex=True ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -751,7 +710,7 @@ def test_series_replace_list_scalar(scalars_dfs): ["Hello, World!", "T"], "Howdy, Planet!" ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -763,7 +722,7 @@ def test_series_replace_nans_with_pd_na(scalars_dfs): bf_result = scalars_df[col_name].replace({pd.NA: "UNKNOWN"}).to_pandas() pd_result = scalars_pandas_df[col_name].replace({pd.NA: "UNKNOWN"}) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -774,12 +733,10 @@ def test_series_replace_nans_with_pd_na(scalars_dfs): ( ({"Hello, World!": "Howdy, Planet!", "T": "R"},), ({},), - ({0: "Hello, World!"},), ), ids=[ "non-empty", "empty", - "off-type", ], ) def test_series_replace_dict(scalars_dfs, replacement_dict): @@ -788,7 +745,7 @@ def test_series_replace_dict(scalars_dfs, replacement_dict): bf_result = scalars_df[col_name].replace(replacement_dict).to_pandas() pd_result = scalars_pandas_df[col_name].replace(replacement_dict) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -807,8 +764,6 @@ 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] @@ -821,12 +776,11 @@ def test_series_interpolate(method): bf_result = bf_series.interpolate(method=method).to_pandas() # pd uses non-null types, while bf uses nullable types - assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, check_index_type=False, check_dtype=False, - nulls_are_nan=True, ) @@ -844,9 +798,7 @@ def test_series_dropna(scalars_dfs, ignore_index): 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) - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -882,9 +834,7 @@ def test_series_agg_multi_string(scalars_dfs): # Pandas may produce narrower numeric types, but bigframes always produces Float64 pd_result = pd_result.astype("Float64") - bigframes.testing.utils.assert_series_equal( - pd_result, bf_result, check_index_type=False - ) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -1001,7 +951,7 @@ def test_mode_stat(scalars_df_index, scalars_pandas_df_index, col_name): ## Mode implicitly resets index, and bigframes default indices use nullable Int64 pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -1044,8 +994,7 @@ 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) - # 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) + assert_series_equal(pd_result, bf_result) def test_series_pow_scalar(scalars_dfs): @@ -1169,7 +1118,7 @@ def test_mods(scalars_dfs, col_x, col_y, method): else: bf_result = bf_series.astype("Float64").to_pandas() pd_result = getattr(scalars_pandas_df[col_x], method)(scalars_pandas_df[col_y]) - bigframes.testing.utils.assert_series_equal(pd_result, bf_result) + pd.testing.assert_series_equal(pd_result, bf_result) # We work around a pandas bug that doesn't handle correlating nullable dtypes by doing this @@ -1233,12 +1182,19 @@ def test_divmods_series(scalars_dfs, col_x, col_y, method): scalars_pandas_df[col_y] ) # BigQuery's mod functions return NUMERIC values for non-INT64 inputs. - bigframes.testing.utils.assert_series_equal( - pd_div_result, bf_div_result.to_pandas(), check_dtype=False - ) - bigframes.testing.utils.assert_series_equal( - pd_mod_result, bf_mod_result.to_pandas(), check_dtype=False - ) + 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( @@ -1268,20 +1224,16 @@ def test_divmods_scalars(scalars_dfs, col_x, other, method): 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(): - bigframes.testing.utils.assert_series_equal( - pd_div_result, bf_div_result.to_pandas(), check_dtype=False - ) + pd.testing.assert_series_equal(pd_div_result, bf_div_result.to_pandas()) else: - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_div_result, bf_div_result.astype("Float64").to_pandas() ) if bf_mod_result.dtype == pd.Int64Dtype(): - bigframes.testing.utils.assert_series_equal( - pd_div_result, bf_div_result.to_pandas(), check_dtype=False - ) + pd.testing.assert_series_equal(pd_mod_result, bf_mod_result.to_pandas()) else: - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_mod_result, bf_mod_result.astype("Float64").to_pandas() ) @@ -1354,9 +1306,7 @@ def test_series_add_different_table_default_index( + scalars_df_2_default_index["float64_col"].to_pandas() ) # TODO(swast): Can remove sort_index() when there's default ordering. - bigframes.testing.utils.assert_series_equal( - bf_result.sort_index(), pd_result.sort_index() - ) + pd.testing.assert_series_equal(bf_result.sort_index(), pd_result.sort_index()) def test_series_add_different_table_with_index( @@ -1367,7 +1317,7 @@ def test_series_add_different_table_with_index( # 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"] - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): @@ -1386,7 +1336,7 @@ def test_reset_index_drop(scalars_df_index, scalars_pandas_df_index): # BigQuery DataFrames default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + 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): @@ -1405,7 +1355,7 @@ def test_series_reset_index_allow_duplicates(scalars_df_index, scalars_pandas_df pd_result.index = pd_result.index.astype(pd.Int64Dtype()) # reset_index should maintain the original ordering. - bigframes.testing.utils.assert_frame_equal(bf_result, pd_result) + pd.testing.assert_frame_equal(bf_result, pd_result) def test_series_reset_index_duplicates_error(scalars_df_index): @@ -1424,7 +1374,7 @@ def test_series_reset_index_inplace(scalars_df_index, scalars_pandas_df_index): # BigQuery DataFrames default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) @pytest.mark.parametrize( @@ -1451,7 +1401,7 @@ def test_reset_index_no_drop(scalars_df_index, scalars_pandas_df_index, name): # BigQuery DataFrames default indices use nullable Int64 always pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_frame_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_frame_equal(bf_result.to_pandas(), pd_result) def test_copy(scalars_df_index, scalars_pandas_df_index): @@ -1468,7 +1418,7 @@ def test_copy(scalars_df_index, scalars_pandas_df_index): pd_series.loc[0] = 3.4 assert bf_copy.to_pandas().loc[0] != bf_series.to_pandas().loc[0] - bigframes.testing.utils.assert_series_equal(bf_copy.to_pandas(), pd_copy) + pd.testing.assert_series_equal(bf_copy.to_pandas(), pd_copy) def test_isin_raise_error(scalars_df_index, scalars_pandas_df_index): @@ -1509,7 +1459,7 @@ 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") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -1549,7 +1499,7 @@ def test_isin_bigframes_values(scalars_dfs, col_name, test_set, session): 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") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -1559,11 +1509,7 @@ 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 - ) - ) + .isin(bigframes.pandas.Index(["Hello, World!", "Hi", "こんにちは"], session=session)) .to_pandas() ) pd_result = ( @@ -1571,7 +1517,7 @@ def test_isin_bigframes_index(scalars_dfs, session): .isin(pd.Index(["Hello, World!", "Hi", "こんにちは"])) .astype("boolean") ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result, ) @@ -1616,7 +1562,7 @@ def test_isin_bigframes_values_as_predicate( pd_predicate = scalars_pandas_df[col_name].isin(test_set) pd_result = scalars_pandas_df[pd_predicate] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( pd_result.reset_index(), bf_result.reset_index(), ) @@ -1717,10 +1663,10 @@ def test_loc_setitem_cell(scalars_df_index, scalars_pandas_df_index): pd_series.loc[2] = "This value isn't in the test data." bf_result = bf_series.to_pandas() pd_result = pd_series - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) # Per Copy-on-Write semantics, other references to the original DataFrame # should remain unchanged. - bigframes.testing.utils.assert_series_equal(bf_original.to_pandas(), pd_original) + pd.testing.assert_series_equal(bf_original.to_pandas(), pd_original) def test_at_setitem_row_label_scalar(scalars_dfs): @@ -1731,7 +1677,7 @@ def test_at_setitem_row_label_scalar(scalars_dfs): pd_series.at[1] = 1000 bf_result = bf_series.to_pandas() pd_result = pd_series.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_ne_obj_series(scalars_dfs): @@ -1787,7 +1733,7 @@ def test_take(scalars_dfs, indices): bf_result = scalars_df.take(indices).to_pandas() pd_result = scalars_pandas_df.take(indices) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_nested_filter(scalars_dfs): @@ -1903,10 +1849,6 @@ def test_series_binop_w_other_types(scalars_dfs, other): 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, @@ -1977,22 +1919,10 @@ def test_mean(scalars_dfs): assert math.isclose(pd_result, bf_result) -@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): +def test_median(scalars_dfs): scalars_df, scalars_pandas_df = scalars_dfs - bf_result = scalars_df[col_name].median(exact=False) + col_name = "int64_col" + bf_result = scalars_df[col_name].median() pd_max = scalars_pandas_df[col_name].max() pd_min = scalars_pandas_df[col_name].min() # Median is approximate, so just check for plausibility. @@ -2002,7 +1932,7 @@ def test_median(scalars_dfs, col_name): def test_median_exact(scalars_dfs): 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=True) pd_result = scalars_pandas_df[col_name].median() assert math.isclose(pd_result, bf_result) @@ -2015,7 +1945,7 @@ def test_series_quantile(scalars_dfs): pd_result = pd_series.quantile([0.0, 0.4, 0.6, 1.0]) bf_result = bf_series.quantile([0.0, 0.4, 0.6, 1.0]) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result.to_pandas(), check_dtype=False, check_index_type=False ) @@ -2035,10 +1965,7 @@ def test_series_small_repr(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 - ) + assert repr(bf_series) == pd_series.to_string(length=False, dtype=True, name=True) def test_sum(scalars_dfs): @@ -2064,7 +1991,7 @@ def test_cumprod(scalars_dfs): col_name = "float64_col" bf_result = scalars_df[col_name].cumprod() pd_result = scalars_pandas_df[col_name].cumprod() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_result, bf_result.to_pandas(), ) @@ -2165,7 +2092,7 @@ def test_groupby_level_sum(scalars_dfs): 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). - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series.sort_index(), bf_series.to_pandas().sort_index(), ) @@ -2179,7 +2106,7 @@ def test_groupby_level_list_sum(scalars_dfs): 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). - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series.sort_index(), bf_series.to_pandas().sort_index(), ) @@ -2296,7 +2223,7 @@ def test_groupby_window_ops(scalars_df_index, scalars_pandas_df_index, operator) scalars_pandas_df_index[col_name].groupby(scalars_pandas_df_index[group_key]) ).astype(bf_series.dtype) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series, bf_series, ) @@ -2312,7 +2239,7 @@ def test_groupby_window_ops(scalars_df_index, scalars_pandas_df_index, operator) 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series, bf_series, ) @@ -2322,7 +2249,7 @@ 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]) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series, bf_series, ) @@ -2346,7 +2273,7 @@ def test_drop_label_list(scalars_df_index, scalars_pandas_df_index): 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd_series, bf_series, ) @@ -2383,7 +2310,7 @@ def test_unique(scalars_df_index, scalars_pandas_df_index, col_name): 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) - bigframes.testing.utils.assert_series_equal(pd_series, bf_series, check_dtype=False) + pd.testing.assert_series_equal(pd_series, bf_series, check_dtype=False) def test_shape(scalars_dfs): @@ -2517,7 +2444,7 @@ def test_head_then_scalar_operation(scalars_dfs): bf_result = (scalars_df["float64_col"].head(1) + 4).to_pandas() pd_result = scalars_pandas_df["float64_col"].head(1) + 4 - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2533,7 +2460,7 @@ def test_head_then_series_operation(scalars_dfs): "float64_col" ].head(2) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2544,7 +2471,7 @@ def test_series_peek(scalars_dfs): peek_result = scalars_df["float64_col"].peek(n=3, force=False) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, scalars_pandas_df["float64_col"].reindex_like(peek_result), ) @@ -2563,7 +2490,7 @@ def test_series_peek_with_large_results_not_allowed(scalars_dfs): # 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 - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, scalars_pandas_df["float64_col"].reindex_like(peek_result), ) @@ -2577,7 +2504,7 @@ def test_series_peek_multi_index(scalars_dfs): 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, pd_series.reindex_like(peek_result), ) @@ -2589,7 +2516,7 @@ def test_series_peek_filtered(scalars_dfs): n=3, force=False ) pd_result = scalars_pandas_df[scalars_pandas_df.int64_col > 0]["float64_col"] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, pd_result.reindex_like(peek_result), ) @@ -2605,7 +2532,7 @@ def test_series_peek_force(scalars_dfs): 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"] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, pd_result.reindex_like(peek_result), ) @@ -2621,7 +2548,7 @@ def test_series_peek_force_float(scalars_dfs): 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"] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( peek_result, pd_result.reindex_like(peek_result), ) @@ -2633,7 +2560,7 @@ def test_shift(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2644,7 +2571,7 @@ def test_series_ffill(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index[col_name].ffill(limit=1).to_pandas() pd_result = scalars_pandas_df_index[col_name].ffill(limit=1) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2655,7 +2582,7 @@ def test_series_bfill(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index[col_name].bfill(limit=2).to_pandas() pd_result = scalars_pandas_df_index[col_name].bfill(limit=2) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2670,7 +2597,7 @@ def test_cumsum_int(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2691,7 +2618,7 @@ def test_cumsum_int_ordered(scalars_df_index, scalars_pandas_df_index): .astype(pd.Int64Dtype()) ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2710,7 +2637,7 @@ def test_series_nlargest(scalars_df_index, scalars_pandas_df_index, keep): 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2733,7 +2660,7 @@ def test_diff(scalars_df_index, scalars_pandas_df_index, periods): .astype(pd.Int64Dtype()) ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2750,9 +2677,9 @@ 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"].ffill().pct_change(periods=periods) + pd_result = scalars_pandas_df_index["int64_col"].pct_change(periods=periods) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2771,56 +2698,18 @@ def test_series_nsmallest(scalars_df_index, scalars_pandas_df_index, keep): 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) -@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, -): +def test_rank_ints(scalars_df_index, scalars_pandas_df_index): 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()) - ) + bf_result = scalars_df_index[col_name].rank().to_pandas() + pd_result = scalars_pandas_df_index[col_name].rank().astype(pd.Float64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2832,7 +2721,7 @@ def test_cast_float_to_int(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2844,7 +2733,7 @@ def test_cast_float_to_bool(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2862,7 +2751,7 @@ def test_cumsum_nested(scalars_df_index, scalars_pandas_df_index): .astype(pd.Float64Dtype()) ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2891,7 +2780,7 @@ def test_nested_analytic_ops_align(scalars_df_index, scalars_pandas_df_index): + pd_series.expanding().max() ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2907,7 +2796,7 @@ def test_cumsum_int_filtered(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2919,7 +2808,7 @@ def test_cumsum_float(scalars_df_index, scalars_pandas_df_index): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2930,7 +2819,7 @@ def test_cummin_int(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index[col_name].cummin().to_pandas() pd_result = scalars_pandas_df_index[col_name].cummin() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2941,7 +2830,7 @@ def test_cummax_int(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index[col_name].cummax().to_pandas() pd_result = scalars_pandas_df_index[col_name].cummax() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -2974,7 +2863,7 @@ def test_value_counts(scalars_dfs, kwargs): bf_result = s.value_counts(**kwargs).to_pandas() pd_result = pd_s.value_counts(**kwargs) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3012,17 +2901,18 @@ def test_value_counts_w_cut(scalars_dfs): pd_result = pd_cut.value_counts() pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + 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:] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3051,7 +2941,7 @@ def test_iloc_nested(scalars_df_index, scalars_pandas_df_index): 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] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3087,7 +2977,7 @@ def test_series_add_prefix(scalars_df_index, scalars_pandas_df_index): 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 - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, @@ -3100,7 +2990,7 @@ def test_series_add_suffix(scalars_df_index, scalars_pandas_df_index): 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 - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, check_index_type=False, @@ -3128,7 +3018,7 @@ def test_series_filter_like(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index["float64_col"].filter(like="ello") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3142,7 +3032,7 @@ def test_series_filter_regex(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index["float64_col"].filter(regex="^[GH].*") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3157,7 +3047,7 @@ def test_series_reindex(scalars_df_index, scalars_pandas_df_index): # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3184,7 +3074,7 @@ def test_series_reindex_like(scalars_df_index, scalars_pandas_df_index): # Pandas uses int64 instead of Int64 (nullable) dtype. pd_result.index = pd_result.index.astype(pd.Int64Dtype()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3200,7 +3090,7 @@ def test_where_with_series(scalars_df_index, scalars_pandas_df_index): scalars_pandas_df_index["bool_col"], scalars_pandas_df_index["int64_too"] ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3225,7 +3115,7 @@ def test_where_with_different_indices(scalars_df_index, scalars_pandas_df_index) ) ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3239,7 +3129,7 @@ def test_where_with_default(scalars_df_index, scalars_pandas_df_index): scalars_pandas_df_index["bool_col"] ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3259,7 +3149,7 @@ def _is_positive(x): cond=_is_positive, other=lambda x: x * 10 ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3308,7 +3198,7 @@ def test_clip_filtered_two_sided(scalars_df_index, scalars_pandas_df_index): upper_pd = scalars_pandas_df_index["int64_too"].iloc[:5] + 1 pd_result = col_pd.clip(lower_pd, upper_pd) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3323,7 +3213,7 @@ def test_clip_filtered_one_sided(scalars_df_index, scalars_pandas_df_index): lower_pd = scalars_pandas_df_index["int64_too"].iloc[2:] - 1 pd_result = col_pd.clip(lower_pd, None) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3353,7 +3243,7 @@ def test_between(scalars_df_index, scalars_pandas_df_index, left, right, inclusi ) pd_result = scalars_pandas_df_index["int64_col"].between(left, right, inclusive) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result.astype(pd.BooleanDtype()), ) @@ -3391,7 +3281,7 @@ def test_series_case_when(scalars_dfs_maybe_ordered): bf_result = bf_series.case_when(bf_conditions).to_pandas() pd_result = pd_series.case_when(pd_conditions) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result.astype(pd.Int64Dtype()), ) @@ -3427,7 +3317,7 @@ def test_series_case_when_change_type(scalars_dfs_maybe_ordered): bf_result = bf_series.case_when(bf_conditions).to_pandas() pd_result = pd_series.case_when(pd_conditions) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result.astype("string[pyarrow]"), ) @@ -3439,7 +3329,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_to_frame_no_name(scalars_dfs): @@ -3448,7 +3338,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): @@ -3456,7 +3346,7 @@ def test_to_json(gcs_folder, scalars_df_index, scalars_pandas_df_index): 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) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( gcs_df["int64_col"].astype(pd.Int64Dtype()), scalars_pandas_df_index["int64_col"], check_dtype=False, @@ -3469,7 +3359,7 @@ def test_to_csv(gcs_folder, scalars_df_index, scalars_pandas_df_index): scalars_df_index["int64_col"].to_csv(path) gcs_df = pd.read_csv(get_first_file_from_wildcard(path)) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( gcs_df["int64_col"].astype(pd.Int64Dtype()), scalars_pandas_df_index["int64_col"], check_dtype=False, @@ -3494,10 +3384,7 @@ def test_series_to_json_local_str(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -3516,10 +3403,7 @@ def test_series_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -3604,7 +3488,7 @@ def test_series_values(scalars_df_index, scalars_pandas_df_index): pd_result = scalars_pandas_df_index["int64_too"].values # Numpy isn't equipped to compare non-numeric objects, so convert back to dataframe - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( pd.Series(bf_result), pd.Series(pd_result), check_dtype=False ) @@ -3637,7 +3521,7 @@ def test_sort_values(scalars_df_index, scalars_pandas_df_index, ascending, na_po ascending=ascending, na_position=na_position ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3650,7 +3534,7 @@ def test_series_sort_values_inplace(scalars_df_index, scalars_pandas_df_index): bf_result = bf_series.to_pandas() pd_result = scalars_pandas_df_index["int64_col"].sort_values(ascending=False) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3669,7 +3553,7 @@ def test_sort_index(scalars_df_index, scalars_pandas_df_index, ascending): ) pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=ascending) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3681,7 +3565,7 @@ def test_series_sort_index_inplace(scalars_df_index, scalars_pandas_df_index): bf_result = bf_series.to_pandas() pd_result = scalars_pandas_df_index["int64_too"].sort_index(ascending=False) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3698,7 +3582,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_mask_custom_value(scalars_dfs): @@ -3716,7 +3600,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_mask_with_callable(scalars_df_index, scalars_pandas_df_index): @@ -3733,7 +3617,7 @@ def _ten_times(x): cond=lambda x: x > 0, other=_ten_times ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -3842,7 +3726,7 @@ def test_astype(scalars_df_index, scalars_pandas_df_index, column, to_type, erro 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) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_astype_python(session): @@ -3853,7 +3737,7 @@ def test_series_astype_python(session): index=pd.Index([0, 1, 2, 3], dtype="Int64"), ) result = session.read_pandas(input).astype(float, errors="null").to_pandas() - bigframes.testing.utils.assert_series_equal(result, exepcted) + pd.testing.assert_series_equal(result, exepcted) def test_astype_safe(session): @@ -3864,7 +3748,7 @@ def test_astype_safe(session): index=pd.Index([0, 1, 2, 3], dtype="Int64"), ) result = session.read_pandas(input).astype("Float64", errors="null").to_pandas() - bigframes.testing.utils.assert_series_equal(result, exepcted) + pd.testing.assert_series_equal(result, exepcted) def test_series_astype_w_invalid_error(session): @@ -3885,7 +3769,7 @@ def test_astype_numeric_to_int(scalars_df_index, scalars_pandas_df_index): .apply(lambda x: None if pd.isna(x) else math.trunc(x)) .astype(to_type) ) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -3903,38 +3787,34 @@ def test_date_time_astype_int( 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) - bigframes.testing.utils.assert_series_equal(bf_result, pd_result, check_dtype=False) + pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) assert bf_result.dtype == "Int64" -def test_string_astype_int(session): - pd_series = pd.Series(["4", "-7", "0", "-03"]) - bf_series = series.Series(pd_series, session=session) +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() - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_string_astype_float(session): +def test_string_astype_float(): pd_series = pd.Series( - ["1", "-1", "-0", "000", "-03.235", "naN", "-inf", "INf", ".33", "7.235e-8"] + ["1", "-1", "-0", "000", " -03.235", "naN", "-inf", "INf", ".33", "7.235e-8"] ) - bf_series = series.Series(pd_series, session=session) + bf_series = series.Series(pd_series) pd_result = pd_series.astype("Float64") bf_result = bf_series.astype("Float64").to_pandas() - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_string_astype_date(session): +def test_string_astype_date(): if int(pa.__version__.split(".")[0]) < 15: pytest.skip( "Avoid pyarrow.lib.ArrowNotImplementedError: " @@ -3945,33 +3825,29 @@ def test_string_astype_date(session): pd.ArrowDtype(pa.string()) ) - bf_series = series.Series(pd_series, session=session) + 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() - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_string_astype_datetime(session): +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, session=session) + 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() - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_string_astype_timestamp(session): +def test_string_astype_timestamp(): pd_series = pd.Series( [ "2014-08-15 08:15:12+00:00", @@ -3980,26 +3856,23 @@ def test_string_astype_timestamp(session): ] ).astype(pd.ArrowDtype(pa.string())) - bf_series = series.Series(pd_series, session=session) + 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() - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_index_type=False - ) + pd.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) -def test_timestamp_astype_string(session): +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", - ], - session=session, + ] ).astype(pd.ArrowDtype(pa.timestamp("us", tz="UTC"))) expected_result = pd.Series( @@ -4011,71 +3884,56 @@ def test_timestamp_astype_string(session): ) bf_result = bf_series.astype(pa.string()).to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, expected_result, check_index_type=False, check_dtype=False ) assert bf_result.dtype == "string[pyarrow]" @pytest.mark.parametrize("errors", ["raise", "null"]) -def test_float_astype_json(errors, session): - data = ["1.25", "2500000000.1", None, "-12323.24"] - bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE, session=session) +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 - bf_result_pandas = bf_result.to_pandas() - expected_data = [float(x) if x is not None else None for x in data] - expected_result = pd.Series(expected_data, dtype=dtypes.JSON_DTYPE) + expected_result = pd.Series(data, dtype=dtypes.JSON_DTYPE) expected_result.index = expected_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result_pandas, expected_result) - - -def test_float_astype_json_str(session): - data = ["1.25", "2500000000.1", None, "-12323.24"] - bf_series = series.Series(data, dtype=dtypes.FLOAT_DTYPE, session=session) - - bf_result = bf_series.astype("json") - assert bf_result.dtype == dtypes.JSON_DTYPE - - expected_data = [float(x) if x is not None else None for x in data] - expected_result = pd.Series(expected_data, dtype=dtypes.JSON_DTYPE) - expected_result.index = expected_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), expected_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), expected_result) @pytest.mark.parametrize("errors", ["raise", "null"]) -def test_string_astype_json(errors, session): +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, session=session) + 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) - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), pd_result) + pd.testing.assert_series_equal(bf_result.to_pandas(), pd_result) -def test_string_astype_json_in_safe_mode(session): +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, session=session) + 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") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), expected) + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) -def test_string_astype_json_raise_error(session): +def test_string_astype_json_raise_error(): data = ["this is not a valid json string"] - bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE, session=session) + bf_series = series.Series(data, dtype=dtypes.STRING_DTYPE) with pytest.raises( google.api_core.exceptions.BadRequest, match="syntax error while parsing value", @@ -4099,8 +3957,8 @@ def test_string_astype_json_raise_error(session): ), ], ) -def test_json_astype_others(data, to_type, errors, session): - bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE, session=session) +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 @@ -4108,7 +3966,7 @@ def test_json_astype_others(data, to_type, errors, session): 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") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), expected) + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) @pytest.mark.parametrize( @@ -4120,8 +3978,8 @@ def test_json_astype_others(data, to_type, errors, session): pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), ], ) -def test_json_astype_others_raise_error(data, to_type, session): - bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE, session=session) +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() @@ -4135,14 +3993,14 @@ def test_json_astype_others_raise_error(data, to_type, session): pytest.param(["true", None], dtypes.STRING_DTYPE, id="to_string"), ], ) -def test_json_astype_others_in_safe_mode(data, to_type, session): - bf_series = series.Series(data, dtype=dtypes.JSON_DTYPE, session=session) +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") - bigframes.testing.utils.assert_series_equal(bf_result.to_pandas(), expected) + pd.testing.assert_series_equal(bf_result.to_pandas(), expected) @pytest.mark.parametrize( @@ -4165,7 +4023,7 @@ def test_loc_bool_series_explicit_index(scalars_df_index, scalars_pandas_df_inde 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] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result, pd_result, ) @@ -4181,7 +4039,7 @@ def test_loc_bool_series_default_index( scalars_pandas_df_default_index.bool_col ] - assert_frame_equal( + assert_pandas_df_equal( bf_result.to_frame(), pd_result.to_frame(), ) @@ -4226,7 +4084,7 @@ 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") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4236,7 +4094,7 @@ 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)) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4248,7 +4106,7 @@ def test_rename_dict_same_type(scalars_df_index, scalars_pandas_df_index): pd_result.index = pd_result.index.astype("Int64") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4258,7 +4116,7 @@ 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") - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4275,7 +4133,7 @@ def test_loc_list_string_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.string_col.loc[index_list] pd_result = scalars_pandas_df_index.string_col.loc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4287,7 +4145,7 @@ def test_loc_list_integer_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.bool_col.loc[index_list] pd_result = scalars_pandas_df_index.bool_col.loc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4303,7 +4161,7 @@ def test_loc_list_multiindex(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_multiindex.int64_too.loc[index_list] pd_result = scalars_pandas_df_multiindex.int64_too.loc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4315,7 +4173,7 @@ def test_iloc_list(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.string_col.iloc[index_list] pd_result = scalars_pandas_df_index.string_col.iloc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4329,7 +4187,7 @@ def test_iloc_list_nameless(scalars_df_index, scalars_pandas_df_index): pd_series = scalars_pandas_df_index.string_col.rename(None) pd_result = pd_series.iloc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4344,7 +4202,7 @@ def test_loc_list_nameless(scalars_df_index, scalars_pandas_df_index): pd_series = scalars_pandas_df_index.string_col.rename(None) pd_result = pd_series.loc[index_list] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4360,7 +4218,7 @@ def test_loc_bf_series_string_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.date_col.loc[bf_string_series] pd_result = scalars_pandas_df_index.date_col.loc[pd_string_series] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4378,7 +4236,7 @@ def test_loc_bf_series_multiindex(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_multiindex.int64_too.loc[bf_string_series] pd_result = scalars_pandas_df_multiindex.int64_too.loc[pd_string_series] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4391,7 +4249,7 @@ def test_loc_bf_index_integer_index(scalars_df_index, scalars_pandas_df_index): bf_result = scalars_df_index.date_col.loc[bf_index] pd_result = scalars_pandas_df_index.date_col.loc[pd_index] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4405,7 +4263,7 @@ def test_loc_single_index_with_duplicate(scalars_df_index, scalars_pandas_df_ind index = "Hello, World!" bf_result = scalars_df_index.date_col.loc[index] pd_result = scalars_pandas_df_index.date_col.loc[index] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4450,8 +4308,8 @@ def test_query_job_setters(scalars_dfs): ([1, 1, 1, 1, 1],), ], ) -def test_is_monotonic_increasing(series_input, session): - scalars_df = series.Series(series_input, dtype=pd.Int64Dtype(), session=session) +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 @@ -4469,8 +4327,8 @@ def test_is_monotonic_increasing(series_input, session): ([1, 1, 1, 1, 1],), ], ) -def test_is_monotonic_decreasing(series_input, session): - scalars_df = series.Series(series_input, session=session) +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 @@ -4490,7 +4348,7 @@ def test_map_dict_input(scalars_dfs): pd_result = pd_result.astype("Int64") # pandas type differences bf_result = scalars_df.string_col.map(local_map) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4509,7 +4367,7 @@ def test_map_series_input(scalars_dfs): pd_result = scalars_pandas_df.int64_too.map(pd_map_series) bf_result = scalars_df.int64_too.map(bf_map_series) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_result.to_pandas(), pd_result, ) @@ -4596,7 +4454,7 @@ 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__[:3] in ("2.2", "2.3", "3.0"): + 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_) @@ -4689,7 +4547,7 @@ def foo(x): pd_col = scalars_pandas_df["int64_col"] - if pd.__version__[:3] in ("2.2", "2.3", "3.0"): + 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) @@ -4739,7 +4597,9 @@ def foo(x: int, y: int, df): ) pd_result = ( - scalars_pandas_df_index[column].pipe((foo, "df"), x=7, y=9).pipe(lambda x: x**2) + scalars_pandas_df_index[column] + .pipe((foo, "df"), x=7, y=9) + .pipe(lambda x: x**2) ) assert_series_equal(bf_result, pd_result) @@ -4768,7 +4628,7 @@ def foo(x: int, y: int, df): def test_series_explode(data): s = bigframes.pandas.Series(data) pd_s = s.to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( s.explode().to_pandas(), pd_s.explode(), check_index_type=False, @@ -4814,7 +4674,7 @@ def test_series_explode_w_index(index, ignore_index): s = bigframes.pandas.Series(data, index=index) pd_s = pd.Series(data, index=index) # TODO(b/340885567): fix type error - bigframes.testing.utils.assert_series_equal( + pd.testing.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, @@ -4839,7 +4699,7 @@ def test_series_explode_reserve_order(ignore_index, ordered): # 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()) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( res if ordered else res.sort_index(), pd_res, ) @@ -4861,7 +4721,7 @@ def test_series_construct_empty_array(): dtype=pd.ArrowDtype(pa.list_(pa.float64())), index=pd.Index([0], dtype=pd.Int64Dtype()), ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( expected, s.to_pandas(), ) @@ -4878,7 +4738,7 @@ def test_series_construct_empty_array(): ) def test_series_explode_null(data): s = bigframes.pandas.Series(data) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( s.explode().to_pandas(), s.to_pandas().explode(), check_dtype=False, @@ -4894,18 +4754,16 @@ 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() - # TODO: (b/484364312) - pd_result.index.names = bf_result.index.names - bigframes.testing.utils.assert_series_equal(bf_result, pd_result) + pd.testing.assert_series_equal(bf_result, pd_result) def test_series_struct_get_field_by_attribute( @@ -4917,13 +4775,13 @@ def test_series_struct_get_field_by_attribute( bf_series = nested_structs_df["person"] df_series = nested_structs_pandas_df["person"] - bigframes.testing.utils.assert_series_equal( + 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, ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( bf_series.address.country.to_pandas(), df_series.struct.field("address").struct.field("country"), check_dtype=False, @@ -4992,9 +4850,3 @@ def test_series_item_with_empty(session): with pytest.raises(ValueError, match=re.escape(expected_message)): bf_s_empty.item() - - -def test_series_sql(session): - s = bigframes.pandas.Series([], session=session) - - assert len(s.sql) > 0 diff --git a/tests/system/small/test_series_io.py b/tests/system/small/test_series_io.py index 83c2de70cae..426679d37d0 100644 --- a/tests/system/small/test_series_io.py +++ b/tests/system/small/test_series_io.py @@ -22,6 +22,7 @@ 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) @@ -30,23 +31,13 @@ def test_to_pandas_override_global_option(scalars_df_index): assert table_id is not None session = bf_series._block.session - - history_before = session.execution_history().to_dataframe() - queries_before = ( - len(history_before[history_before["job_type"] == "query"]) - if "job_type" in history_before.columns - else 0 - ) + 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 - - history_after = session.execution_history().to_dataframe() - queries_after = len(history_after[history_after["job_type"] == "query"]) - - assert (queries_after - queries_before) == 1 + assert session._metrics.execution_count - execution_count == 1 @pytest.mark.parametrize( diff --git a/tests/system/small/test_session.py b/tests/system/small/test_session.py index 76788da8a11..f0a6302c7ba 100644 --- a/tests/system/small/test_session.py +++ b/tests/system/small/test_session.py @@ -19,8 +19,8 @@ import textwrap import time import typing -import warnings from typing import List, Optional, Sequence +import warnings import bigframes_vendored.pandas.io.gbq as vendored_pandas_gbq import db_dtypes # type:ignore @@ -36,8 +36,6 @@ import bigframes.dataframe import bigframes.dtypes import bigframes.ml.linear_model -import bigframes.session.execution_spec -import bigframes.testing from bigframes.testing import utils all_write_engines = pytest.mark.parametrize( @@ -47,8 +45,7 @@ "bigquery_inline", "bigquery_load", "bigquery_streaming", - # TODO(b/502298527): Reenable bigquery_write test - # "bigquery_write", + "bigquery_write", ], ) @@ -114,16 +111,14 @@ def test_read_gbq_tokyo( df.sort_index(inplace=True) expected = scalars_pandas_df_index + # 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( - destination_spec=bigframes.session.execution_spec.EphemeralTableSpec() - ), + df._block.expr, use_explicit_destination=True ) assert exec_result.query_job is not None assert exec_result.query_job.location == tokyo_location - assert len(expected) == exec_result.batches().approx_total_rows + assert len(expected) == exec_result.total_rows @pytest.mark.parametrize( @@ -327,7 +322,7 @@ def test_read_gbq_w_anonymous_query_results_table(session: bigframes.Session): df = session.read_gbq(destination, index_col="name") result = df.to_pandas() expected.index = expected.index.astype(result.index.dtype) - bigframes.testing.utils.assert_frame_equal(result, expected, check_dtype=False) + pd.testing.assert_frame_equal(result, expected, check_dtype=False) def test_read_gbq_w_primary_keys_table( @@ -350,10 +345,10 @@ def test_read_gbq_w_primary_keys_table( # Verify that the DataFrame is already sorted by primary keys. sorted_result = result.sort_values(primary_keys) - bigframes.testing.utils.assert_frame_equal(result, sorted_result) + pd.testing.assert_frame_equal(result, sorted_result) # Verify that we're working from a snapshot rather than a copy of the table. - assert "FOR SYSTEM_TIME AS OF" in df.sql + assert "FOR SYSTEM_TIME AS OF TIMESTAMP" in df.sql def test_read_gbq_w_primary_keys_table_and_filters( @@ -389,7 +384,7 @@ def test_read_gbq_w_primary_keys_table_and_filters( # Verify that the DataFrame is already sorted by primary keys. sorted_result = result.sort_values(primary_keys) - bigframes.testing.utils.assert_frame_equal(result, sorted_result) + pd.testing.assert_frame_equal(result, sorted_result) @pytest.mark.parametrize( @@ -431,63 +426,18 @@ def test_read_gbq_w_max_results( assert bf_result.shape[0] == max_results -@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 - ); +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 + ); - 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 + 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" def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): @@ -512,6 +462,8 @@ def test_read_gbq_twice_with_same_timestamp(session, penguins_table_id): [ # Wildcard tables "bigquery-public-data.noaa_gsod.gsod194*", + # Linked datasets + "bigframes-dev.thelook_ecommerce.orders", # Materialized views "bigframes-dev.bigframes_tests_sys.base_table_mat_view", ], @@ -534,9 +486,7 @@ def test_read_gbq_w_ambigous_name( .to_pandas() ) pd_df = pd.DataFrame({"x": [2, 1], "ambiguous_name": [20, 10]}) - bigframes.testing.utils.assert_frame_equal( - df, pd_df, check_dtype=False, check_index_type=False - ) + pd.testing.assert_frame_equal(df, pd_df, check_dtype=False, check_index_type=False) def test_read_gbq_table_clustered_with_filter(session: bigframes.Session): @@ -669,7 +619,7 @@ def test_read_gbq_wildcard( pytest.param( {"query": {"useQueryCache": False, "maximumBytesBilled": "100"}}, marks=pytest.mark.xfail( - raises=google.api_core.exceptions.BadRequest, + raises=google.api_core.exceptions.InternalServerError, reason="Expected failure when the query exceeds the maximum bytes billed limit.", ), ), @@ -771,10 +721,8 @@ def test_read_gbq_w_json_and_compare_w_pandas_json(session): dtype=pd.ArrowDtype(db_dtypes.JSONArrowType()), ) pd_df.index = pd_df.index.astype("Int64") - bigframes.testing.utils.assert_series_equal(df.dtypes, pd_df.dtypes) - bigframes.testing.utils.assert_series_equal( - df["json_col"].to_pandas(), pd_df["json_col"] - ) + 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): @@ -872,22 +820,24 @@ def test_read_pandas(session, scalars_dfs): result = df.to_pandas() expected = scalars_pandas_df - bigframes.testing.utils.assert_frame_equal(result, expected) + pd.testing.assert_frame_equal(result, expected) def test_read_pandas_series(session): + idx: pd.Index = pd.Index([2, 7, 1, 2, 8], dtype=pd.Int64Dtype()) pd_series = pd.Series([3, 1, 4, 1, 5], dtype=pd.Int64Dtype(), index=idx) bf_series = session.read_pandas(pd_series) - bigframes.testing.utils.assert_series_equal(bf_series.to_pandas(), pd_series) + pd.testing.assert_series_equal(bf_series.to_pandas(), pd_series) def test_read_pandas_index(session): + pd_idx: pd.Index = pd.Index([2, 7, 1, 2, 8], dtype=pd.Int64Dtype()) bf_idx = session.read_pandas(pd_idx) - bigframes.testing.utils.assert_index_equal(bf_idx.to_pandas(), pd_idx) + pd.testing.assert_index_equal(bf_idx.to_pandas(), pd_idx) def test_read_pandas_w_unsupported_mixed_dtype(session): @@ -917,7 +867,7 @@ def test_read_pandas_col_label_w_space(session: bigframes.Session): ) result = session.read_pandas(expected).to_pandas() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( result, expected, check_index_type=False, check_dtype=False ) @@ -925,7 +875,7 @@ def test_read_pandas_col_label_w_space(session: bigframes.Session): def test_read_pandas_multi_index(session, scalars_pandas_df_multi_index): df = session.read_pandas(scalars_pandas_df_multi_index) result = df.to_pandas() - bigframes.testing.utils.assert_frame_equal(result, scalars_pandas_df_multi_index) + pd.testing.assert_frame_equal(result, scalars_pandas_df_multi_index) def test_read_pandas_rowid_exists_adds_suffix(session, scalars_pandas_df_default_index): @@ -933,9 +883,7 @@ def test_read_pandas_rowid_exists_adds_suffix(session, scalars_pandas_df_default pandas_df["rowid"] = np.arange(pandas_df.shape[0]) df_roundtrip = session.read_pandas(pandas_df).to_pandas() - bigframes.testing.utils.assert_frame_equal( - df_roundtrip, pandas_df, check_dtype=False - ) + pd.testing.assert_frame_equal(df_roundtrip, pandas_df, check_dtype=False) def test_read_pandas_tokyo( @@ -948,15 +896,12 @@ def test_read_pandas_tokyo( expected = scalars_pandas_df_index result = session_tokyo._executor.execute( - df._block.expr, - bigframes.session.execution_spec.ExecutionSpec( - destination_spec=bigframes.session.execution_spec.EphemeralTableSpec() - ), + df._block.expr, use_explicit_destination=True ) assert result.query_job is not None assert result.query_job.location == tokyo_location - assert len(expected) == result.batches().approx_total_rows + assert len(expected) == result.total_rows @all_write_engines @@ -974,14 +919,12 @@ def test_read_pandas_timedelta_dataframes(session, write_engine): expected_result = pandas_df.astype(bigframes.dtypes.TIMEDELTA_DTYPE) expected_result.index = expected_result.index.astype(bigframes.dtypes.INT_DTYPE) - bigframes.testing.utils.assert_frame_equal(actual_result, expected_result) + pd.testing.assert_frame_equal(actual_result, expected_result) @all_write_engines def test_read_pandas_timedelta_series(session, write_engine): - expected_series = pd.Series(pd.to_timedelta([1, 2, 3], unit="d")).astype( - "timedelta64[ns]" - ) + expected_series = pd.Series(pd.to_timedelta([1, 2, 3], unit="d")) actual_result = ( session.read_pandas(expected_series, write_engine=write_engine) @@ -989,15 +932,15 @@ def test_read_pandas_timedelta_series(session, write_engine): .astype("timedelta64[ns]") ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( actual_result, expected_series, check_index_type=False ) @all_write_engines def test_read_pandas_timedelta_index(session, write_engine): - expected_index = pd.to_timedelta([1, 2, 3], unit="d").astype( - "timedelta64[ns]" + expected_index = pd.to_timedelta( + [1, 2, 3], unit="d" ) # to_timedelta returns an index actual_result = ( @@ -1006,7 +949,7 @@ def test_read_pandas_timedelta_index(session, write_engine): .astype("timedelta64[ns]") ) - bigframes.testing.utils.assert_index_equal(actual_result, expected_index) + pd.testing.assert_index_equal(actual_result, expected_index) @all_write_engines @@ -1025,9 +968,7 @@ def test_read_pandas_json_dataframes(session, write_engine): expected_df, write_engine=write_engine ).to_pandas() - bigframes.testing.utils.assert_frame_equal( - actual_result, expected_df, check_index_type=False - ) + pd.testing.assert_frame_equal(actual_result, expected_df, check_index_type=False) @all_write_engines @@ -1043,7 +984,7 @@ def test_read_pandas_json_series(session, write_engine): actual_result = session.read_pandas( expected_series, write_engine=write_engine ).to_pandas() - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( actual_result, expected_series, check_index_type=False ) @@ -1071,7 +1012,7 @@ def test_read_pandas_json_index(session, write_engine): actual_result = session.read_pandas( expected_index, write_engine=write_engine ).to_pandas() - bigframes.testing.utils.assert_index_equal(actual_result, expected_index) + pd.testing.assert_index_equal(actual_result, expected_index) @pytest.mark.parametrize( @@ -1096,9 +1037,7 @@ def test_read_pandas_w_nested_json_fails(session, write_engine): pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) ), ) - with pytest.raises( - NotImplementedError, match="Nested JSON types are currently unsupported" - ): + with pytest.raises(NotImplementedError, match="Nested JSON types, found in column"): session.read_pandas(pd_s, write_engine=write_engine) @@ -1108,8 +1047,7 @@ def test_read_pandas_w_nested_json_fails(session, write_engine): pytest.param("default"), pytest.param("bigquery_inline"), pytest.param("bigquery_streaming"), - # TODO(b/502298527): Reenable bigquery_write test - # pytest.param("bigquery_write"), + pytest.param("bigquery_write"), ], ) def test_read_pandas_w_nested_json(session, write_engine): @@ -1133,7 +1071,7 @@ def test_read_pandas_w_nested_json(session, write_engine): .to_pandas() .reset_index(drop=True) ) - bigframes.testing.utils.assert_series_equal(bq_s, pd_s) + pd.testing.assert_series_equal(bq_s, pd_s) @pytest.mark.parametrize( @@ -1185,9 +1123,7 @@ def test_read_pandas_w_nested_json_index_fails(session, write_engine): pa.list_(pa.struct([("json_field", bigframes.dtypes.JSON_ARROW_TYPE)])) ), ) - with pytest.raises( - NotImplementedError, match="Nested JSON types are currently unsupported" - ): + with pytest.raises(NotImplementedError, match="Nested JSON types, found in"): session.read_pandas(pd_idx, write_engine=write_engine) @@ -1197,8 +1133,7 @@ def test_read_pandas_w_nested_json_index_fails(session, write_engine): pytest.param("default"), pytest.param("bigquery_inline"), pytest.param("bigquery_streaming"), - # TODO(b/502298527): Reenable bigquery_write test - # pytest.param("bigquery_write"), + pytest.param("bigquery_write"), ], ) def test_read_pandas_w_nested_json_index(session, write_engine): @@ -1218,7 +1153,7 @@ def test_read_pandas_w_nested_json_index(session, write_engine): ), ) bq_idx = session.read_pandas(pd_idx, write_engine=write_engine).to_pandas() - bigframes.testing.utils.assert_index_equal(bq_idx, pd_idx) + pd.testing.assert_index_equal(bq_idx, pd_idx) @all_write_engines @@ -1232,15 +1167,13 @@ def test_read_csv_for_gcs_file_w_write_engine(session, df_and_gcs_csv, write_eng write_engine=write_engine, dtype=scalars_df.dtypes.to_dict(), ) - bigframes.testing.utils.assert_frame_equal( - pd_df.to_pandas(), scalars_df.to_pandas() - ) + pd.testing.assert_frame_equal(pd_df.to_pandas(), scalars_df.to_pandas()) if write_engine in ("default", "bigquery_load"): bf_df = session.read_csv( path, engine="bigquery", index_col="rowindex", write_engine=write_engine ) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( @@ -1268,10 +1201,8 @@ def test_read_csv_for_local_file_w_sep(session, df_and_local_csv, sep): pd_df = session.read_csv( buffer, index_col="rowindex", sep=sep, dtype=scalars_df.dtypes.to_dict() ) - bigframes.testing.utils.assert_frame_equal( - bf_df.to_pandas(), scalars_df.to_pandas() - ) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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()) @pytest.mark.parametrize( @@ -1303,7 +1234,7 @@ def test_read_csv_for_index_col_w_false(session, df_and_local_csv, index_col): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index("rowindex").sort_index() pd_df = pd_df.set_index("rowindex") - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( @@ -1326,7 +1257,7 @@ def test_read_csv_for_index_col(session, df_and_gcs_csv, index_col): ) assert bf_df.shape == pd_df.shape - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( @@ -1379,7 +1310,7 @@ def test_read_csv_for_gcs_wildcard_path(session, df_and_gcs_csv): assert bf_df.shape == pd_df.shape assert bf_df.columns.tolist() == pd_df.columns.tolist() - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) def test_read_csv_for_names(session, df_and_gcs_csv_for_two_columns): @@ -1398,7 +1329,7 @@ def test_read_csv_for_names(session, df_and_gcs_csv_for_two_columns): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index(names[0]).sort_index() pd_df = pd_df.set_index(names[0]) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) def test_read_csv_for_names_more_than_columns_can_raise_error( @@ -1427,7 +1358,7 @@ def test_read_csv_for_names_less_than_columns(session, df_and_gcs_csv_for_two_co # Pandas's index name is None, while BigFrames's index name is "rowindex". pd_df.index.name = "rowindex" - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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( @@ -1465,7 +1396,7 @@ def test_read_csv_for_names_and_index_col( assert bf_df.shape == pd_df.shape assert bf_df.columns.tolist() == pd_df.columns.tolist() - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_df.to_pandas(), pd_df.to_pandas(), check_index_type=False ) @@ -1497,7 +1428,7 @@ def test_read_csv_for_names_and_usecols( # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index(names[0]).sort_index() pd_df = pd_df.set_index(names[0]) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) def test_read_csv_for_names_and_invalid_usecols( @@ -1544,7 +1475,7 @@ def test_read_csv_for_names_and_usecols_and_indexcol( assert bf_df.shape == pd_df.shape assert bf_df.columns.tolist() == pd_df.columns.tolist() - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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( @@ -1567,7 +1498,7 @@ def test_read_csv_for_names_less_than_columns_and_same_usecols( # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index(names[0]).sort_index() pd_df = pd_df.set_index(names[0]) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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( @@ -1612,7 +1543,7 @@ def test_read_csv_for_dtype(session, df_and_gcs_csv_for_two_columns): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index("rowindex").sort_index() pd_df = pd_df.set_index("rowindex") - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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): @@ -1632,7 +1563,7 @@ def test_read_csv_for_dtype_w_names(session, df_and_gcs_csv_for_two_columns): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index("a").sort_index() pd_df = pd_df.set_index("a") - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + pd.testing.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) @pytest.mark.parametrize( @@ -1699,10 +1630,8 @@ def test_read_csv_for_gcs_file_w_header(session, df_and_gcs_csv, header): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index("rowindex").sort_index() pd_df = pd_df.set_index("rowindex") - bigframes.testing.utils.assert_frame_equal( - bf_df.to_pandas(), scalars_df.to_pandas() - ) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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): @@ -1730,7 +1659,7 @@ def test_read_csv_w_usecols(session, df_and_local_csv): # (b/280889935) or guarantee row ordering. bf_df = bf_df.set_index("rowindex").sort_index() pd_df = pd_df.set_index("rowindex") - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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): @@ -1756,7 +1685,7 @@ def test_read_csv_w_usecols_and_indexcol(session, df_and_local_csv): assert bf_df.shape == pd_df.shape assert bf_df.columns.tolist() == pd_df.columns.tolist() - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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): @@ -1811,10 +1740,10 @@ def test_read_csv_local_w_encoding(session, penguins_pandas_df_default_index): bf_df = session.read_csv( path, engine="bigquery", index_col="rowindex", encoding="ISO-8859-1" ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( bf_df.to_pandas(), penguins_pandas_df_default_index ) - bigframes.testing.utils.assert_frame_equal(bf_df.to_pandas(), pd_df.to_pandas()) + 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): @@ -1823,9 +1752,7 @@ def test_read_pickle_local(session, penguins_pandas_df_default_index, tmp_path): penguins_pandas_df_default_index.to_pickle(path) df = session.read_pickle(path) - bigframes.testing.utils.assert_frame_equal( - penguins_pandas_df_default_index, df.to_pandas() - ) + pd.testing.assert_frame_equal(penguins_pandas_df_default_index, df.to_pandas()) def test_read_pickle_buffer(session, penguins_pandas_df_default_index): @@ -1834,9 +1761,7 @@ def test_read_pickle_buffer(session, penguins_pandas_df_default_index): buffer.seek(0) df = session.read_pickle(buffer) - bigframes.testing.utils.assert_frame_equal( - penguins_pandas_df_default_index, df.to_pandas() - ) + pd.testing.assert_frame_equal(penguins_pandas_df_default_index, df.to_pandas()) def test_read_pickle_series_buffer(session): @@ -1855,9 +1780,7 @@ def test_read_pickle_gcs(session, penguins_pandas_df_default_index, gcs_folder): penguins_pandas_df_default_index.to_pickle(path) df = session.read_pickle(path) - bigframes.testing.utils.assert_frame_equal( - penguins_pandas_df_default_index, df.to_pandas() - ) + pd.testing.assert_frame_equal(penguins_pandas_df_default_index, df.to_pandas()) @pytest.mark.parametrize( @@ -1913,8 +1836,7 @@ def test_read_parquet_gcs( df_out = ( session.read_parquet(read_path, engine=engine) # Restore order. - .set_index(df_write.index.name) - .sort_index() + .set_index(df_write.index.name).sort_index() # Restore index. .set_index(typing.cast(str, df_in.index.name)) ) @@ -1931,132 +1853,7 @@ def test_read_parquet_gcs( assert df_out.size != 0 pd_df_in = df_in.to_pandas() pd_df_out = df_out.to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_df_in, pd_df_out) - - -@pytest.mark.parametrize( - ("engine", "filename"), - ( - pytest.param( - "bigquery", - "000000000000.orc", - id="bigquery", - ), - pytest.param( - "auto", - "000000000000.orc", - id="auto", - ), - pytest.param( - "pyarrow", - "000000000000.orc", - id="pyarrow", - ), - pytest.param( - "bigquery", - "*.orc", - id="bigquery_wildcard", - ), - pytest.param( - "auto", - "*.orc", - id="auto_wildcard", - marks=pytest.mark.xfail( - raises=ValueError, - ), - ), - ), -) -def test_read_orc_gcs( - session: bigframes.Session, scalars_dfs, gcs_folder, engine, filename -): - pytest.importorskip( - "pandas", - minversion="2.0.0", - reason="pandas<2 does not handle nullable int columns well", - ) - scalars_df, _ = scalars_dfs - write_path = gcs_folder + test_read_orc_gcs.__name__ + "000000000000.orc" - read_path = gcs_folder + test_read_orc_gcs.__name__ + filename - - df_in: bigframes.dataframe.DataFrame = scalars_df.copy() - df_in = df_in.drop( - columns=[ - "geography_col", - "time_col", - "datetime_col", - "duration_col", - "timestamp_col", - ] - ) - df_write = df_in.reset_index(drop=False) - df_write.index.name = f"ordering_id_{random.randrange(1_000_000)}" - df_write.to_orc(write_path) - - df_out = ( - session.read_orc(read_path, engine=engine) - .set_index(df_write.index.name) - .sort_index() - .set_index(typing.cast(str, df_in.index.name)) - ) - - assert df_out.size != 0 - pd_df_in = df_in.to_pandas() - pd_df_out = df_out.to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_df_in, pd_df_out) - - -@pytest.mark.parametrize( - ("engine", "filename"), - ( - pytest.param( - "bigquery", - "000000000000.avro", - id="bigquery", - ), - pytest.param( - "bigquery", - "*.avro", - id="bigquery_wildcard", - ), - ), -) -def test_read_avro_gcs( - session: bigframes.Session, scalars_dfs, gcs_folder, engine, filename -): - scalars_df, _ = scalars_dfs - write_uri = gcs_folder + test_read_avro_gcs.__name__ + "*.avro" - read_uri = gcs_folder + test_read_avro_gcs.__name__ + filename - - df_in: bigframes.dataframe.DataFrame = scalars_df.copy() - # datetime round-trips back as str in avro - df_in = df_in.drop(columns=["geography_col", "duration_col", "datetime_col"]) - df_write = df_in.reset_index(drop=False) - index_name = f"ordering_id_{random.randrange(1_000_000)}" - df_write.index.name = index_name - - # Create a BigQuery table - table_id = df_write.to_gbq() - - # Extract to GCS as Avro - client = session.bqclient - extract_job_config = bigquery.ExtractJobConfig() - extract_job_config.destination_format = "AVRO" - extract_job_config.use_avro_logical_types = True - - client.extract_table(table_id, write_uri, job_config=extract_job_config).result() - - df_out = ( - session.read_avro(read_uri, engine=engine) - .set_index(index_name) - .sort_index() - .set_index(typing.cast(str, df_in.index.name)) - ) - - assert df_out.size != 0 - pd_df_in = df_in.to_pandas() - pd_df_out = df_out.to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_df_in, pd_df_out) + pd.testing.assert_frame_equal(pd_df_in, pd_df_out) @pytest.mark.parametrize( @@ -2090,8 +1887,7 @@ def test_read_parquet_gcs_compressed( df_out = ( session.read_parquet(path, engine="bigquery") # Restore order. - .set_index(df_write.index.name) - .sort_index() + .set_index(df_write.index.name).sort_index() # Restore index. .set_index(typing.cast(str, df_in.index.name)) ) @@ -2107,7 +1903,7 @@ def test_read_parquet_gcs_compressed( assert df_out.size != 0 pd_df_in = df_in.to_pandas() pd_df_out = df_out.to_pandas() - bigframes.testing.utils.assert_frame_equal(pd_df_in, pd_df_out) + pd.testing.assert_frame_equal(pd_df_in, pd_df_out) @pytest.mark.parametrize( @@ -2152,7 +1948,7 @@ def test_read_json_gcs_bq_engine(session, scalars_dfs, gcs_folder): df = session.read_json(read_path, lines=True, orient="records", engine="bigquery") # The auto detects of BigQuery load job does not preserve any ordering of columns for json. - bigframes.testing.utils.assert_index_equal( + pd.testing.assert_index_equal( df.columns.sort_values(), scalars_df.columns.sort_values() ) @@ -2177,7 +1973,7 @@ def test_read_json_gcs_bq_engine(session, scalars_dfs, gcs_folder): ] ) assert df.shape[0] == scalars_df.shape[0] - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( df.dtypes.sort_index(), scalars_df.dtypes.sort_index() ) @@ -2203,7 +1999,7 @@ def test_read_json_gcs_default_engine(session, scalars_dfs, gcs_folder): orient="records", ) - bigframes.testing.utils.assert_index_equal(df.columns, scalars_df.columns) + 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. @@ -2217,7 +2013,7 @@ def test_read_json_gcs_default_engine(session, scalars_dfs, gcs_folder): scalars_df = scalars_df.drop(columns=["date_col", "datetime_col", "time_col"]) assert df.shape[0] == scalars_df.shape[0] - bigframes.testing.utils.assert_series_equal(df.dtypes, scalars_df.dtypes) + pd.testing.assert_series_equal(df.dtypes, scalars_df.dtypes) @pytest.mark.parametrize( @@ -2327,22 +2123,6 @@ def test_read_gbq_query_dry_run(scalars_table_id, session): _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( [ @@ -2365,7 +2145,7 @@ def _assert_query_dry_run_stats_are_valid(result: pd.Series): ] ) - bigframes.testing.utils.assert_index_equal(result.index, expected_index) + pd.testing.assert_index_equal(result.index, expected_index) assert result["columnCount"] + result["indexLevel"] > 0 @@ -2385,5 +2165,5 @@ def _assert_table_dry_run_stats_are_valid(result: pd.Series): ] ) - bigframes.testing.utils.assert_index_equal(result.index, expected_index) + 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 deleted file mode 100644 index e280c551cbd..00000000000 --- a/tests/system/small/test_session_as_bpd.py +++ /dev/null @@ -1,154 +0,0 @@ -# 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 c8db041fec2..0825b780379 100644 --- a/tests/system/small/test_unordered.py +++ b/tests/system/small/test_unordered.py @@ -19,7 +19,7 @@ import bigframes.exceptions import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal, assert_series_equal +from bigframes.testing.utils import assert_pandas_df_equal, assert_series_equal def test_unordered_mode_sql_no_hash(unordered_session): @@ -48,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_frame_equal(bf_result, pd_result, ignore_order=True) # type: ignore + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) def test_unordered_mode_series_peek(unordered_session): @@ -103,7 +103,7 @@ def test_unordered_mode_read_gbq(unordered_session): } ) # Don't need ignore_order as there is only 1 row - assert_frame_equal(df.to_pandas(), expected, check_index_type=False) + assert_pandas_df_equal(df.to_pandas(), expected) @pytest.mark.parametrize( @@ -124,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_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_reset_index(unordered_session): @@ -134,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_frame_equal(bf_result.to_pandas(), pd_result) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result) def test_unordered_merge(unordered_session): @@ -146,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_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_drop_duplicates_ambiguous(unordered_session): @@ -167,7 +167,7 @@ def test_unordered_drop_duplicates_ambiguous(unordered_session): .drop_duplicates() ) - assert_frame_equal(bf_result.to_pandas(), pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result, ignore_order=True) def test_unordered_mode_cache_preserves_order(unordered_session): @@ -181,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_frame_equal(bf_result, pd_result, ignore_order=False) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=False) def test_unordered_mode_no_ordering_error(unordered_session): @@ -195,13 +195,15 @@ def test_unordered_mode_no_ordering_error(unordered_session): df.merge(df, on="a").head(3) -def test_unordered_mode_allows_ambiguity(unordered_session): +def test_unordered_mode_ambiguity_warning(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) - df.merge(df, on="a").sort_values("b_x").head(3) + + with pytest.warns(bigframes.exceptions.AmbiguousWindowWarning): + df.merge(df, on="a").sort_values("b_x").head(3) def test_unordered_mode_no_ambiguity_warning(unordered_session): @@ -248,7 +250,7 @@ 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" @@ -256,40 +258,10 @@ def test_resample_with_index(unordered_session, rule, origin, data): 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() - 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) - # TODO: (b/484364312) - pd_result.index.name = bf_result.index.name - assert_frame_equal( - bf_result.to_pandas(), - pd_result, - check_index_type=False, - check_dtype=False, - ) + 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() -@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 + pd.testing.assert_frame_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False ) - - # Pandas produces NaN, where bq dataframes produces pd.NA - bf_result = bf_result.fillna(float("nan")) - pd_result = pd_result.fillna(float("nan")) - 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 a70a676e84d..b48bb8bc863 100644 --- a/tests/system/small/test_window.py +++ b/tests/system/small/test_window.py @@ -18,7 +18,6 @@ import pandas as pd import pytest -import bigframes.testing.utils from bigframes import dtypes @@ -62,9 +61,7 @@ def test_dataframe_rolling_closed_param(rows_rolling_dfs, closed): actual_result = bf_df.rolling(window=3, closed=closed).sum().to_pandas() expected_result = pd_df.rolling(window=3, closed=closed).sum() - bigframes.testing.utils.assert_frame_equal( - actual_result, expected_result, check_dtype=False - ) + pd.testing.assert_frame_equal(actual_result, expected_result, check_dtype=False) @pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) @@ -83,7 +80,7 @@ def test_dataframe_groupby_rolling_closed_param(rows_rolling_dfs, closed): expected_result = ( pd_df.groupby(pd_df["int64_too"] % 2).rolling(window=3, closed=closed).sum() ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( actual_result[check_columns], expected_result, check_dtype=False ) @@ -94,9 +91,7 @@ def test_dataframe_rolling_on(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() - bigframes.testing.utils.assert_frame_equal( - actual_result, expected_result, check_dtype=False - ) + pd.testing.assert_frame_equal(actual_result, expected_result, check_dtype=False) def test_dataframe_rolling_on_invalid_column_raise_error(rows_rolling_dfs): @@ -121,7 +116,7 @@ def test_dataframe_groupby_rolling_on(rows_rolling_dfs): expected_result = ( pd_df.groupby(pd_df["int64_too"] % 2).rolling(window=3, on="float64_col").sum() ) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( actual_result[check_columns], expected_result, check_dtype=False ) @@ -140,9 +135,7 @@ def test_series_rolling_closed_param(rows_rolling_series, closed): actual_result = bf_series.rolling(window=3, closed=closed).sum().to_pandas() expected_result = df_series.rolling(window=3, closed=closed).sum() - bigframes.testing.utils.assert_series_equal( - actual_result, expected_result, check_dtype=False - ) + pd.testing.assert_series_equal(actual_result, expected_result, check_dtype=False) @pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) @@ -159,9 +152,7 @@ def test_series_groupby_rolling_closed_param(rows_rolling_series, closed): expected_result = ( df_series.groupby(df_series % 2).rolling(window=3, closed=closed).sum() ) - bigframes.testing.utils.assert_series_equal( - actual_result, expected_result, check_dtype=False - ) + pd.testing.assert_series_equal(actual_result, expected_result, check_dtype=False) @pytest.mark.parametrize( @@ -195,9 +186,7 @@ def test_series_window_agg_ops(rows_rolling_series, windowing, agg_op): actual_result = agg_op(windowing(bf_series)).to_pandas() expected_result = agg_op(windowing(pd_series)) - bigframes.testing.utils.assert_series_equal( - expected_result, actual_result, check_dtype=False - ) + pd.testing.assert_series_equal(expected_result, actual_result, check_dtype=False) @pytest.mark.parametrize( @@ -236,80 +225,7 @@ def test_dataframe_window_agg_ops(scalars_dfs, windowing, agg_op): bf_result = agg_op(windowing(bf_df)).to_pandas() pd_result = agg_op(windowing(pd_df)) - bigframes.testing.utils.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): - if pd.__version__.startswith("3"): - pytest.skip( - "pandas 3.0 bugged for this case 'Length of values (8) does not match length of index (9)'" - ) - 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) - - bigframes.testing.utils.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") - - bigframes.testing.utils.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]) - - bigframes.testing.utils.assert_frame_equal(pd_result, bf_result, check_dtype=False) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) @pytest.mark.parametrize("closed", ["left", "right", "both", "neither"]) @@ -335,7 +251,7 @@ def test_series_range_rolling(range_rolling_dfs, window, closed, ascending): .rolling(window=window, closed=closed) .min() ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( actual_result, expected_result, check_dtype=False, check_index=False ) @@ -356,7 +272,7 @@ def test_series_groupby_range_rolling(range_rolling_dfs): expected_result = ( pd_series.sort_index().groupby(pd_series % 2 == 0).rolling(window="3s").min() ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( actual_result, expected_result, check_dtype=False, check_index=False ) @@ -387,7 +303,7 @@ def test_dataframe_range_rolling(range_rolling_dfs, window, closed, ascending): # 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) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( actual_result, expected_result, check_dtype=False, @@ -404,7 +320,7 @@ def test_dataframe_range_rolling_on(range_rolling_dfs): # Need to specify the column order because Pandas (seemingly) # re-arranges columns alphabetically cols = ["ts_col", "int_col", "float_col"] - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( actual_result[cols], expected_result[cols], check_dtype=False, @@ -428,7 +344,7 @@ def test_dataframe_groupby_range_rolling(range_rolling_dfs): 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) - bigframes.testing.utils.assert_frame_equal( + pd.testing.assert_frame_equal( actual_result, expected_result, check_dtype=False, @@ -455,7 +371,7 @@ def test_range_rolling_order_info_lookup(range_rolling_dfs): .rolling(window="3s") .count() ) - bigframes.testing.utils.assert_series_equal( + pd.testing.assert_series_equal( actual_result, expected_result, check_dtype=False, check_index=False ) diff --git a/tests/unit/_config/test_bigquery_options.py b/tests/unit/_config/test_bigquery_options.py index 0c51abfd95c..3c80f00a371 100644 --- a/tests/unit/_config/test_bigquery_options.py +++ b/tests/unit/_config/test_bigquery_options.py @@ -13,8 +13,8 @@ # limitations under the License. import re -import warnings from unittest import mock +import warnings import google.auth.credentials import pytest @@ -203,8 +203,3 @@ def test_default_options(): 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_experiment_options.py b/tests/unit/_config/test_experiment_options.py index 0d66b2156ab..deeee2e46a7 100644 --- a/tests/unit/_config/test_experiment_options.py +++ b/tests/unit/_config/test_experiment_options.py @@ -15,18 +15,34 @@ import pytest import bigframes._config.experiment_options as experiment_options +import bigframes.exceptions as bfe -def test_sql_compiler_default_stable(): +def test_semantic_operators_default_false(): options = experiment_options.ExperimentOptions() - assert options.sql_compiler == "stable" + assert options.semantic_operators is False -def test_sql_compiler_set_experimental_shows_warning(): +def test_semantic_operators_set_true_shows_warning(): options = experiment_options.ExperimentOptions() with pytest.warns(FutureWarning): - options.sql_compiler = "experimental" + options.semantic_operators = True - assert options.sql_compiler == "experimental" + assert options.semantic_operators is True + + +def test_ai_operators_default_false(): + options = experiment_options.ExperimentOptions() + + assert options.ai_operators is False + + +def test_ai_operators_set_true_shows_warning(): + options = experiment_options.ExperimentOptions() + + with pytest.warns(bfe.PreviewWarning): + options.ai_operators = True + + assert options.ai_operators is True diff --git a/tests/unit/bigquery/generated/__init__.py b/tests/unit/bigquery/generated/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/bigquery/generated/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/generated/global_namespace/__init__.py b/tests/unit/bigquery/generated/global_namespace/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/generated/global_namespace/test_aead_encryption.py b/tests/unit/bigquery/generated/global_namespace/test_aead_encryption.py deleted file mode 100644 index 818151952ff..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/test_aead_encryption.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/aead_encryption.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.global_namespace.aead_encryption as aead_encryption_op -import bigframes.pandas as bpd - - -def test_deterministic_decrypt_bytes_expression(): - # Call the function with col() expressions - result = bbq.deterministic_decrypt_bytes( - bpd.col("keyset"), - bpd.col("ciphertext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_encryption_op._DETERMINISTIC_DECRYPT_BYTES_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "ciphertext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" - - -def test_deterministic_decrypt_string_expression(): - # Call the function with col() expressions - result = bbq.deterministic_decrypt_string( - bpd.col("keyset"), - bpd.col("ciphertext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_encryption_op._DETERMINISTIC_DECRYPT_STRING_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "ciphertext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" - - -def test_deterministic_encrypt_expression(): - # Call the function with col() expressions - result = bbq.deterministic_encrypt( - bpd.col("keyset"), - bpd.col("plaintext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_encryption_op._DETERMINISTIC_ENCRYPT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "plaintext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" diff --git a/tests/unit/bigquery/generated/global_namespace/test_array.py b/tests/unit/bigquery/generated/global_namespace/test_array.py deleted file mode 100644 index 56b85386902..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/test_array.py +++ /dev/null @@ -1,339 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/array.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.global_namespace.array as array_op -import bigframes.pandas as bpd - - -def test_array_concat_expression(): - # Call the function with col() expressions - result = bbq.array_concat( - bpd.col("array_expression_1"), - bpd.col("array_expression_2"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_CONCAT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_expression_1" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "array_expression_2" - - -def test_array_first_expression(): - # Call the function with col() expressions - result = bbq.array_first( - bpd.col("array_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_FIRST_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_expression" - - -def test_array_first_n_expression(): - # Call the function with col() expressions - result = bbq.array_first_n( - bpd.col("input_array"), - bpd.col("n"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_FIRST_N_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "input_array" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "n" - - -def test_array_includes_expression(): - # Call the function with col() expressions - result = bbq.array_includes( - bpd.col("array_to_search"), - bpd.col("search_value"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_INCLUDES_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_to_search" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "search_value" - - -def test_array_includes_all_expression(): - # Call the function with col() expressions - result = bbq.array_includes_all( - bpd.col("array_to_search"), - bpd.col("search_values"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_INCLUDES_ALL_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_to_search" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "search_values" - - -def test_array_includes_any_expression(): - # Call the function with col() expressions - result = bbq.array_includes_any( - bpd.col("array_to_search"), - bpd.col("search_values"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_INCLUDES_ANY_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_to_search" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "search_values" - - -def test_array_is_distinct_expression(): - # Call the function with col() expressions - result = bbq.array_is_distinct( - bpd.col("array_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_IS_DISTINCT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_expression" - - -def test_array_last_expression(): - # Call the function with col() expressions - result = bbq.array_last( - bpd.col("array_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_LAST_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_expression" - - -def test_array_length_expression(): - # Call the function with col() expressions - result = bbq.array_length( - bpd.col("series"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_LENGTH_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "series" - - -def test_array_reverse_expression(): - # Call the function with col() expressions - result = bbq.array_reverse( - bpd.col("value"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_REVERSE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "value" - - -def test_array_slice_expression(): - # Call the function with col() expressions - result = bbq.array_slice( - bpd.col("array_to_slice"), - bpd.col("start_offset"), - bpd.col("end_offset"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_SLICE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_to_slice" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "start_offset" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "end_offset" - - -def test_array_to_string_expression(): - # Call the function with col() expressions - result = bbq.array_to_string( - bpd.col("series"), - bpd.col("delimiter"), - bpd.col("null_text"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._ARRAY_TO_STRING_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "series" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "delimiter" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "null_text" - - -def test_flatten_expression(): - # Call the function with col() expressions - result = bbq.flatten( - bpd.col("array_to_flatten"), - bpd.col("depth"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._FLATTEN_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "array_to_flatten" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "depth" - - -def test_generate_array_expression(): - # Call the function with col() expressions - result = bbq.generate_array( - bpd.col("start_expression"), - bpd.col("end_expression"), - bpd.col("step_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == array_op._GENERATE_ARRAY_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "start_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "end_expression" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "step_expression" diff --git a/tests/unit/bigquery/generated/global_namespace/test_bit.py b/tests/unit/bigquery/generated/global_namespace/test_bit.py deleted file mode 100644 index 2cccafc0643..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/test_bit.py +++ /dev/null @@ -1,43 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/bit.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.global_namespace.bit as bit_op -import bigframes.pandas as bpd - - -def test_bit_count_expression(): - # Call the function with col() expressions - result = bbq.bit_count( - bpd.col("expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == bit_op._BIT_COUNT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "expression" diff --git a/tests/unit/bigquery/generated/global_namespace/test_conversion.py b/tests/unit/bigquery/generated/global_namespace/test_conversion.py deleted file mode 100644 index 84dfc02465c..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/test_conversion.py +++ /dev/null @@ -1,172 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/conversion.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.global_namespace.conversion as conversion_op -import bigframes.pandas as bpd - - -def test_bool__expression(): - # Call the function with col() expressions - result = bbq.bool_( - bpd.col("json_string_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._BOOL_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "json_string_expression" - - -def test_double_expression(): - # Call the function with col() expressions - result = bbq.double( - bpd.col("json_string_expression"), - bpd.col("wide_number_mode"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._DOUBLE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "json_string_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "wide_number_mode" - - -def test_float64_expression(): - # Call the function with col() expressions - result = bbq.float64( - bpd.col("json_string_expression"), - bpd.col("wide_number_mode"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._FLOAT64_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "json_string_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "wide_number_mode" - - -def test_int64_expression(): - # Call the function with col() expressions - result = bbq.int64( - bpd.col("json_string_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._INT64_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "json_string_expression" - - -def test_parse_bignumeric_expression(): - # Call the function with col() expressions - result = bbq.parse_bignumeric( - bpd.col("string_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._PARSE_BIGNUMERIC_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "string_expression" - - -def test_parse_numeric_expression(): - # Call the function with col() expressions - result = bbq.parse_numeric( - bpd.col("string_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._PARSE_NUMERIC_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "string_expression" - - -def test_string_expression(): - # Call the function with col() expressions - result = bbq.string( - bpd.col("expression"), - bpd.col("timezone"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == conversion_op._STRING_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "timezone" diff --git a/tests/unit/bigquery/generated/global_namespace/test_date.py b/tests/unit/bigquery/generated/global_namespace/test_date.py deleted file mode 100644 index 6484208584f..00000000000 --- a/tests/unit/bigquery/generated/global_namespace/test_date.py +++ /dev/null @@ -1,340 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/global_namespace/date.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.global_namespace.date as date_op -import bigframes.pandas as bpd - - -def test_current_date_expression(): - # Call the function with col() expressions - result = bbq.current_date( - bpd.col("time_zone_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._CURRENT_DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "time_zone_expression" - - -def test_date_expression(): - # Call the function with col() expressions - result = bbq.date( - bpd.col("expression"), - bpd.col("time_zone_expression"), - bpd.col("year"), - bpd.col("month"), - bpd.col("day"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 5 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "time_zone_expression" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "year" - assert isinstance(expr.inputs[3], ex.UnboundVariableExpression) - assert expr.inputs[3].id == "month" - assert isinstance(expr.inputs[4], ex.UnboundVariableExpression) - assert expr.inputs[4].id == "day" - - -def test_date_add_expression(): - # Call the function with col() expressions - result = bbq.date_add( - bpd.col("date_expression"), - bpd.col("int64_expression"), - bpd.col("date_part"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_ADD_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "int64_expression" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "date_part" - - -def test_date_diff_expression(): - # Call the function with col() expressions - result = bbq.date_diff( - bpd.col("end_date"), - bpd.col("start_date"), - bpd.col("granularity"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_DIFF_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "end_date" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "start_date" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "granularity" - - -def test_date_from_unix_date_expression(): - # Call the function with col() expressions - result = bbq.date_from_unix_date( - bpd.col("int64_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_FROM_UNIX_DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "int64_expression" - - -def test_date_sub_expression(): - # Call the function with col() expressions - result = bbq.date_sub( - bpd.col("date_expression"), - bpd.col("int64_expression"), - bpd.col("date_part"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_SUB_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "int64_expression" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "date_part" - - -def test_date_trunc_expression(): - # Call the function with col() expressions - result = bbq.date_trunc( - bpd.col("date_value"), - bpd.col("granularity"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._DATE_TRUNC_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_value" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "granularity" - - -def test_extract_expression(): - # Call the function with col() expressions - result = bbq.extract( - bpd.col("date_expression"), - bpd.col("part"), - bpd.col("time_zone"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._EXTRACT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "part" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "time_zone" - - -def test_format_date_expression(): - # Call the function with col() expressions - result = bbq.format_date( - bpd.col("format_string"), - bpd.col("date_expr"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._FORMAT_DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "format_string" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "date_expr" - - -def test_generate_date_array_expression(): - # Call the function with col() expressions - result = bbq.generate_date_array( - bpd.col("start_date"), - bpd.col("end_date"), - bpd.col("int64_expression"), - bpd.col("date_part"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._GENERATE_DATE_ARRAY_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 4 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "start_date" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "end_date" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "int64_expression" - assert isinstance(expr.inputs[3], ex.UnboundVariableExpression) - assert expr.inputs[3].id == "date_part" - - -def test_last_day_expression(): - # Call the function with col() expressions - result = bbq.last_day( - bpd.col("date_expression"), - bpd.col("date_part"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._LAST_DAY_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_expression" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "date_part" - - -def test_parse_date_expression(): - # Call the function with col() expressions - result = bbq.parse_date( - bpd.col("format_string"), - bpd.col("date_string"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._PARSE_DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 2 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "format_string" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "date_string" - - -def test_unix_date_expression(): - # Call the function with col() expressions - result = bbq.unix_date( - bpd.col("date_expression"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == date_op._UNIX_DATE_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 1 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "date_expression" diff --git a/tests/unit/bigquery/generated/test_aead.py b/tests/unit/bigquery/generated/test_aead.py deleted file mode 100644 index ce728b41899..00000000000 --- a/tests/unit/bigquery/generated/test_aead.py +++ /dev/null @@ -1,101 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -# DO NOT MODIFY THIS FILE DIRECTLY. -# This file was generated from: scripts/data/sql-functions/aead.yaml -# by the script: scripts/generate_bigframes_bigquery.py - -import bigframes.bigquery as bbq -import bigframes.core.col -import bigframes.core.expression as ex -import bigframes.operations.googlesql.aead as aead_op -import bigframes.pandas as bpd - - -def test_decrypt_bytes_expression(): - # Call the function with col() expressions - result = bbq.aead.decrypt_bytes( - bpd.col("keyset"), - bpd.col("ciphertext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_op._DECRYPT_BYTES_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "ciphertext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" - - -def test_decrypt_string_expression(): - # Call the function with col() expressions - result = bbq.aead.decrypt_string( - bpd.col("keyset"), - bpd.col("ciphertext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_op._DECRYPT_STRING_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "ciphertext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" - - -def test_encrypt_expression(): - # Call the function with col() expressions - result = bbq.aead.encrypt( - bpd.col("keyset"), - bpd.col("plaintext"), - bpd.col("additional_data"), - ) - - # Verify result is a col Expression - assert isinstance(result, bigframes.core.col.Expression) - - # Verify the internal expression structure - expr = result._value - assert isinstance(expr, ex.OpExpression) - assert expr.op == aead_op._ENCRYPT_OP - - # Verify arguments are free variables matching the names - assert len(expr.inputs) == 3 - assert isinstance(expr.inputs[0], ex.UnboundVariableExpression) - assert expr.inputs[0].id == "keyset" - assert isinstance(expr.inputs[1], ex.UnboundVariableExpression) - assert expr.inputs[1].id == "plaintext" - assert isinstance(expr.inputs[2], ex.UnboundVariableExpression) - assert expr.inputs[2].id == "additional_data" diff --git a/tests/unit/bigquery/test_ai.py b/tests/unit/bigquery/test_ai.py deleted file mode 100644 index 2cb876d39a5..00000000000 --- a/tests/unit/bigquery/test_ai.py +++ /dev/null @@ -1,319 +0,0 @@ -# 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 pandas as pd -import pytest - -import bigframes.bigquery as bbq -import bigframes.dataframe -import bigframes.series -import bigframes.session - - -@pytest.fixture -def mock_session(): - return mock.create_autospec(spec=bigframes.session.Session) - - -@pytest.fixture -def mock_dataframe(mock_session): - df = mock.create_autospec(spec=bigframes.dataframe.DataFrame) - df._session = mock_session - df.sql = "SELECT * FROM my_table" - df._to_sql_query.return_value = ("SELECT * FROM my_table", None, None) - return df - - -@pytest.fixture -def mock_embedding_series(mock_session): - series = mock.create_autospec(spec=bigframes.series.Series) - series._session = mock_session - # Mock to_frame to return a mock dataframe - df = mock.create_autospec(spec=bigframes.dataframe.DataFrame) - df._session = mock_session - df.sql = "SELECT my_col AS content FROM my_table" - df._to_sql_query.return_value = ( - "SELECT my_col AS content FROM my_table", - None, - None, - ) - series.copy.return_value = series - series.to_frame.return_value = df - return series - - -@pytest.fixture -def mock_text_series(mock_session): - series = mock.create_autospec(spec=bigframes.series.Series) - series._session = mock_session - # Mock to_frame to return a mock dataframe - df = mock.create_autospec(spec=bigframes.dataframe.DataFrame) - df._session = mock_session - df.sql = "SELECT my_col AS prompt FROM my_table" - df._to_sql_query.return_value = ( - "SELECT my_col AS prompt FROM my_table", - None, - None, - ) - series.copy.return_value = series - series.to_frame.return_value = df - return series - - -def test_generate_embedding_with_dataframe(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_embedding( - model_name, - mock_dataframe, - output_dimensionality=256, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - - # Normalize whitespace for comparison - query = " ".join(query.split()) - - expected_part_1 = "SELECT * FROM AI.GENERATE_EMBEDDING(" - expected_part_2 = f"MODEL `{model_name}`," - expected_part_3 = "(SELECT * FROM my_table)," - expected_part_4 = "STRUCT(256 AS `OUTPUT_DIMENSIONALITY`)" - - assert expected_part_1 in query - assert expected_part_2 in query - assert expected_part_3 in query - assert expected_part_4 in query - - -def test_generate_embedding_with_series(mock_embedding_series, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_embedding( - model_name, - mock_embedding_series, - start_second=0.0, - end_second=10.0, - interval_seconds=5.0, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - query = " ".join(query.split()) - - assert f"MODEL `{model_name}`" in query - assert "(SELECT my_col AS content FROM my_table)" in query - assert ( - "STRUCT(0.0 AS `START_SECOND`, 10.0 AS `END_SECOND`, 5.0 AS `INTERVAL_SECONDS`)" - in query - ) - - -def test_generate_embedding_defaults(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_embedding( - model_name, - mock_dataframe, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - query = " ".join(query.split()) - - assert f"MODEL `{model_name}`" in query - assert "STRUCT()" in query - - -@mock.patch("bigframes.pandas.read_pandas") -def test_generate_embedding_with_pandas_dataframe( - read_pandas_mock, mock_dataframe, mock_session -): - # This tests that pandas input path works and calls read_pandas - model_name = "project.dataset.model" - - # Mock return value of read_pandas to be a BigFrames DataFrame - read_pandas_mock.return_value = mock_dataframe - - pandas_df = pd.DataFrame({"content": ["test"]}) - - bbq.ai.generate_embedding( - model_name, - pandas_df, - ) - - read_pandas_mock.assert_called_once() - # Check that read_pandas was called with something (the pandas df) - assert read_pandas_mock.call_args[0][0] is pandas_df - - mock_session.read_gbq_query.assert_called_once() - - -def test_generate_text_with_dataframe(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_text( - model_name, - mock_dataframe, - max_output_tokens=256, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - - # Normalize whitespace for comparison - query = " ".join(query.split()) - - expected_part_1 = "SELECT * FROM AI.GENERATE_TEXT(" - expected_part_2 = f"MODEL `{model_name}`," - expected_part_3 = "(SELECT * FROM my_table)," - expected_part_4 = "STRUCT(256 AS `MAX_OUTPUT_TOKENS`)" - - assert expected_part_1 in query - assert expected_part_2 in query - assert expected_part_3 in query - assert expected_part_4 in query - - -def test_generate_text_with_series(mock_text_series, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_text( - model_name, - mock_text_series, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - query = " ".join(query.split()) - - assert f"MODEL `{model_name}`" in query - assert "(SELECT my_col AS prompt FROM my_table)" in query - - -def test_generate_text_defaults(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_text( - model_name, - mock_dataframe, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - query = " ".join(query.split()) - - assert f"MODEL `{model_name}`" in query - assert "STRUCT()" in query - - -def test_generate_table_with_dataframe(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_table( - model_name, - mock_dataframe, - output_schema="col1 STRING, col2 INT64", - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - - # Normalize whitespace for comparison - query = " ".join(query.split()) - - expected_part_1 = "SELECT * FROM AI.GENERATE_TABLE(" - expected_part_2 = f"MODEL `{model_name}`," - expected_part_3 = "(SELECT * FROM my_table)," - expected_part_4 = "STRUCT('col1 STRING, col2 INT64' AS `output_schema`)" - - assert expected_part_1 in query - assert expected_part_2 in query - assert expected_part_3 in query - assert expected_part_4 in query - - -def test_generate_table_with_options(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_table( - model_name, - mock_dataframe, - output_schema="col1 STRING", - temperature=0.5, - max_output_tokens=100, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - query = " ".join(query.split()) - - assert f"MODEL `{model_name}`" in query - assert "(SELECT * FROM my_table)" in query - assert ( - "STRUCT('col1 STRING' AS `output_schema`, 0.5 AS `temperature`, 100 AS `max_output_tokens`)" - in query - ) - - -def test_generate_table_with_mapping_schema(mock_dataframe, mock_session): - model_name = "project.dataset.model" - - bbq.ai.generate_table( - model_name, - mock_dataframe, - output_schema={"col1": "STRING", "col2": "INT64"}, - ) - - mock_session.read_gbq_query.assert_called_once() - query = mock_session.read_gbq_query.call_args[0][0] - - # Normalize whitespace for comparison - query = " ".join(query.split()) - - expected_part_1 = "SELECT * FROM AI.GENERATE_TABLE(" - expected_part_2 = f"MODEL `{model_name}`," - expected_part_3 = "(SELECT * FROM my_table)," - expected_part_4 = "STRUCT('col1 STRING, col2 INT64' AS `output_schema`)" - - assert expected_part_1 in query - assert expected_part_2 in query - assert expected_part_3 in query - assert expected_part_4 in query - - -@mock.patch("bigframes.pandas.read_pandas") -def test_generate_text_with_pandas_dataframe( - read_pandas_mock, mock_dataframe, mock_session -): - # This tests that pandas input path works and calls read_pandas - model_name = "project.dataset.model" - - # Mock return value of read_pandas to be a BigFrames DataFrame - read_pandas_mock.return_value = mock_dataframe - - pandas_df = pd.DataFrame({"content": ["test"]}) - - bbq.ai.generate_text( - model_name, - pandas_df, - ) - - read_pandas_mock.assert_called_once() - # Check that read_pandas was called with something (the pandas df) - assert read_pandas_mock.call_args[0][0] is pandas_df - - mock_session.read_gbq_query.assert_called_once() diff --git a/tests/unit/bigquery/test_mathematical.py b/tests/unit/bigquery/test_mathematical.py deleted file mode 100644 index f0cb16ae145..00000000000 --- a/tests/unit/bigquery/test_mathematical.py +++ /dev/null @@ -1,33 +0,0 @@ -# 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.bigquery as bbq -import bigframes.core.col as col -import bigframes.core.expression as ex -import bigframes.dtypes as dtypes -import bigframes.operations as ops - - -def test_rand_returns_expression(): - expr = bbq.rand() - - assert isinstance(expr, col.Expression) - node = expr._value - assert isinstance(node, ex.OpExpression) - op = node.op - assert isinstance(op, ops.GoogleSqlScalarOp) - assert op.sql_name == "RAND" - assert op.output_type() == dtypes.FLOAT_DTYPE - assert not op.is_deterministic - assert len(node.inputs) == 0 diff --git a/tests/unit/bigquery/test_ml.py b/tests/unit/bigquery/test_ml.py deleted file mode 100644 index a68133225d4..00000000000 --- a/tests/unit/bigquery/test_ml.py +++ /dev/null @@ -1,215 +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 __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" - - -@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 - - -@mock.patch("bigframes.pandas.read_gbq_query") -@mock.patch("bigframes.pandas.read_pandas") -def test_transform_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.transform(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.TRANSFORM" 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_generate_text_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.generate_text( - MODEL_SERIES, - input_=df, - temperature=0.5, - max_output_tokens=128, - top_k=20, - top_p=0.9, - flatten_json_output=True, - stop_sequences=["a", "b"], - ground_with_google_search=True, - request_type="TYPE", - ) - 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.GENERATE_TEXT" in generated_sql - assert f"MODEL `{MODEL_NAME}`" in generated_sql - assert "(SELECT * FROM `pandas_df`)" in generated_sql - assert "STRUCT(\n 0.5 AS `temperature`" in generated_sql - assert "128 AS `max_output_tokens`" in generated_sql - assert "20 AS `top_k`" in generated_sql - assert "0.9 AS `top_p`" in generated_sql - assert "TRUE AS `flatten_json_output`" in generated_sql - assert "['a', 'b'] AS `stop_sequences`" in generated_sql - assert "TRUE AS `ground_with_google_search`" in generated_sql - assert "'TYPE' AS `request_type`" in generated_sql - - -@mock.patch("bigframes.pandas.read_gbq_query") -def test_get_insights(read_gbq_query_mock): - ml_ops.get_insights(MODEL_SERIES) - read_gbq_query_mock.assert_called_once() - generated_sql = read_gbq_query_mock.call_args[0][0] - assert "ML.GET_INSIGHTS" in generated_sql - assert f"MODEL `{MODEL_NAME}`" in generated_sql - - -@mock.patch("bigframes.pandas.read_gbq_query") -@mock.patch("bigframes.pandas.read_pandas") -def test_generate_embedding_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.generate_embedding( - MODEL_SERIES, - input_=df, - flatten_json_output=True, - task_type="RETRIEVAL_DOCUMENT", - output_dimensionality=256, - ) - 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.GENERATE_EMBEDDING" in generated_sql - assert f"MODEL `{MODEL_NAME}`" in generated_sql - assert "(SELECT * FROM `pandas_df`)" in generated_sql - assert "STRUCT(\n TRUE AS `flatten_json_output`" in generated_sql - assert "'RETRIEVAL_DOCUMENT' AS `task_type`" in generated_sql - assert "256 AS `output_dimensionality`" in generated_sql diff --git a/tests/unit/bigquery/test_obj.py b/tests/unit/bigquery/test_obj.py deleted file mode 100644 index 9eac234b8bc..00000000000 --- a/tests/unit/bigquery/test_obj.py +++ /dev/null @@ -1,125 +0,0 @@ -# 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 unittest import mock - -import bigframes.bigquery.obj as obj -import bigframes.operations as ops -import bigframes.series - - -def create_mock_series(): - result = mock.create_autospec(bigframes.series.Series, instance=True) - result.copy.return_value = result - return result - - -def test_fetch_metadata_op_structure(): - op = ops.obj_fetch_metadata_op - assert op.name == "obj_fetch_metadata" - - -def test_get_access_url_op_structure(): - op = ops.ObjGetAccessUrl(mode="r") - assert op.name == "obj_get_access_url" - assert op.mode == "r" - assert op.duration is None - - -def test_get_access_url_with_duration_op_structure(): - op = ops.ObjGetAccessUrl(mode="rw", duration=3600000000) - assert op.name == "obj_get_access_url" - assert op.mode == "rw" - assert op.duration == 3600000000 - - -def test_make_ref_op_structure(): - op = ops.obj_make_ref_op - assert op.name == "obj_make_ref" - - -def test_make_ref_json_op_structure(): - op = ops.obj_make_ref_json_op - assert op.name == "obj_make_ref_json" - - -def test_fetch_metadata_calls_apply_unary_op(): - series = create_mock_series() - - obj.fetch_metadata(series) - - series._apply_unary_op.assert_called_once() - args, _ = series._apply_unary_op.call_args - assert args[0] == ops.obj_fetch_metadata_op - - -def test_get_access_url_calls_apply_unary_op_without_duration(): - series = create_mock_series() - - obj.get_access_url(series, mode="r") - - series._apply_unary_op.assert_called_once() - args, _ = series._apply_unary_op.call_args - assert isinstance(args[0], ops.ObjGetAccessUrl) - assert args[0].mode == "r" - assert args[0].duration is None - - -def test_get_access_url_calls_apply_unary_op_with_duration(): - series = create_mock_series() - duration = datetime.timedelta(hours=1) - - obj.get_access_url(series, mode="rw", duration=duration) - - series._apply_unary_op.assert_called_once() - args, _ = series._apply_unary_op.call_args - assert isinstance(args[0], ops.ObjGetAccessUrl) - assert args[0].mode == "rw" - # 1 hour = 3600 seconds = 3600 * 1000 * 1000 microseconds - assert args[0].duration == 3600000000 - - -def test_make_ref_calls_apply_binary_op_with_authorizer(): - uri = create_mock_series() - auth = create_mock_series() - - obj.make_ref(uri, authorizer=auth) - - uri._apply_binary_op.assert_called_once() - args, _ = uri._apply_binary_op.call_args - assert args[0] == auth - assert args[1] == ops.obj_make_ref_op - - -def test_make_ref_calls_apply_binary_op_with_authorizer_string(): - uri = create_mock_series() - auth = "us.bigframes-test-connection" - - obj.make_ref(uri, authorizer=auth) - - uri._apply_binary_op.assert_called_once() - args, _ = uri._apply_binary_op.call_args - assert args[0] == auth - assert args[1] == ops.obj_make_ref_op - - -def test_make_ref_calls_apply_unary_op_without_authorizer(): - json_val = create_mock_series() - - obj.make_ref(json_val) - - json_val._apply_unary_op.assert_called_once() - args, _ = json_val._apply_unary_op.call_args - assert args[0] == ops.obj_make_ref_json_op diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py deleted file mode 100644 index d880fe54242..00000000000 --- a/tests/unit/conftest.py +++ /dev/null @@ -1,316 +0,0 @@ -# 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 json -import pathlib -import typing - -import numpy as np -import pandas as pd -import pyarrow as pa -import pytest -from google.cloud import bigquery - -import bigframes.core as core -import bigframes.pandas as bpd -import bigframes.testing.mocks as mocks -import bigframes.testing.utils -from bigframes import dtypes - -CURRENT_DIR = pathlib.Path(__file__).parent -DATA_DIR = CURRENT_DIR.parent / "data" - - -@pytest.fixture(scope="session") -def polars_session(): - pytest.importorskip("polars") - - from bigframes.testing import polars_session - - return polars_session.TestSession() - - -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)) - - def to_json_str(val): - if val is None or (isinstance(val, float) and np.isnan(val)): - return None - return json.dumps(val) - - df["json_col"] = df["json_col"].apply(to_json_str).astype(dtypes.JSON_DTYPE) - - # timestamp_col - def parse_timestamp(val): - if pd.isna(val): - return None - if isinstance(val, str): - return datetime.datetime.fromisoformat(val.replace("Z", "+00:00")) - if hasattr(val, "to_pydatetime"): - return val.to_pydatetime() - return val - - timestamp_vals = [parse_timestamp(x) for x in df["timestamp_col"]] - timestamp_arr = pa.array(timestamp_vals, type=dtypes.TIMESTAMP_DTYPE.pyarrow_dtype) - df["timestamp_col"] = pd.Series( - timestamp_arr, index=df.index, dtype=dtypes.TIMESTAMP_DTYPE - ) - - 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/googlesql/__init__.py b/tests/unit/core/compile/googlesql/__init__.py new file mode 100644 index 00000000000..6d5e14bcf4a --- /dev/null +++ b/tests/unit/core/compile/googlesql/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/tests/unit/core/compile/googlesql/test_expression.py b/tests/unit/core/compile/googlesql/test_expression.py new file mode 100644 index 00000000000..e72598b1760 --- /dev/null +++ b/tests/unit/core/compile/googlesql/test_expression.py @@ -0,0 +1,37 @@ +# 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 pytest + +import bigframes.core.compile.googlesql as sql + + +@pytest.mark.parametrize( + ("table_id", "dataset_id", "project_id", "expected"), + [ + pytest.param("a", None, None, "`a`"), + pytest.param("a", "b", None, "`b`.`a`"), + pytest.param("a", "b", "c", "`c`.`b`.`a`"), + pytest.param("a", None, "c", None, marks=pytest.mark.xfail(raises=ValueError)), + ], +) +def test_table_expression(table_id, dataset_id, project_id, expected): + expr = sql.TableExpression( + table_id=table_id, dataset_id=dataset_id, project_id=project_id + ) + assert expr.sql() == expected + + +def test_escape_chars(): + assert sql._escape_chars("\a\b\f\n\r\t\v\\?'\"`") == r"\a\b\f\n\r\t\v\\\?\'\"\`" diff --git a/tests/unit/core/compile/googlesql/test_function.py b/tests/unit/core/compile/googlesql/test_function.py new file mode 100644 index 00000000000..4edfda6f345 --- /dev/null +++ b/tests/unit/core/compile/googlesql/test_function.py @@ -0,0 +1,21 @@ +# 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 bigframes.core.compile.googlesql as sql + + +def test_cast(): + col = sql.ColumnExpression("col") + assert sql.Cast(col, sql.DataType.STRING).sql() == "CAST (`col` AS STRING)" + assert sql.Cast(col, sql.DataType.FLOAT64).sql() == "CAST (`col` AS FLOAT64)" diff --git a/tests/unit/core/compile/googlesql/test_query.py b/tests/unit/core/compile/googlesql/test_query.py new file mode 100644 index 00000000000..b8d1d024e2b --- /dev/null +++ b/tests/unit/core/compile/googlesql/test_query.py @@ -0,0 +1,223 @@ +# 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.mock import MagicMock + +import google.cloud.bigquery as bigquery +import pytest + +import bigframes.core.compile.googlesql as sql + + +@pytest.mark.parametrize( + ("table_id", "dataset_id", "project_id", "expected"), + [ + pytest.param("a", None, None, "`a`"), + pytest.param("a", "b", None, "`b`.`a`"), + pytest.param("a", "b", "c", "`c`.`b`.`a`"), + pytest.param("a", None, "c", None, marks=pytest.mark.xfail(raises=ValueError)), + ], +) +def test_table_expression(table_id, dataset_id, project_id, expected): + expr = sql.TableExpression( + table_id=table_id, dataset_id=dataset_id, project_id=project_id + ) + assert expr.sql() == expected + + +@pytest.mark.parametrize( + ("table_name", "alias", "expected"), + [ + pytest.param("a", None, "`a`"), + pytest.param("a", "aa", "`a` AS `aa`"), + ], +) +def test_from_item_w_table_name(table_name, alias, expected): + expr = sql.FromItem( + sql.TableExpression(table_id=table_name), + as_alias=None + if alias is None + else sql.AsAlias(sql.AliasExpression(alias=alias)), + ) + assert expr.sql() == expected + + +def test_from_item_w_query_expr(): + from_clause = sql.FromClause( + sql.FromItem(expression=sql.TableExpression(table_id="table_a")) + ) + select = sql.Select( + select_list=[sql.SelectAll(sql.StarExpression())], + from_clause_list=[from_clause], + ) + query_expr = sql.QueryExpr(select=select) + expected = "SELECT\n*\nFROM\n`table_a`" + + # A QueryExpr object + expr = sql.FromItem(expression=query_expr) + assert expr.sql() == f"({expected})" + + # A str object + expr = sql.FromItem(expression=expected) + assert expr.sql() == f"({expected})" + + +def test_from_item_w_cte(): + expr = sql.FromItem(expression=sql.CTEExpression("test")) + assert expr.sql() == "`test`" + + +def test_from_item_w_table_ref(): + mock_table_ref = MagicMock(spec=bigquery.TableReference) + mock_table_ref.table_id = "mock_table" + mock_table_ref.dataset_id = "mock_dataset" + mock_table_ref.project = "mock_project" + + from_item = sql.FromItem.from_source(mock_table_ref) + + assert from_item.sql() == "`mock_project`.`mock_dataset`.`mock_table`" + + +@pytest.mark.parametrize( + ("col_name", "alias", "expected"), + [ + pytest.param("a", None, "`a`"), + pytest.param("a", "aa", "`a` AS `aa`"), + ], +) +def test_select_expression(col_name, alias, expected): + expr = sql.SelectExpression( + expression=sql.ColumnExpression(col_name), + alias=None if alias is None else sql.AliasExpression(alias=alias), + ) + assert expr.sql() == expected + + +def test_select(): + select_1 = sql.SelectExpression(expression=sql.ColumnExpression("a")) + select_2 = sql.SelectExpression( + expression=sql.ColumnExpression("b"), alias=sql.AliasExpression(alias="bb") + ) + from_1 = sql.FromItem(expression=sql.TableExpression(table_id="table_a")) + from_2 = sql.FromItem( + expression="SELECT * FROM project.table_b", + as_alias=sql.AsAlias(sql.AliasExpression(alias="table_b")), + ) + expr = sql.Select( + select_list=[select_1, select_2], + from_clause_list=[sql.FromClause(from_1), sql.FromClause(from_2)], + ) + expected = "SELECT\n`a`,\n`b` AS `bb`\nFROM\n`table_a`,\n(SELECT * FROM project.table_b) AS `table_b`" + + assert expr.sql() == expected + + +@pytest.mark.parametrize( + "columns, source, expected", + [ + ( + ["a", "b", "c"], + "select * from test", + "SELECT\nDISTINCT\n`a`,\n`b`,\n`c`\nFROM\n(select * from test)", + ), + ( + "a", + "select * from test", + "SELECT\nDISTINCT\n`a`\nFROM\n(select * from test)", + ), + ], +) +def test_select_from_str(columns, source, expected): + expr = sql.Select().from_(source).select(columns, distinct=True) + assert expr.sql() == expected + + +@pytest.mark.parametrize( + ("columns", "distinct", "expected"), + [ + pytest.param( + ["a", "b", "c"], + True, + "SELECT\nDISTINCT\n`a`,\n`b`,\n`c`\nFROM\n`mock_project`.`mock_dataset`.`mock_table`", + ), + pytest.param( + None, + True, + "SELECT\nDISTINCT\n*\nFROM\n`mock_project`.`mock_dataset`.`mock_table`", + ), + pytest.param( + None, False, "SELECT\n*\nFROM\n`mock_project`.`mock_dataset`.`mock_table`" + ), + ], +) +def test_select_from_table_ref(columns, distinct, expected): + mock_table_ref = MagicMock(spec=bigquery.TableReference) + mock_table_ref.table_id = "mock_table" + mock_table_ref.dataset_id = "mock_dataset" + mock_table_ref.project = "mock_project" + + expr = sql.Select().from_(mock_table_ref).select(columns, distinct=distinct) + assert expr.sql() == expected + + +def test_query_expr_w_cte(): + # Test a simple SELECT query. + from_clause1 = sql.FromClause( + sql.FromItem(expression=sql.TableExpression(table_id="table_a")) + ) + select1 = sql.Select( + select_list=[sql.SelectAll(sql.StarExpression())], + from_clause_list=[from_clause1], + ) + query1 = sql.QueryExpr(select=select1) + query1_sql = "SELECT\n*\nFROM\n`table_a`" + assert query1.sql() == query1_sql + + # Test a query with CTE statements. + cte1 = sql.NonRecursiveCTE(cte_name=sql.CTEExpression("a"), query_expr=query1) + cte2 = sql.NonRecursiveCTE(cte_name=sql.CTEExpression("b"), query_expr=query1) + + cte1_sql = f"`a` AS (\n{query1_sql}\n)" + cte2_sql = f"`b` AS (\n{query1_sql}\n)" + assert cte1.sql() == cte1_sql + assert cte2.sql() == cte2_sql + + with_cte_list = [cte1, cte2] + select2 = sql.Select( + select_list=[ + sql.SelectExpression( + sql.ColumnExpression(parent=cte1.cte_name, name="column_x") + ), + sql.SelectAll(sql.StarExpression(parent=cte2.cte_name)), + ], + from_clause_list=[ + sql.FromClause(sql.FromItem(expression=cte1.cte_name)), + sql.FromClause(sql.FromItem(expression=cte2.cte_name)), + ], + distinct=True, + ) + select2_sql = "SELECT\nDISTINCT\n`a`.`column_x`,\n`b`.*\nFROM\n`a`,\n`b`" + assert select2.sql() == select2_sql + + query2 = sql.QueryExpr(select=select2, with_cte_list=with_cte_list) + query2_sql = f"WITH {cte1_sql},\n{cte2_sql}\n{select2_sql}" + assert query2.sql() == query2_sql + + +def test_identifier(): + assert sql.identifier("\aa") == r"`\aa`" + + +def test_escape_chars(): + assert sql._escape_chars("\a\b\f\n\r\t\v\\?'\"`") == r"\a\b\f\n\r\t\v\\\?\'\"\`" 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 deleted file mode 100644 index fb930323dbd..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_corr/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col`, - `float64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 92b8ea4d3ab..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_binary_compiler/test_cov/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col`, - `float64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 7056c8b0af3..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ROW_NUMBER() OVER () - 1 AS `row_number` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8efea4b51bc..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_row_number_with_window/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ROW_NUMBER() OVER (ORDER BY `int64_col` ASC NULLS LAST) - 1 AS `row_number` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4d67203ecc6..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_nullary_compiler/test_size/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index f929970a227..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_array_agg/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 7e697719b36..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_ordered_unary_compiler/test_string_agg/out.sql +++ /dev/null @@ -1,18 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `string_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index dc1f6fb4f79..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - COALESCE(LOGICAL_AND(`bool_col`), TRUE) AS `bfcol_2`, - COALESCE(LOGICAL_AND(`int64_col` <> 0), TRUE) AS `bfcol_3` - FROM `bfcte_0` -) -SELECT - `bfcol_2` AS `bool_col`, - `bfcol_3` 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_all_w_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all_w_window/out.sql deleted file mode 100644 index 7e4c9d6c3c9..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_all_w_window/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(LOGICAL_AND(`bool_col`) OVER (), TRUE) AS `agg_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8ae589fb09f..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - COALESCE(LOGICAL_OR(`bool_col`), FALSE) AS `bfcol_2`, - COALESCE(LOGICAL_OR(`int64_col` <> 0), FALSE) AS `bfcol_3` - FROM `bfcte_0` -) -SELECT - `bfcol_2` AS `bool_col`, - `bfcol_3` 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/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql deleted file mode 100644 index e8556018852..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 020d7603b98..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ANY_VALUE(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 577c5929b91..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_value/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ANY_VALUE(`int64_col`) OVER (PARTITION BY `string_col`) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_w_window/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_w_window/out.sql deleted file mode 100644 index 33045c4b70d..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_any_w_window/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(LOGICAL_OR(`bool_col`) OVER (), FALSE) AS `agg_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e2a119499f2..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_quartiles/out.sql +++ /dev/null @@ -1,16 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 1c391c6691f..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_approx_top_count/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 61f073b7dc8..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index e46b49e7e48..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COUNT(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 98088d97dfc..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_count/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COUNT(`int64_col`) OVER (PARTITION BY `string_col`) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ac5525fe63f..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins.sql +++ /dev/null @@ -1,47 +0,0 @@ -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 `int_bins` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 94e9f57b28e..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/int_bins_labels.sql +++ /dev/null @@ -1,16 +0,0 @@ -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 `int_bins_labels` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 10f9778f55e..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins.sql +++ /dev/null @@ -1,8 +0,0 @@ -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 `interval_bins` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 247c71a6349..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_cut/interval_bins_labels.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - CASE - WHEN `int64_col` > 0 AND `int64_col` <= 1 - THEN 0 - WHEN `int64_col` > 1 AND `int64_col` <= 2 - THEN 1 - END AS `interval_bins_labels` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 95f53752c34..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_dense_rank/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - DENSE_RANK() OVER (ORDER BY `int64_col` DESC) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 592f3e240a4..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_bool/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `bool_col` <> LAG(`bool_col`, 1) OVER (ORDER BY `bool_col` DESC) AS `diff_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_date/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_date/out.sql deleted file mode 100644 index 4b41355d948..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_date/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - CAST(FLOOR( - DATE_DIFF(`date_col`, LAG(`date_col`, 1) OVER (ORDER BY `date_col` ASC NULLS LAST), DAY) * 86400000000 - ) AS INT64) AS `diff_date` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 866f49b1ed4..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_datetime/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - DATETIME_DIFF( - `datetime_col`, - LAG(`datetime_col`, 1) OVER (ORDER BY `datetime_col` ASC NULLS LAST), - MICROSECOND - ) AS `diff_datetime` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4c8a0880f3b..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_int/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `int64_col` - LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC NULLS LAST) AS `diff_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 364f6b69d84..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_diff_w_timestamp/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - TIMESTAMP_DIFF( - `timestamp_col`, - LAG(`timestamp_col`, 1) OVER (ORDER BY `timestamp_col` DESC), - MICROSECOND - ) AS `diff_timestamp` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 86aedff91d1..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - FIRST_VALUE(`int64_col`) OVER ( - ORDER BY `int64_col` DESC - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b7851a350ed..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_first_non_null/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - FIRST_VALUE(`int64_col` IGNORE NULLS) OVER ( - ORDER BY `int64_col` ASC NULLS LAST - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index d0bb802c333..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - LAST_VALUE(`int64_col`) OVER ( - ORDER BY `int64_col` DESC - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 39d063a3c99..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_last_non_null/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - LAST_VALUE(`int64_col` IGNORE NULLS) OVER ( - ORDER BY `int64_col` ASC NULLS LAST - ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING - ) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7e01c2c7187..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index d6dec51cdb4..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - MAX(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a35a64a8e5f..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_max/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - MAX(`int64_col`) OVER (PARTITION BY `string_col`) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 94287fc432b..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/out.sql +++ /dev/null @@ -1,23 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col`, - `duration_col`, - `int64_col` AS `bfcol_6`, - `bool_col` AS `bfcol_7`, - `duration_col` AS `bfcol_8` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` 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_0` -) -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_1` \ 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 deleted file mode 100644 index 3443cd2a680..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AVG(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b94b84ddb81..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_mean/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AVG(`int64_col`) OVER (PARTITION BY `string_col`) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7d1215163f8..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_median/out.sql +++ /dev/null @@ -1,18 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `date_col`, - `int64_col`, - `string_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 144c07d7010..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 031c19eff16..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - MIN(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2de5bd5f717..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_min/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - MIN(`int64_col`) OVER (PARTITION BY `string_col`) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e0cc1a2eac5..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_nunique/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index b855c791182..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index 3bfaedd3953..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_pop_var/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - VAR_POP(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 33204f2ff56..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/out.sql +++ /dev/null @@ -1,16 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - CASE - WHEN LOGICAL_OR(`int64_col` = 0) - THEN 0 - ELSE POWER(2, SUM(IF(`int64_col` = 0, 0, LOG(ABS(`int64_col`), 2)))) * POWER(-1, MOD(SUM(CASE WHEN SIGN(`int64_col`) = -1 THEN 1 ELSE 0 END), 2)) - 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 deleted file mode 100644 index 532349d3599..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_product/window_partition_out.sql +++ /dev/null @@ -1,16 +0,0 @@ -SELECT - CASE - WHEN LOGICAL_OR(`int64_col` = 0) OVER (PARTITION BY `string_col`) - THEN 0 - ELSE POWER( - 2, - SUM(IF(`int64_col` = 0, 0, LOG(ABS(`int64_col`), 2))) OVER (PARTITION BY `string_col`) - ) * POWER( - -1, - MOD( - SUM(CASE WHEN SIGN(`int64_col`) = -1 THEN 1 ELSE 0 END) OVER (PARTITION BY `string_col`), - 2 - ) - ) - END AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cb1541d083b..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_qcut/out.sql +++ /dev/null @@ -1,51 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - IF( - ( - `int64_col` - ) IS NOT NULL, - IF( - `int64_col` IS NULL, - NULL, - CAST(GREATEST( - CEIL( - PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) * 4 - ) - 1, - 0 - ) AS INT64) - ), - NULL - ) AS `qcut_w_int`, - IF( - ( - `int64_col` - ) IS NOT NULL, - CASE - WHEN PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) < 0 - THEN NULL - WHEN PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) <= 0.25 - THEN 0 - WHEN PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) <= 0.5 - THEN 1 - WHEN PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) <= 0.75 - THEN 2 - WHEN PERCENT_RANK() OVER (PARTITION BY ( - `int64_col` - ) IS NOT NULL ORDER BY `int64_col` ASC) <= 1 - THEN 3 - ELSE NULL - END, - NULL - ) AS `qcut_w_list` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 656d01ea2e5..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_quantile/out.sql +++ /dev/null @@ -1,17 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - PERCENTILE_CONT(`int64_col`, 0.5) OVER () AS `bfcol_4`, - PERCENTILE_CONT(CAST(`bool_col` AS INT64), 0.5) OVER () AS `bfcol_5`, - CAST(FLOOR(PERCENTILE_CONT(`int64_col`, 0.5) OVER ()) AS INT64) AS `bfcol_6` - FROM `bfcte_0` -) -SELECT - `bfcol_4` AS `int64`, - `bfcol_5` AS `bool`, - `bfcol_6` AS `int64_w_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 deleted file mode 100644 index 2170d6cdcf7..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_rank/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - RANK() OVER (ORDER BY `int64_col` DESC NULLS FIRST) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2bea343497f..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lag.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LAG(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `lag` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5055f443718..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/lead.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LEAD(`int64_col`, 1) OVER (ORDER BY `int64_col` ASC) AS `lead` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 65af6af7c79..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_shift/noop.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `int64_col` AS `noop` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql new file mode 100644 index 00000000000..78104eb5784 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size/out.sql @@ -0,0 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `string_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 `string_col_agg` +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 deleted file mode 100644 index fffb4831b95..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_size_unary/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100644 index e3c3d7b5253..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/out.sql +++ /dev/null @@ -1,23 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col`, - `duration_col`, - `int64_col` AS `bfcol_6`, - `bool_col` AS `bfcol_7`, - `duration_col` AS `bfcol_8` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` 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_0` -) -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_1` \ 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 deleted file mode 100644 index 225dd5acf66..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_std/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - STDDEV(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 index c67eef9da34..e748f712789 100644 --- 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 @@ -1,15 +1,12 @@ WITH `bfcte_0` AS ( SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `int64_col` AS `bfcol_0` + 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` + COALESCE(SUM(`bfcol_0`), 0) AS `bfcol_1` FROM `bfcte_0` ) SELECT - `bfcol_4` AS `int64_col`, - `bfcol_5` AS `bool_col` + `bfcol_1` AS `int64_col_agg` 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 deleted file mode 100644 index ea5a12edfb5..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(SUM(`int64_col`) OVER (), 0) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ec6083b1a9d..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_sum/window_partition_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(SUM(`int64_col`) OVER (PARTITION BY `string_col`), 0) AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b35d67c1ce1..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `bool_col`, - `int64_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `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 deleted file mode 100644 index e33797d02fb..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/snapshots/test_unary_compiler/test_var/window_out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - VARIANCE(`int64_col`) OVER () AS `agg_int64` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 11f5cd6bad8..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/test_binary_compiler.py +++ /dev/null @@ -1,54 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -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 - -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 deleted file mode 100644 index 0ce8437b904..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/test_nullary_compiler.py +++ /dev/null @@ -1,84 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -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 - -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 index 9306bbf6559..e3688f19dfd 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_op_registration.py @@ -13,7 +13,7 @@ # limitations under the License. import pytest -from bigframes_vendored.sqlglot import expressions as sge +from sqlglot import expressions as sge from bigframes.core.compile.sqlglot.aggregations import op_registration from bigframes.operations import aggregations as agg_ops @@ -29,6 +29,7 @@ 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) + assert reg[agg_ops.SizeOp.name](op, input) == test_func(op, input) def test_register_function_first_argument_is_not_agg_op_raise_error(): @@ -42,23 +43,3 @@ def test_func(input: sge.Expression) -> sge.Expression: ValueError, match=r".*first parameter must be a window operator.*" ): test_func(sge.to_identifier("A")) - - -def test_register_already_registered_raise_error(): - reg = op_registration.OpRegistration() - - @reg.register(agg_ops.SizeOp) - def test_func1(op, input): - return input - - with pytest.raises(ValueError, match=r".*is already registered.*"): - - @reg.register(agg_ops.SizeOp) - def test_func2(op, input): - return input - - -def test_getitem_not_registered_raise_error(): - reg = op_registration.OpRegistration() - with pytest.raises(ValueError, match=r".*is not registered.*"): - _ = reg[agg_ops.SizeOp()] 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 deleted file mode 100644 index dd8912a452b..00000000000 --- a/tests/unit/core/compile/sqlglot/aggregations/test_ordered_unary_compiler.py +++ /dev/null @@ -1,67 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -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 - -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): - 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): - 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 index 7c827cd6dc1..96cdceb3c67 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_unary_compiler.py @@ -12,624 +12,40 @@ # See the License for the specific language governing permissions and # limitations under the License. -import typing - import pytest -import bigframes.pandas as bpd -from bigframes.core import agg_expressions as agg_exprs -from bigframes.core import ( - array_value, - expression, - identifiers, - nodes, - ordering, - window_spec, -) +from bigframes.core import array_value, expression, identifiers, nodes 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( +def _apply_unary_op(obj: bpd.DataFrame, op: agg_ops.UnaryWindowOp, arg: str) -> str: + agg_node = nodes.AggregateNode( obj._block.expr.node, - agg_exprs=(nodes.ColumnDef(op, identifiers.ColumnId(new_name)),), - window_spec=window_spec, + aggregations=( + ( + expression.UnaryAggregation(op, expression.deref(arg)), + identifiers.ColumnId(arg + "_agg"), + ), + ), ) - result = array_value.ArrayValue(win_node).select_columns([new_name]) + result = array_value.ArrayValue(agg_node) sql = result.session._executor.to_sql(result, enable_cache=False) return sql -def test_all(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["bool_col", "int64_col"]] - ops_map = { - "bool_col": agg_ops.AllOp().as_expr("bool_col"), - "int64_col": agg_ops.AllOp().as_expr("int64_col"), - } - sql = _apply_unary_agg_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - - snapshot.assert_match(sql, "out.sql") - - -def test_all_w_window(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) - - # 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, "out.sql") - - -def test_any(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["bool_col", "int64_col"]] - ops_map = { - "bool_col": agg_ops.AnyOp().as_expr("bool_col"), - "int64_col": agg_ops.AnyOp().as_expr("int64_col"), - } - sql = _apply_unary_agg_ops(bf_df, list(ops_map.values()), list(ops_map.keys())) - - snapshot.assert_match(sql, "out.sql") - - -def test_any_w_window(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) - - # 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, "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_date(scalar_types_df: bpd.DataFrame, snapshot): - col_name = "date_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_date") - 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): - 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): - 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): - 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): - 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") +def test_size(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, agg_ops.SizeUnaryOp(), "string_col") 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): - 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): - bf_df = scalar_types_df[["int64_col", "bool_col"]] - agg_ops_map = { - "int64": agg_ops.QuantileOp(q=0.5).as_expr("int64_col"), - "bool": agg_ops.QuantileOp(q=0.5).as_expr("bool_col"), - "int64_w_floor": agg_ops.QuantileOp(q=0.5, should_floor_result=True).as_expr( - "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") - - -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()) - ) + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, agg_ops.SumOp(), "int64_col") 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 index 98d0452c9a7..609d3441a53 100644 --- a/tests/unit/core/compile/sqlglot/aggregations/test_windows.py +++ b/tests/unit/core/compile/sqlglot/aggregations/test_windows.py @@ -14,19 +14,17 @@ import unittest -import bigframes_vendored.sqlglot.expressions as sge import pandas as pd import pytest +import sqlglot.expressions as sge -import bigframes.core.expression as ex -import bigframes.core.identifiers as ids -import bigframes.core.ordering as ordering -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.ordering as ordering class WindowsTest(unittest.TestCase): @@ -84,37 +82,16 @@ def test_apply_window_if_present_row_bounded_no_ordering_raises(self): ), ) - def test_apply_window_if_present_grouping_no_ordering(self): + def test_apply_window_if_present_unbounded_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, - ), - ), + grouping_keys=(ex.deref("col1"),), ), ) self.assertEqual( result.sql(dialect="bigquery"), - "value OVER (PARTITION BY `col1`, CAST(`col2` AS STRING), TO_JSON_STRING(`col3`), ST_ASBINARY(`col4`))", + "value OVER (PARTITION BY `col1`)", ) def test_apply_window_if_present_range_bounded(self): @@ -127,7 +104,7 @@ def test_apply_window_if_present_range_bounded(self): ) self.assertEqual( result.sql(dialect="bigquery"), - "value OVER (ORDER BY `col1` ASC RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)", + "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): @@ -142,35 +119,21 @@ def test_apply_window_if_present_range_bounded_timedelta(self): ) self.assertEqual( result.sql(dialect="bigquery"), - "value OVER (ORDER BY `col1` ASC RANGE BETWEEN 86400000000 PRECEDING AND 43200000000 FOLLOWING)", + "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, - ) - ), - ), + grouping_keys=(ex.deref("col1"),), + ordering=(ordering.OrderingExpression(ex.deref("col2")),), 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)", + "value OVER (PARTITION BY `col1` ORDER BY `col2` IS NULL ASC NULLS LAST, `col2` ASC NULLS LAST ROWS BETWEEN 1 PRECEDING AND CURRENT ROW)", ) diff --git a/tests/unit/core/compile/sqlglot/conftest.py b/tests/unit/core/compile/sqlglot/conftest.py new file mode 100644 index 00000000000..f65343fd660 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/conftest.py @@ -0,0 +1,274 @@ +# 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"), + 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.read_gbq_table("bigframes-dev.sqlglot_test.scalar_types") + 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.read_gbq_table( + "bigframes-dev.sqlglot_test.nested_structs_types" + ) + 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.read_gbq_table( + "bigframes-dev.sqlglot_test.repeated_types" + ) + 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.read_gbq_table( + "bigframes-dev.sqlglot_test.json_types" + ) + # 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/snapshots/test_ai_ops/test_ai_classify/None/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/None/out.sql deleted file mode 100644 index fc29d96cc1a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/None/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.CLASSIFY(input => STRUCT(`string_col`), categories => ['greeting', 'rejection']) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/bigframes-dev.us.bigframes-default-connection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/bigframes-dev.us.bigframes-default-connection/out.sql deleted file mode 100644 index 969b946725b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify/bigframes-dev.us.bigframes-default-connection/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.CLASSIFY( - input => STRUCT(`string_col`), - categories => ['greeting', 'rejection'], - connection_id => 'bigframes-dev.us.bigframes-default-connection' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_multi_with_list_examples/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_multi_with_list_examples/out.sql deleted file mode 100644 index 74078e98606..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_multi_with_list_examples/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - AI.CLASSIFY( - input => STRUCT(`string_col`), - categories => ['greeting', 'rejection'], - examples => [('hi', ['greeting', 'positive']), ('bye', ['rejection', 'negative'])], - output_mode => 'multi' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_output_mode/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_output_mode/out.sql deleted file mode 100644 index 08d7476d77f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_output_mode/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.CLASSIFY( - input => STRUCT(`string_col`), - categories => ['greeting', 'rejection'], - output_mode => 'multi' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_params/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_params/out.sql deleted file mode 100644 index 30542740a2d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_classify_with_params/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - AI.CLASSIFY( - input => STRUCT(`string_col`), - categories => ['greeting', 'rejection'], - examples => [('hi', 'greeting'), ('bye', 'rejection')], - endpoint => 'gemini-2.5-flash', - max_error_ratio => 0.1 - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed/out.sql deleted file mode 100644 index 9c18a7cd532..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.EMBED(`string_col`, endpoint => 'text-embedding-005') AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_connection_id/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_connection_id/out.sql deleted file mode 100644 index 0968a101b22..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_connection_id/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.EMBED( - `string_col`, - endpoint => 'text-embedding-005', - connection_id => 'bigframes-dev.us.bigframes-default-connection' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model/out.sql deleted file mode 100644 index 4c3c76f87b6..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.EMBED(`string_col`, model => 'embeddinggemma-300m') AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model_param_and_title/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model_param_and_title/out.sql deleted file mode 100644 index 873db838682..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_model_param_and_title/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - AI.EMBED( - `string_col`, - endpoint => 'text-embedding-005', - task_type => 'retrieval_document', - title => 'My Document', - model_params => JSON '{"outputDimensionality": 256}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_task_type_and_title/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_task_type_and_title/out.sql deleted file mode 100644 index 9e4db995871..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_embed_with_task_type_and_title/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - AI.EMBED( - `string_col`, - endpoint => 'text-embedding-005', - task_type => 'RETRIEVAL_DOCUMENT', - title => 'My Document', - model_params => JSON '{"outputDimensionality": 256}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 622782fa7d6..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash', - request_type => 'SHARED' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a71bce037a5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_BOOL( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index db1ec378aaf..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_connection_id/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE_BOOL( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 76af8833e63..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_bool_with_model_param/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_BOOL( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - model_params => JSON '{}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1cef7568798..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_DOUBLE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index d0088721e38..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_connection_id/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE_DOUBLE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2b50e05b7fe..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_double_with_model_param/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_DOUBLE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - model_params => JSON '{}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9ef143c8b9e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_INT( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 3fa3e8cc05e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_connection_id/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE_INT( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 18adea8a062..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_int_with_model_param/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE_INT( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - model_params => JSON '{}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 14604cfc8df..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_connection_id/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 090a42d889f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_model_param/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.GENERATE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - model_params => JSON '{}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 31c179e7b01..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_generate_with_output_schema/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.GENERATE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash', - output_schema => 'x INT64, y FLOAT64' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/None/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/None/out.sql deleted file mode 100644 index 59cf1c02a35..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/None/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.IF( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - optimization_mode => 'MINIMIZE_COST', - max_error_ratio => 0.5 - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/bigframes-dev.us.bigframes-default-connection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/bigframes-dev.us.bigframes-default-connection/out.sql deleted file mode 100644 index 0f26ab3c6ea..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if/bigframes-dev.us.bigframes-default-connection/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - AI.IF( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection', - optimization_mode => 'MINIMIZE_COST', - max_error_ratio => 0.5 - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if_with_endpoint/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if_with_endpoint/out.sql deleted file mode 100644 index 4dd910528a4..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_if_with_endpoint/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.IF( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/None/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/None/out.sql deleted file mode 100644 index 37590eec4f0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/None/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.SCORE(prompt => STRUCT(`string_col`, ' is the same as ', `string_col`)) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/bigframes-dev.us.bigframes-default-connection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/bigframes-dev.us.bigframes-default-connection/out.sql deleted file mode 100644 index 696c7e9f318..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score/bigframes-dev.us.bigframes-default-connection/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - AI.SCORE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - connection_id => 'bigframes-dev.us.bigframes-default-connection' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score_with_endpoint_and_max_error_ratio/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score_with_endpoint_and_max_error_ratio/out.sql deleted file mode 100644 index a802e5a396b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_score_with_endpoint_and_max_error_ratio/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - AI.SCORE( - prompt => STRUCT(`string_col`, ' is the same as ', `string_col`), - endpoint => 'gemini-2.5-flash', - max_error_ratio => 0.5 - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/None/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/None/out.sql deleted file mode 100644 index 1df70aaf18e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/None/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.SIMILARITY(content1 => `string_col`, content2 => `string_col`, endpoint => 'text-embedding-005') AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/bigframes-dev.us.bigframes-default-connection/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/bigframes-dev.us.bigframes-default-connection/out.sql deleted file mode 100644 index db57188ffa0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity/bigframes-dev.us.bigframes-default-connection/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - AI.SIMILARITY( - content1 => `string_col`, - content2 => `string_col`, - endpoint => 'text-embedding-005', - connection_id => 'bigframes-dev.us.bigframes-default-connection' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model/out.sql deleted file mode 100644 index 704f9f94491..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - AI.SIMILARITY(content1 => `string_col`, content2 => `string_col`, model => 'embeddinggemma-300m') AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model_param/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model_param/out.sql deleted file mode 100644 index 5173ac43bd9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_ai_ops/test_ai_similarity_with_model_param/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - AI.SIMILARITY( - content1 => `string_col`, - content2 => `string_col`, - endpoint => 'text-embedding-005', - model_params => JSON '{"outputDimensionality": 256}' - ) AS `result` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a1f089424a1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_index/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - IF(SUBSTRING(`string_col`, 2, 1) <> '', SUBSTRING(`string_col`, 2, 1), NULL) AS `string_index`, - [`int64_col`, `int64_too`][SAFE_OFFSET(1)] AS `array_index` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1053ec1c2c6..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_reduce_op/out.sql +++ /dev/null @@ -1,27 +0,0 @@ -SELECT - ( - SELECT - COALESCE(SUM(bf_arr_reduce_uid), 0) - FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid - ) AS `sum_float`, - ( - SELECT - STDDEV(bf_arr_reduce_uid) - FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid - ) AS `std_float`, - ( - SELECT - COUNT(bf_arr_reduce_uid) - FROM UNNEST(`string_list_col`) AS bf_arr_reduce_uid - ) AS `count_str`, - ( - SELECT - COALESCE(LOGICAL_OR(bf_arr_reduce_uid), FALSE) - FROM UNNEST(`bool_list_col`) AS bf_arr_reduce_uid - ) AS `any_bool`, - ( - SELECT - ARRAY_AGG(bf_arr_reduce_uid IGNORE NULLS) - FROM UNNEST(`string_list_col`) AS bf_arr_reduce_uid - ) AS `array_agg_str` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice/out.sql deleted file mode 100644 index ffec3b8e934..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_slice/out.sql +++ /dev/null @@ -1,17 +0,0 @@ -SELECT - SUBSTRING(`string_col`, 2, 4) AS `string_slice`, - ARRAY( - SELECT - el - FROM UNNEST([`int64_col`, `int64_too`]) AS el WITH OFFSET AS slice_idx - WHERE - slice_idx >= 1 - ) AS `slice_only_start`, - ARRAY( - SELECT - el - FROM UNNEST([`int64_col`, `int64_too`]) AS el WITH OFFSET AS slice_idx - WHERE - slice_idx >= 1 AND slice_idx < 5 - ) AS `slice_start_stop` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 27587771506..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_array_to_string/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ARRAY_TO_STRING(`string_list_col`, '.') AS `string_list_col` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ 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 deleted file mode 100644 index f7d8d748b4a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_op/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - [COALESCE(`bool_col`, FALSE)] AS `bool_col`, - [COALESCE(`int64_col`, 0)] AS `int64_col`, - [COALESCE(`string_col`, ''), COALESCE(`string_col`, '')] AS `strs_col`, - [ - COALESCE(`int64_col`, 0), - CAST(COALESCE(`bool_col`, FALSE) AS INT64), - COALESCE(`float64_col`, 0.0) - ] AS `numeric_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_with_subquery_expression/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_with_subquery_expression/out.sql deleted file mode 100644 index 63dfcec026b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_array_ops/test_to_array_with_subquery_expression/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -SELECT - [ - COALESCE( - ( - SELECT - COALESCE(SUM(bf_arr_reduce_uid), 0) - FROM UNNEST(`float_list_col`) AS bf_arr_reduce_uid - ), - 0.0 - ) - ] AS `arr_subquery_coalesce` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_numeric/out.sql new file mode 100644 index 00000000000..44335805e44 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` + `bfcol_1` 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_binary_compiler/test_add_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_string/out.sql new file mode 100644 index 00000000000..de5129a6a30 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CONCAT(`bfcol_0`, '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_binary_compiler/test_add_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_timedelta/out.sql new file mode 100644 index 00000000000..a47531999b8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_add_timedelta/out.sql @@ -0,0 +1,60 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_6`, + `bfcol_2` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + TIMESTAMP_ADD(CAST(`bfcol_0` 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_binary_compiler/test_div_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql new file mode 100644 index 00000000000..03d48276a0a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_numeric/out.sql @@ -0,0 +1,122 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `rowindex` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_3` AS `bfcol_8`, + `bfcol_1` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_2` AS `bfcol_11`, + IEEE_DIVIDE(`bfcol_1`, `bfcol_1`) 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_binary_compiler/test_div_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_timedelta/out.sql new file mode 100644 index 00000000000..6e05302fc92 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_div_timedelta/out.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_6`, + `bfcol_2` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + CAST(FLOOR(IEEE_DIVIDE(86400000000, `bfcol_0`)) 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_binary_compiler/test_eq_null_match/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql new file mode 100644 index 00000000000..90cbcfe5c71 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_null_match/out.sql @@ -0,0 +1,14 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COALESCE(CAST(`bfcol_1` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bfcol_0` 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_binary_compiler/test_eq_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql new file mode 100644 index 00000000000..8e3c52310d3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_eq_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` = `bfcol_1` 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_binary_compiler/test_floordiv_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql new file mode 100644 index 00000000000..c38bc185234 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_numeric/out.sql @@ -0,0 +1,154 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `float64_col` AS `bfcol_2`, + `rowindex` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_3` AS `bfcol_8`, + `bfcol_1` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + `bfcol_2` AS `bfcol_11`, + CASE + WHEN `bfcol_1` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_1` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_1`, `bfcol_1`)) AS INT64) + END 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`, + CASE + WHEN 1 = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_9` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_9`, 1)) AS INT64) + END 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`, + CASE + WHEN 0.0 = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_19` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_19`, 0.0)) AS INT64) + END 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`, + CASE + WHEN `bfcol_33` = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_31` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_31`, `bfcol_33`)) AS INT64) + END 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`, + CASE + WHEN `bfcol_45` = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_47` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_47`, `bfcol_45`)) AS INT64) + END 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`, + CASE + WHEN 0.0 = CAST(0 AS INT64) + THEN CAST('Infinity' AS FLOAT64) * `bfcol_63` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_63`, 0.0)) AS INT64) + END 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`, + CASE + WHEN CAST(`bfcol_80` AS INT64) = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * `bfcol_79` + ELSE CAST(FLOOR(IEEE_DIVIDE(`bfcol_79`, CAST(`bfcol_80` AS INT64))) AS INT64) + END 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`, + CASE + WHEN `bfcol_99` = CAST(0 AS INT64) + THEN CAST(0 AS INT64) * CAST(`bfcol_100` AS INT64) + ELSE CAST(FLOOR(IEEE_DIVIDE(CAST(`bfcol_100` AS INT64), `bfcol_99`)) AS INT64) + END 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_binary_compiler/test_floordiv_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql new file mode 100644 index 00000000000..bc4f94d3066 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_floordiv_timedelta/out.sql @@ -0,0 +1,18 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + 43200000000 AS `bfcol_6` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `rowindex`, + `bfcol_2` AS `timestamp_col`, + `bfcol_0` AS `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_binary_compiler/test_ge_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql new file mode 100644 index 00000000000..494cb861a77 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ge_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` >= `bfcol_1` 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_binary_compiler/test_gt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql new file mode 100644 index 00000000000..b0c87688507 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_gt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` > `bfcol_1` 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_binary_compiler/test_json_set/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_json_set/out.sql new file mode 100644 index 00000000000..b226066b161 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_json_set/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_SET(`bfcol_0`, '$.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_binary_compiler/test_le_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql new file mode 100644 index 00000000000..2f642d8cbbb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_le_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` <= `bfcol_1` 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_binary_compiler/test_lt_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql new file mode 100644 index 00000000000..b244e3cbcc2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_lt_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` < `bfcol_1` 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_binary_compiler/test_mul_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_numeric/out.sql new file mode 100644 index 00000000000..a9c81f47440 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` * `bfcol_1` 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_binary_compiler/test_mul_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql new file mode 100644 index 00000000000..c8a8cf6cbf0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_mul_timedelta/out.sql @@ -0,0 +1,43 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2`, + `duration_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_8`, + `bfcol_2` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + INTERVAL `bfcol_3` MICROSECOND 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_binary_compiler/test_ne_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql new file mode 100644 index 00000000000..6fba4b960f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_ne_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` <> `bfcol_1` 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_binary_compiler/test_obj_make_ref/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_obj_make_ref/out.sql new file mode 100644 index 00000000000..e3228feaaa8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_obj_make_ref/out.sql @@ -0,0 +1,15 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`bfcol_1`, 'bigframes-dev.test-region.bigframes-default-connection') AS `bfcol_4` + FROM `bfcte_0` +) +SELECT + `bfcol_0` AS `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_binary_compiler/test_sub_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_numeric/out.sql new file mode 100644 index 00000000000..a43fa2df67d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_numeric/out.sql @@ -0,0 +1,54 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_1` AS `bfcol_7`, + `bfcol_0` AS `bfcol_8`, + `bfcol_1` - `bfcol_1` 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_binary_compiler/test_sub_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql new file mode 100644 index 00000000000..460f941d1bd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_binary_compiler/test_sub_timedelta/out.sql @@ -0,0 +1,82 @@ +WITH `bfcte_0` AS ( + SELECT + `date_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1`, + `timestamp_col` AS `bfcol_2`, + `duration_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_1` AS `bfcol_8`, + `bfcol_2` AS `bfcol_9`, + `bfcol_0` AS `bfcol_10`, + INTERVAL `bfcol_3` MICROSECOND 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_bool_ops/test_and_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql deleted file mode 100644 index d6f6587ead9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_and_op/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - `rowindex`, - `bool_col`, - `int64_col`, - `int64_col` & `int64_col` AS `int_and_int`, - `bool_col` AND `bool_col` AS `bool_and_bool`, - IF(`bool_col` = FALSE, `bool_col`, NULL) AS `bool_and_null`, - IF(`bool_col` = FALSE, `bool_col`, NULL) AS `null_and_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index dad4cee9d0b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_or_op/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - `rowindex`, - `bool_col`, - `int64_col`, - `int64_col` | `int64_col` AS `int_and_int`, - `bool_col` OR `bool_col` AS `bool_and_bool`, - IF(`bool_col` = TRUE, `bool_col`, NULL) AS `bool_and_null`, - IF(`bool_col` = TRUE, `bool_col`, NULL) AS `null_and_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4be3b9f94ad..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_bool_ops/test_xor_op/out.sql +++ /dev/null @@ -1,23 +0,0 @@ -SELECT - `rowindex`, - `bool_col`, - `int64_col`, - `int64_col` ^ `int64_col` AS `int_and_int`, - ( - `bool_col` AND NOT `bool_col` - ) OR ( - NOT `bool_col` AND `bool_col` - ) AS `bool_and_bool`, - ( - `bool_col` AND NOT CAST(NULL AS BOOLEAN) - ) - OR ( - NOT `bool_col` AND CAST(NULL AS BOOLEAN) - ) AS `bool_and_null`, - ( - `bool_col` AND NOT CAST(NULL AS BOOLEAN) - ) - OR ( - NOT `bool_col` AND CAST(NULL AS BOOLEAN) - ) AS `null_and_bool` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 3d23b8576ec..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_null_match/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(CAST(`int64_col` AS STRING), '$NULL_SENTINEL$') = COALESCE(CAST(CAST(`bool_col` AS INT64) AS STRING), '$NULL_SENTINEL$') AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7827731881e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_eq_numeric/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` = `int64_col` AS `int_eq_int`, - `int64_col` = 1 AS `int_eq_1`, - `int64_col` IS NULL AS `int_eq_null`, - `int64_col` IS NULL AS `null_eq_int`, - `int64_col` = CAST(`bool_col` AS INT64) AS `int_eq_bool`, - CAST(`bool_col` AS INT64) = `int64_col` AS `bool_eq_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5903cf03699..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ge_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` >= `int64_col` AS `int_ge_int`, - `int64_col` >= 1 AS `int_ge_1`, - NULL AS `null_ge_int`, - `int64_col` >= CAST(`bool_col` AS INT64) AS `int_ge_bool`, - CAST(`bool_col` AS INT64) >= `int64_col` AS `bool_ge_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 42bf029240f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_gt_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` > `int64_col` AS `int_gt_int`, - `int64_col` > 1 AS `int_gt_1`, - NULL AS `null_gt_int`, - `int64_col` > CAST(`bool_col` AS INT64) AS `int_gt_bool`, - CAST(`bool_col` AS INT64) > `int64_col` AS `bool_gt_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 308e6f9cbd7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_is_in/out.sql +++ /dev/null @@ -1,19 +0,0 @@ -SELECT - COALESCE(`bool_col` IN (TRUE, FALSE), FALSE) AS `bools`, - COALESCE(`int64_col` IN (1, 2, 3), FALSE) AS `ints`, - `int64_col` IS NULL AS `ints_w_null`, - COALESCE(`int64_col` IN (1.0, 2.0, 3.0), FALSE) AS `floats`, - FALSE AS `strings`, - COALESCE(`int64_col` IN (2.5, 3, 1e-10, CAST('Infinity' AS FLOAT64), NULL, 0), FALSE) AS `mixed`, - FALSE AS `empty`, - FALSE AS `empty_wo_match_nulls`, - COALESCE(`int64_col` IN (123456), FALSE) AS `ints_wo_match_nulls`, - ( - `float64_col` IS NULL - ) OR `float64_col` IN (1, 2, 3) AS `float_in_ints`, - ( - `int64_col` IS NULL - ) OR `int64_col` IN (2) AS `mixed_with_null`, - COALESCE(CAST(`bool_col` AS INT64) IN (1, 2.5), FALSE) AS `bool_in_mixed`, - `int64_col` IS NULL AS `only_null_match` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index c6c86510102..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_le_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` <= `int64_col` AS `int_le_int`, - `int64_col` <= 1 AS `int_le_1`, - NULL AS `null_le_int`, - `int64_col` <= CAST(`bool_col` AS INT64) AS `int_le_bool`, - CAST(`bool_col` AS INT64) <= `int64_col` AS `bool_le_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ec5c317a8e5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_lt_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` < `int64_col` AS `int_lt_int`, - `int64_col` < 1 AS `int_lt_1`, - NULL AS `null_lt_int`, - `int64_col` < CAST(`bool_col` AS INT64) AS `int_lt_bool`, - CAST(`bool_col` AS INT64) < `int64_col` AS `bool_lt_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a469fa47cf1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_maximum_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - GREATEST(`int64_col`, `float64_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ea82af979a3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_minimum_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LEAST(`int64_col`, `float64_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 448a6146294..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_comparison_ops/test_ne_numeric/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` <> `int64_col` AS `int_ne_int`, - `int64_col` <> 1 AS `int_ne_1`, - ( - `int64_col` - ) IS NOT NULL AS `int_ne_null`, - ( - `int64_col` - ) IS NOT NULL AS `null_ne_int`, - `int64_col` <> CAST(`bool_col` AS INT64) AS `int_ne_bool`, - CAST(`bool_col` AS INT64) <> `int64_col` AS `bool_ne_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b1ccf096cfa..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_add_timedelta/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `date_col`, - TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `date_add_timedelta`, - TIMESTAMP_ADD(`timestamp_col`, INTERVAL 86400000000 MICROSECOND) AS `timestamp_add_timedelta`, - TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `timedelta_add_date`, - TIMESTAMP_ADD(`timestamp_col`, INTERVAL 86400000000 MICROSECOND) AS `timedelta_add_timestamp`, - 172800000000 AS `timedelta_add_timedelta` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index eb0d2f11049..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_date/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - DATE(`timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4b0696386c1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_datetime_to_integer_label/out.sql +++ /dev/null @@ -1,76 +0,0 @@ -SELECT - CAST(FLOOR( - IEEE_DIVIDE( - UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) - UNIX_MICROS(CAST(`timestamp_col` AS TIMESTAMP)), - 86400000000 - ) - ) AS INT64) AS `fixed_freq`, - CAST(FLOOR(IEEE_DIVIDE(UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) - 0, 86400000000)) AS INT64) AS `origin_epoch`, - CAST(FLOOR( - IEEE_DIVIDE( - UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) - UNIX_MICROS(CAST(CAST(`timestamp_col` AS DATE) AS TIMESTAMP)), - 86400000000 - ) - ) AS INT64) AS `origin_start_day`, - 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 `non_fixed_freq_weekly`, - CASE - WHEN ( - EXTRACT(YEAR FROM `datetime_col`) * 12 + EXTRACT(MONTH FROM `datetime_col`) - 1 - ) = ( - EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1 - ) - THEN 0 - ELSE CAST(FLOOR( - IEEE_DIVIDE( - ( - EXTRACT(YEAR FROM `datetime_col`) * 12 + EXTRACT(MONTH FROM `datetime_col`) - 1 - ) - ( - EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1 - ) - 1, - 1 - ) - ) AS INT64) + 1 - END AS `non_fixed_freq_monthly`, - CASE - WHEN ( - EXTRACT(YEAR FROM `datetime_col`) * 4 + EXTRACT(QUARTER FROM `datetime_col`) - 1 - ) = ( - EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1 - ) - THEN 0 - ELSE CAST(FLOOR( - IEEE_DIVIDE( - ( - EXTRACT(YEAR FROM `datetime_col`) * 4 + EXTRACT(QUARTER FROM `datetime_col`) - 1 - ) - ( - EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1 - ) - 1, - 1 - ) - ) AS INT64) + 1 - END AS `non_fixed_freq_quarterly`, - CASE - WHEN EXTRACT(YEAR FROM `datetime_col`) = EXTRACT(YEAR FROM `timestamp_col`) - THEN 0 - ELSE CAST(FLOOR( - IEEE_DIVIDE(EXTRACT(YEAR FROM `datetime_col`) - EXTRACT(YEAR FROM `timestamp_col`) - 1, 1) - ) AS INT64) + 1 - END AS `non_fixed_freq_yearly` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b9c030cb53e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_day/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(DAY FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a25d520d804..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofweek/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - CAST(MOD(EXTRACT(DAYOFWEEK FROM `datetime_col`) + 5, 7) AS INT64) AS `datetime_col`, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) AS `timestamp_col`, - CAST(MOD(EXTRACT(DAYOFWEEK FROM `date_col`) + 5, 7) AS INT64) AS `date_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 87a410911b5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_dayofyear/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(DAYOFYEAR FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 49fb8fe5749..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_floor_dt/out.sql +++ /dev/null @@ -1,14 +0,0 @@ -SELECT - TIMESTAMP_TRUNC(`timestamp_col`, MICROSECOND) AS `timestamp_col_us`, - TIMESTAMP_TRUNC(`timestamp_col`, MILLISECOND) AS `timestamp_col_ms`, - TIMESTAMP_TRUNC(`timestamp_col`, SECOND) AS `timestamp_col_s`, - TIMESTAMP_TRUNC(`timestamp_col`, MINUTE) AS `timestamp_col_min`, - TIMESTAMP_TRUNC(`timestamp_col`, HOUR) AS `timestamp_col_h`, - TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `timestamp_col_D`, - TIMESTAMP_TRUNC(`timestamp_col`, WEEK(MONDAY)) AS `timestamp_col_W`, - TIMESTAMP_TRUNC(`timestamp_col`, MONTH) AS `timestamp_col_M`, - TIMESTAMP_TRUNC(`timestamp_col`, QUARTER) AS `timestamp_col_Q`, - TIMESTAMP_TRUNC(`timestamp_col`, YEAR) AS `timestamp_col_Y`, - TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `datetime_col_q`, - TIMESTAMP_TRUNC(`datetime_col`, MICROSECOND) AS `datetime_col_us` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e971057f527..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_hour/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(HOUR FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime/out.sql deleted file mode 100644 index 2a1bd0e2e21..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime/out.sql +++ /dev/null @@ -1,58 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `rowindex`, - `timestamp_col` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` -), `bfcte_1` AS ( - SELECT - *, - CAST(TIMESTAMP_MICROS( - CAST(CAST(`rowindex` AS BIGNUMERIC) * 86400000000 + CAST(UNIX_MICROS(CAST(`timestamp_col` AS TIMESTAMP)) AS BIGNUMERIC) AS INT64) - ) AS TIMESTAMP) AS `bfcol_2`, - CAST(DATETIME( - CASE - WHEN ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 = 12 - THEN CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) - ) AS INT64) + 1 - ELSE CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) - ) AS INT64) - END, - CASE - WHEN ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 = 12 - THEN 1 - ELSE ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 + 1 - END, - 1, - 0, - 0, - 0 - ) - INTERVAL 1 DAY AS TIMESTAMP) AS `bfcol_3` - FROM `bfcte_0` -) -SELECT - `bfcol_2` AS `fixed_freq`, - `bfcol_3` AS `non_fixed_freq` -FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_fixed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_fixed/out.sql deleted file mode 100644 index 244bd88deb7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_fixed/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - CAST(TIMESTAMP_MICROS( - CAST(CAST(`rowindex` AS BIGNUMERIC) * 86400000000 + CAST(UNIX_MICROS(CAST(`timestamp_col` AS TIMESTAMP)) AS BIGNUMERIC) AS INT64) - ) AS TIMESTAMP) AS `fixed_freq` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_month/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_month/out.sql deleted file mode 100644 index 1ece688b91f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_month/out.sql +++ /dev/null @@ -1,39 +0,0 @@ -SELECT - CAST(TIMESTAMP( - DATETIME( - CASE - WHEN MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1, - 12 - ) + 1 = 12 - THEN CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1, - 12 - ) - ) AS INT64) + 1 - ELSE CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1, - 12 - ) - ) AS INT64) - END, - CASE - WHEN MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1, - 12 - ) + 1 = 12 - THEN 1 - ELSE MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 12 + EXTRACT(MONTH FROM `timestamp_col`) - 1, - 12 - ) + 1 + 1 - END, - 1, - 0, - 0, - 0 - ) - ) - INTERVAL 1 DAY AS TIMESTAMP) AS `non_fixed_freq_monthly` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_quarter/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_quarter/out.sql deleted file mode 100644 index 683b26be91b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_quarter/out.sql +++ /dev/null @@ -1,43 +0,0 @@ -SELECT - CAST(DATETIME( - CASE - WHEN ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 = 12 - THEN CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) - ) AS INT64) + 1 - ELSE CAST(FLOOR( - IEEE_DIVIDE( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) - ) AS INT64) - END, - CASE - WHEN ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 = 12 - THEN 1 - ELSE ( - MOD( - `rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) * 4 + EXTRACT(QUARTER FROM `timestamp_col`) - 1, - 4 - ) + 1 - ) * 3 + 1 - END, - 1, - 0, - 0, - 0 - ) - INTERVAL 1 DAY AS TIMESTAMP) AS `non_fixed_freq` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_week/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_week/out.sql deleted file mode 100644 index 6196e6976b0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_week/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CAST(TIMESTAMP_MICROS( - CAST(CAST(`rowindex` AS BIGNUMERIC) * 604800000000 + CAST(UNIX_MICROS( - TIMESTAMP_TRUNC(CAST(`timestamp_col` AS TIMESTAMP), WEEK(MONDAY)) + INTERVAL 6 DAY - ) AS BIGNUMERIC) AS INT64) - ) AS TIMESTAMP) AS `non_fixed_freq_weekly` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_year/out.sql deleted file mode 100644 index e0d05ec5b4b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_integer_label_to_datetime_year/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CAST(TIMESTAMP(DATETIME(`rowindex` * 1 + EXTRACT(YEAR FROM `timestamp_col`) + 1, 1, 1, 0, 0, 0)) - INTERVAL 1 DAY AS TIMESTAMP) AS `non_fixed_freq_yearly` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index bf7dfea7378..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_day/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CAST(MOD(EXTRACT(DAYOFWEEK FROM `timestamp_col`) + 5, 7) AS INT64) + 1 AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ce231592164..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_week/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(ISOWEEK FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index aea4bec4371..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_iso_year/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(ISOYEAR FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ed1ffcee104..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_minute/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(MINUTE FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8defb0312e9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_month/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(MONTH FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 0ae08c77ad0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_normalize/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TIMESTAMP_TRUNC(`timestamp_col`, DAY) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9426f685855..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_quarter/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(QUARTER FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 953a0ff762a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_second/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(SECOND FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 308c040640d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_strftime/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - FORMAT_DATE('%Y-%m-%d', `date_col`) AS `date_col`, - FORMAT_DATETIME('%Y-%m-%d', `datetime_col`) AS `datetime_col`, - FORMAT_TIME('%Y-%m-%d', `time_col`) AS `time_col`, - FORMAT_TIMESTAMP('%Y-%m-%d', `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5c8b130d59d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_sub_timedelta/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `duration_col`, - `date_col`, - TIMESTAMP_SUB(CAST(`date_col` AS DATETIME), INTERVAL `duration_col` MICROSECOND) AS `date_sub_timedelta`, - TIMESTAMP_SUB(`timestamp_col`, INTERVAL `duration_col` MICROSECOND) AS `timestamp_sub_timedelta`, - TIMESTAMP_DIFF(CAST(`date_col` AS DATETIME), CAST(`date_col` AS DATETIME), MICROSECOND) AS `timestamp_sub_date`, - TIMESTAMP_DIFF(`timestamp_col`, `timestamp_col`, MICROSECOND) AS `date_sub_timestamp`, - `duration_col` - `duration_col` AS `timedelta_sub_timedelta` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e46ca373909..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_time/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TIME(`timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 50142f20ba5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_datetime/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - DATETIME(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)), 'UTC') AS `int64_col`, - SAFE_CAST(`string_col` AS DATETIME) AS `string_col`, - DATETIME(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)), 'UTC') AS `float64_col`, - DATETIME(`timestamp_col`, 'UTC') AS `timestamp_col`, - SAFE_CAST(`string_col` AS DATETIME) AS `string_col_fmt` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` 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 deleted file mode 100644 index e0fb530cc6d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_to_timestamp/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `int64_col`, - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`float64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `float64_col`, - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000000) AS INT64)) AS TIMESTAMP) AS `int64_col_s`, - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 1000) AS INT64)) AS TIMESTAMP) AS `int64_col_ms`, - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col`) AS INT64)) AS TIMESTAMP) AS `int64_col_us`, - CAST(TIMESTAMP_MICROS(CAST(TRUNC(`int64_col` * 0.001) AS INT64)) AS TIMESTAMP) AS `int64_col_ns`, - TIMESTAMP(`datetime_col`) AS `datetime_col`, - PARSE_TIMESTAMP('%Y-%m-%d', `string_col`, 'UTC') AS `string_col_fmt` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a212164e6ce..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_micros/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - UNIX_MICROS(`timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8df5ad956a3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_millis/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - UNIX_MILLIS(`timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7344ca82949..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_unix_seconds/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - UNIX_SECONDS(`timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f1a1d7085ef..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_datetime_ops/test_year/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - EXTRACT(YEAR FROM `timestamp_col`) AS `timestamp_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2f75cf4cf7f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_bool/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - `bool_col`, - `float64_col` <> 0 AS `float64_col`, - `float64_col` <> 0 AS `float64_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7f7bd86084e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_float/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - CAST(CAST(`bool_col` AS INT64) AS FLOAT64) AS `bool_col`, - CAST('1.34235e4' AS FLOAT64) AS `str_const`, - SAFE_CAST(SAFE_CAST(`bool_col` AS INT64) AS FLOAT64) AS `bool_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` 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 deleted file mode 100644 index c9450a92800..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_from_json/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - SAFE.INT64(`json_col`) AS `int64_col`, - SAFE.FLOAT64(`json_col`) AS `float64_col`, - SAFE.BOOL(`json_col`) AS `bool_col`, - SAFE.STRING(`json_col`) AS `string_col`, - SAFE.INT64(`json_col`) AS `int64_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 8d44c674dc9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_int/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - UNIX_MICROS(CAST(`datetime_col` AS TIMESTAMP)) AS `datetime_col`, - UNIX_MICROS(SAFE_CAST(`datetime_col` AS TIMESTAMP)) AS `datetime_w_safe`, - TIME_DIFF(CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `time_col`, - TIME_DIFF(SAFE_CAST(`time_col` AS TIME), '00:00:00', MICROSECOND) AS `time_w_safe`, - UNIX_MICROS(`timestamp_col`) AS `timestamp_col`, - CAST(TRUNC(`numeric_col`) AS INT64) AS `numeric_col`, - CAST(TRUNC(`float64_col`) AS INT64) AS `float64_col`, - SAFE_CAST(TRUNC(`float64_col`) AS INT64) AS `float64_w_safe`, - CAST('100' AS INT64) AS `str_const` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b62cee83a91..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_json/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - PARSE_JSON(CAST(`int64_col` AS STRING)) AS `int64_col`, - PARSE_JSON(CAST(`float64_col` AS STRING)) AS `float64_col`, - PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bool_col`, - PARSE_JSON(`string_col`) AS `string_col`, - PARSE_JSON(CAST(`bool_col` AS STRING)) AS `bool_w_safe`, - SAFE.PARSE_JSON(`string_col`) AS `string_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 174f18d9823..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_string/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - CAST(`int64_col` AS STRING) AS `int64_col`, - INITCAP(CAST(`bool_col` AS STRING)) AS `bool_col`, - INITCAP(SAFE_CAST(`bool_col` AS STRING)) AS `bool_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` 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 deleted file mode 100644 index f50505592bb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_astype_time_like/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - CAST(TIMESTAMP_MICROS(`int64_col`) AS DATETIME) AS `int64_to_datetime`, - CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `int64_to_time`, - CAST(TIMESTAMP_MICROS(`int64_col`) AS TIMESTAMP) AS `int64_to_timestamp`, - SAFE_CAST(TIMESTAMP_MICROS(`int64_col`) AS TIME) AS `int64_to_time_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_binary_remote_function_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_binary_remote_function_op/out.sql deleted file mode 100644 index 29f9d69cb25..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_binary_remote_function_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `my_project`.`my_dataset`.`my_routine`(`int64_col`, `float64_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 58e901fecc0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_case_when_op/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -SELECT - CASE WHEN `bool_col` THEN `int64_col` END AS `single_case`, - CASE WHEN `bool_col` THEN `int64_col` WHEN `bool_col` THEN `int64_too` END AS `double_case`, - CASE WHEN `bool_col` THEN `bool_col` WHEN `bool_col` THEN `bool_col` END AS `bool_types_case`, - CASE - WHEN `bool_col` - THEN `int64_col` - WHEN `bool_col` - THEN CAST(`bool_col` AS INT64) - WHEN `bool_col` - THEN `float64_col` - END AS `mixed_types_cast` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index bbfeb304181..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_clip/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - GREATEST(LEAST(`rowindex`, `int64_too`), `int64_col`) AS `result_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4f88ec71d88..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_coalesce/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - `int64_col`, - COALESCE(`int64_too`, `int64_col`) AS `int64_too` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ae6f975da5a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_fillna/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COALESCE(`int64_col`, `float64_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b1afe9db39b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_hash/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - FARM_FINGERPRINT(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5cd1b15a776..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_invert/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - ~( - `int64_col` - ) AS `int64_col`, - ~( - `bytes_col` - ) AS `bytes_col`, - NOT ( - `bool_col` - ) AS `bool_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cfe38ae3600..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_isnull/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - ( - `float64_col` - ) IS NULL AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 3b1d0446b3b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_map/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - CASE - WHEN `string_col` = 'value1' - THEN 'mapped1' - WHEN `string_col` IS NULL - THEN 'UNKNOWN' - ELSE `string_col` - END AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_nary_remote_function_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_nary_remote_function_op/out.sql deleted file mode 100644 index a1977d809f7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_nary_remote_function_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `my_project`.`my_dataset`.`my_routine`(`int64_col`, `float64_col`, `string_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 97b9f54f429..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_notnull/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - ( - `float64_col` - ) IS NOT NULL AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_remote_function_op/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_remote_function_op/out.sql deleted file mode 100644 index a1977d809f7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_remote_function_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `my_project`.`my_dataset`.`my_routine`(`int64_col`, `float64_col`, `string_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f5bf9b3b6ee..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_row_key/out.sql +++ /dev/null @@ -1,46 +0,0 @@ -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 `row_key` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8f50ff28ca4..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_sql_scalar_op/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CAST(`bool_col` AS INT64) + BYTE_LENGTH(`bytes_col`) AS `bool_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_to_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_to_json/out.sql deleted file mode 100644 index 86d6f0e9fbb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_to_json/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - IF(`int64_col` IS NULL, NULL, TO_JSON(`int64_col`)) AS `int64_col`, - IF(`float64_col` IS NULL, NULL, TO_JSON(`float64_col`)) AS `float64_col`, - IF(`bool_col` IS NULL, NULL, TO_JSON(`bool_col`)) AS `bool_col`, - SAFE.PARSE_JSON(`string_col`) AS `string_col`, - IF(`bool_col` IS NULL, NULL, TO_JSON(`bool_col`)) AS `bool_w_safe`, - SAFE.PARSE_JSON(`string_col`) AS `string_w_safe` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1ca3b009898..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_generic_ops/test_where/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - IF(`bool_col`, `int64_col`, `float64_col`) AS `result_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 78c786b036e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_area/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_AREA(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 526c0c37d7e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_astext/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_ASTEXT(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4bf43469cf5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_boundary/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_BOUNDARY(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 40669569fbb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_buffer/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_BUFFER(`geography_col`, 1.0, 8.0, FALSE) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index accd33bd627..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_centroid/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_CENTROID(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e4a718d42a9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_convexhull/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_CONVEXHULL(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2a17ef1c7c8..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_difference/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_DIFFERENCE(`geography_col`, `geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4c55ddd0824..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_distance/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - ST_DISTANCE(`geography_col`, `geography_col`, TRUE) AS `spheroid`, - ST_DISTANCE(`geography_col`, `geography_col`, FALSE) AS `no_spheroid` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index db62766d4c9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogfromtext/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - SAFE.ST_GEOGFROMTEXT(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 3299ef0bd2d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_geogpoint/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_GEOGPOINT(`rowindex`, `rowindex_2`) AS `rowindex` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a615ddf042e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_intersection/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_INTERSECTION(`geography_col`, `geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4f04e70b569..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_isclosed/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_ISCLOSED(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ee64b20ca46..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_st_length/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_LENGTH(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index c1ab623d9a1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_x/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_X(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e7575606e6e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_geo_ops/test_geo_y/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ST_Y(`geography_col`) AS `geography_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7a7ad2f394d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_EXTRACT(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index f2c4cd72985..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_array/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_EXTRACT_ARRAY(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 61e8bae8a32..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_extract_string_array/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_EXTRACT_STRING_ARRAY(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 78004c1180c..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_keys/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - JSON_KEYS(`json_col`, NULL) AS `json_keys`, - JSON_KEYS(`json_col`, 2) AS `json_keys_w_max_depth` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 8aa312e9d75..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_QUERY(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 898068fe595..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_query_array/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_QUERY_ARRAY(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index e515d5fdc3b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_set/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_SET(`json_col`, '$.a', 100) AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index c9a73ae1942..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_VALUE(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value_array/out.sql deleted file mode 100644 index 8250c02934e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_json_value_array/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - JSON_VALUE_ARRAY(`json_col`, '$') AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ 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 deleted file mode 100644 index 55a195edf20..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_parse_json/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - PARSE_JSON(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 0545577e27f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - SAFE.PARSE_JSON(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 62886c26ed9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_json_ops/test_to_json_string/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TO_JSON_STRING(`json_col`) AS `json_col` -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_literals/test_float_literals/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_literals/test_float_literals/out.sql deleted file mode 100644 index 030e733edd7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_literals/test_float_literals/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - CAST('Infinity' AS FLOAT64) AS `inf`, - CAST('-Infinity' AS FLOAT64) AS `ninf`, - NULL AS `nan`, - -0.0 AS `neg_zero`, - 1e-05 AS `0.00001`, - 1e-10 AS `1E-10` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index bc53c60895a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_abs/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ABS(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 3aa06fe16e3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` + `int64_col` AS `int_add_int`, - `int64_col` + 1 AS `int_add_1`, - NULL AS `int_add_null`, - `int64_col` + CAST(`bool_col` AS INT64) AS `int_add_bool`, - CAST(`bool_col` AS INT64) + `int64_col` AS `bool_add_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cf4051464b7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_string/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CONCAT(`string_col`, 'a') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b1ccf096cfa..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_add_timedelta/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `date_col`, - TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `date_add_timedelta`, - TIMESTAMP_ADD(`timestamp_col`, INTERVAL 86400000000 MICROSECOND) AS `timestamp_add_timedelta`, - TIMESTAMP_ADD(CAST(`date_col` AS DATETIME), INTERVAL 86400000000 MICROSECOND) AS `timedelta_add_date`, - TIMESTAMP_ADD(`timestamp_col`, INTERVAL 86400000000 MICROSECOND) AS `timedelta_add_timestamp`, - 172800000000 AS `timedelta_add_timedelta` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index d00086dfde8..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccos/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN ABS(`float64_col`) > 1 - THEN CAST('NaN' AS FLOAT64) - ELSE ACOS(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f1a04757a0a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arccosh/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN `float64_col` < 1 - THEN CAST('NaN' AS FLOAT64) - ELSE ACOSH(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index eff8f1f5007..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsin/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN ABS(`float64_col`) > 1 - THEN CAST('NaN' AS FLOAT64) - ELSE ASIN(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 557407f09ee..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arcsinh/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ASINH(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index d99b62f2cdb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ATAN(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 463896e981f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctan2/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - ATAN2(`int64_col`, `float64_col`) AS `int64_col`, - ATAN2(CAST(`bool_col` AS INT64), `float64_col`) AS `bool_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9b016071480..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_arctanh/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - CASE - WHEN ABS(`float64_col`) < 1 - THEN ATANH(`float64_col`) - WHEN ABS(`float64_col`) > 1 - THEN CAST('NaN' AS FLOAT64) - ELSE CAST('Infinity' AS FLOAT64) * `float64_col` - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f69ae7f2760..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ceil/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CEIL(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 427dfbb9a93..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cos/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - COS(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 0f119c254f0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosh/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN ABS(`float64_col`) > 709.78 - THEN CAST('Infinity' AS FLOAT64) - ELSE COSH(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1c482fc8a78..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_cosine_distance/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - ML.DISTANCE(`int_list_col`, `int_list_col`, 'COSINE') AS `int_list_col`, - ML.DISTANCE(`float_list_col`, `float_list_col`, 'COSINE') AS `float_list_col` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ 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 deleted file mode 100644 index e2ccf96410a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_numeric/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `float64_col`, - IEEE_DIVIDE(`int64_col`, `int64_col`) AS `int_div_int`, - IEEE_DIVIDE(`int64_col`, 1) AS `int_div_1`, - IEEE_DIVIDE(`int64_col`, 0.0) AS `int_div_0`, - NULL AS `int_div_null`, - IEEE_DIVIDE(`int64_col`, `float64_col`) AS `int_div_float`, - IEEE_DIVIDE(`float64_col`, `int64_col`) AS `float_div_int`, - IEEE_DIVIDE(`float64_col`, 0.0) AS `float_div_0`, - IEEE_DIVIDE(`int64_col`, CAST(`bool_col` AS INT64)) AS `int_div_bool`, - IEEE_DIVIDE(CAST(`bool_col` AS INT64), `int64_col`) AS `bool_div_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a733ed81278..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_div_timedelta/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `int64_col`, - CAST(IF( - IEEE_DIVIDE(86400000000, `int64_col`) > 0, - FLOOR(IEEE_DIVIDE(86400000000, `int64_col`)), - CEIL(IEEE_DIVIDE(86400000000, `int64_col`)) - ) AS INT64) AS `timedelta_div_numeric` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 349d78584a9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_euclidean_distance/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - ML.DISTANCE(`int_list_col`, `int_list_col`, 'EUCLIDEAN') AS `int_list_col`, - ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'EUCLIDEAN') AS `numeric_list_col` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ 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 deleted file mode 100644 index 178282ca087..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_exp/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN `float64_col` > 709.78 - THEN CAST('Infinity' AS FLOAT64) - ELSE EXP(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 6c896448f24..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_expm1/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - IF(`float64_col` > 709.78, CAST('Infinity' AS FLOAT64), EXP(`float64_col`) - 1) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 31b715623cb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floor/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - FLOOR(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_numeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_numeric/out.sql deleted file mode 100644 index 8307b1b8ada..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_numeric/out.sql +++ /dev/null @@ -1,48 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `float64_col`, - CASE - WHEN `int64_col` = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `int64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`int64_col`, `int64_col`)) AS INT64) - END AS `int_div_int`, - CASE - WHEN 1 = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `int64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`int64_col`, 1)) AS INT64) - END AS `int_div_1`, - CASE - WHEN 0.0 = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `int64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`int64_col`, 0.0)) AS INT64) - END AS `int_div_0`, - NULL AS `int_div_null`, - CASE - WHEN `float64_col` = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `int64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`int64_col`, `float64_col`)) AS INT64) - END AS `int_div_float`, - CASE - WHEN `int64_col` = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `float64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`float64_col`, `int64_col`)) AS INT64) - END AS `float_div_int`, - CASE - WHEN 0.0 = CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) * `float64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`float64_col`, 0.0)) AS INT64) - END AS `float_div_0`, - NULL AS `float_div_null`, - CASE - WHEN CAST(`bool_col` AS INT64) = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `int64_col` - ELSE CAST(FLOOR(IEEE_DIVIDE(`int64_col`, CAST(`bool_col` AS INT64))) AS INT64) - END AS `int_div_bool`, - CASE - WHEN `int64_col` = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * CAST(`bool_col` AS INT64) - ELSE CAST(FLOOR(IEEE_DIVIDE(CAST(`bool_col` AS INT64), `int64_col`)) AS INT64) - END AS `bool_div_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4d978991eb5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_floordiv_timedelta/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `date_col`, - 43200000000 AS `timedelta_div_numeric` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_isfinite/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_isfinite/out.sql deleted file mode 100644 index 54cbe2dd689..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_isfinite/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - NOT IS_INF(`float64_col`) OR IS_NAN(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 53ab88b7fc3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_ln/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - CASE - WHEN `float64_col` IS NULL - THEN NULL - WHEN `float64_col` > 0 - THEN LN(`float64_col`) - WHEN `float64_col` < 0 - THEN CAST('NaN' AS FLOAT64) - ELSE CAST('-Infinity' AS FLOAT64) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2037649332f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log10/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - CASE - WHEN `float64_col` IS NULL - THEN NULL - WHEN `float64_col` > 0 - THEN LOG(`float64_col`, 10) - WHEN `float64_col` < 0 - THEN CAST('NaN' AS FLOAT64) - ELSE CAST('-Infinity' AS FLOAT64) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f7ddf4c223f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_log1p/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - CASE - WHEN `float64_col` IS NULL - THEN NULL - WHEN `float64_col` > -1 - THEN LN(1 + `float64_col`) - WHEN `float64_col` < -1 - THEN CAST('NaN' AS FLOAT64) - ELSE CAST('-Infinity' AS FLOAT64) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b6132a9fd6e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_manhattan_distance/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - ML.DISTANCE(`float_list_col`, `float_list_col`, 'MANHATTAN') AS `float_list_col`, - ML.DISTANCE(`numeric_list_col`, `numeric_list_col`, 'MANHATTAN') AS `numeric_list_col` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ 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 deleted file mode 100644 index 78107415b43..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mod_numeric/out.sql +++ /dev/null @@ -1,194 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `float64_col`, - 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 `int_mod_int`, - 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 `int_mod_int_neg`, - CASE - WHEN 1 = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `int64_col` - WHEN 1 < CAST(0 AS INT64) AND ( - MOD(`int64_col`, 1) - ) > CAST(0 AS INT64) - THEN 1 + ( - MOD(`int64_col`, 1) - ) - WHEN 1 > CAST(0 AS INT64) AND ( - MOD(`int64_col`, 1) - ) < CAST(0 AS INT64) - THEN 1 + ( - MOD(`int64_col`, 1) - ) - ELSE MOD(`int64_col`, 1) - END AS `int_mod_1`, - CASE - WHEN 0 = CAST(0 AS INT64) - THEN CAST(0 AS INT64) * `int64_col` - WHEN 0 < CAST(0 AS INT64) AND ( - MOD(`int64_col`, 0) - ) > CAST(0 AS INT64) - THEN 0 + ( - MOD(`int64_col`, 0) - ) - WHEN 0 > CAST(0 AS INT64) AND ( - MOD(`int64_col`, 0) - ) < CAST(0 AS INT64) - THEN 0 + ( - MOD(`int64_col`, 0) - ) - ELSE MOD(`int64_col`, 0) - END AS `int_mod_0`, - CASE - WHEN CAST(`float64_col` AS BIGNUMERIC) = CAST(0 AS INT64) - THEN CAST('NaN' AS FLOAT64) * CAST(`float64_col` AS BIGNUMERIC) - WHEN CAST(`float64_col` AS BIGNUMERIC) < CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(`float64_col` AS BIGNUMERIC)) - ) > CAST(0 AS INT64) - THEN CAST(`float64_col` AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(`float64_col` AS BIGNUMERIC)) - ) - WHEN CAST(`float64_col` AS BIGNUMERIC) > CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(`float64_col` AS BIGNUMERIC)) - ) < CAST(0 AS INT64) - THEN CAST(`float64_col` AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(`float64_col` AS BIGNUMERIC)) - ) - ELSE MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(`float64_col` AS BIGNUMERIC)) - END AS `float_mod_float`, - CASE - WHEN CAST(-( - `float64_col` - ) AS BIGNUMERIC) = CAST(0 AS INT64) - THEN CAST('NaN' AS FLOAT64) * CAST(`float64_col` AS BIGNUMERIC) - WHEN CAST(-( - `float64_col` - ) AS BIGNUMERIC) < CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(-( - `float64_col` - ) AS BIGNUMERIC)) - ) > CAST(0 AS INT64) - THEN CAST(-( - `float64_col` - ) AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(-( - `float64_col` - ) AS BIGNUMERIC)) - ) - WHEN CAST(-( - `float64_col` - ) AS BIGNUMERIC) > CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(-( - `float64_col` - ) AS BIGNUMERIC)) - ) < CAST(0 AS INT64) - THEN CAST(-( - `float64_col` - ) AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(-( - `float64_col` - ) AS BIGNUMERIC)) - ) - ELSE MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(-( - `float64_col` - ) AS BIGNUMERIC)) - END AS `float_mod_float_neg`, - CASE - WHEN CAST(1 AS BIGNUMERIC) = CAST(0 AS INT64) - THEN CAST('NaN' AS FLOAT64) * CAST(`float64_col` AS BIGNUMERIC) - WHEN CAST(1 AS BIGNUMERIC) < CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) - ) > CAST(0 AS INT64) - THEN CAST(1 AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) - ) - WHEN CAST(1 AS BIGNUMERIC) > CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) - ) < CAST(0 AS INT64) - THEN CAST(1 AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) - ) - ELSE MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(1 AS BIGNUMERIC)) - END AS `float_mod_1`, - CASE - WHEN CAST(0 AS BIGNUMERIC) = CAST(0 AS INT64) - THEN CAST('NaN' AS FLOAT64) * CAST(`float64_col` AS BIGNUMERIC) - WHEN CAST(0 AS BIGNUMERIC) < CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) - ) > CAST(0 AS INT64) - THEN CAST(0 AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) - ) - WHEN CAST(0 AS BIGNUMERIC) > CAST(0 AS INT64) - AND ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) - ) < CAST(0 AS INT64) - THEN CAST(0 AS BIGNUMERIC) + ( - MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) - ) - ELSE MOD(CAST(`float64_col` AS BIGNUMERIC), CAST(0 AS BIGNUMERIC)) - END AS `float_mod_0`, - NULL AS `float_mod_null` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ebe8d571d65..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` * `int64_col` AS `int_mul_int`, - `int64_col` * 1 AS `int_mul_1`, - NULL AS `int_mul_null`, - `int64_col` * CAST(`bool_col` AS INT64) AS `int_mul_bool`, - CAST(`bool_col` AS INT64) * `int64_col` AS `bool_mul_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 8285d1e7d4a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_mul_timedelta/out.sql +++ /dev/null @@ -1,16 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `int64_col`, - `duration_col`, - CAST(IF( - `duration_col` * `int64_col` > 0, - FLOOR(`duration_col` * `int64_col`), - CEIL(`duration_col` * `int64_col`) - ) AS INT64) AS `timedelta_mul_numeric`, - CAST(IF( - `int64_col` * `duration_col` > 0, - FLOOR(`int64_col` * `duration_col`), - CEIL(`int64_col` * `duration_col`) - ) AS INT64) AS `numeric_mul_timedelta` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 13a9f3f6734..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_neg/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - -( - `float64_col` - ) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1890218bdd9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pos/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7202903ebe3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_pow/out.sql +++ /dev/null @@ -1,247 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `float64_col`, - 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 `int_pow_int`, - CASE - WHEN `float64_col` = CAST(0 AS INT64) - THEN 1 - WHEN `int64_col` = 1 - THEN 1 - WHEN `int64_col` = CAST(0 AS INT64) AND `float64_col` < CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) - WHEN ABS(`int64_col`) = CAST('Infinity' AS FLOAT64) - THEN POWER( - `int64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - WHEN ABS(`float64_col`) > 9007199254740992 - THEN POWER( - `int64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - WHEN `int64_col` < CAST(0 AS INT64) - AND NOT ( - CAST(`float64_col` AS INT64) = `float64_col` - ) - THEN CAST('NaN' AS FLOAT64) - WHEN `int64_col` <> CAST(0 AS INT64) AND `float64_col` * LN(ABS(`int64_col`)) > 709.78 - THEN CAST('Infinity' AS FLOAT64) * CASE - WHEN `int64_col` < CAST(0 AS INT64) AND MOD(CAST(`float64_col` AS INT64), 2) = 1 - THEN -1 - ELSE 1 - END - ELSE POWER( - `int64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - END AS `int_pow_float`, - CASE - WHEN `int64_col` = CAST(0 AS INT64) - THEN 1 - WHEN `float64_col` = 1 - THEN 1 - WHEN `float64_col` = CAST(0 AS INT64) AND `int64_col` < CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) - WHEN ABS(`float64_col`) = CAST('Infinity' AS FLOAT64) - THEN POWER( - `float64_col`, - CASE - WHEN ABS(`int64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`int64_col`) - ELSE `int64_col` - END - ) - WHEN ABS(`int64_col`) > 9007199254740992 - THEN POWER( - `float64_col`, - CASE - WHEN ABS(`int64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`int64_col`) - ELSE `int64_col` - END - ) - WHEN `float64_col` < CAST(0 AS INT64) - AND NOT ( - CAST(`int64_col` AS INT64) = `int64_col` - ) - THEN CAST('NaN' AS FLOAT64) - WHEN `float64_col` <> CAST(0 AS INT64) - AND `int64_col` * LN(ABS(`float64_col`)) > 709.78 - THEN CAST('Infinity' AS FLOAT64) * CASE - WHEN `float64_col` < CAST(0 AS INT64) AND MOD(CAST(`int64_col` AS INT64), 2) = 1 - THEN -1 - ELSE 1 - END - ELSE POWER( - `float64_col`, - CASE - WHEN ABS(`int64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`int64_col`) - ELSE `int64_col` - END - ) - END AS `float_pow_int`, - CASE - WHEN `float64_col` = CAST(0 AS INT64) - THEN 1 - WHEN `float64_col` = 1 - THEN 1 - WHEN `float64_col` = CAST(0 AS INT64) AND `float64_col` < CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) - WHEN ABS(`float64_col`) = CAST('Infinity' AS FLOAT64) - THEN POWER( - `float64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - WHEN ABS(`float64_col`) > 9007199254740992 - THEN POWER( - `float64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - WHEN `float64_col` < CAST(0 AS INT64) - AND NOT ( - CAST(`float64_col` AS INT64) = `float64_col` - ) - THEN CAST('NaN' AS FLOAT64) - WHEN `float64_col` <> CAST(0 AS INT64) - AND `float64_col` * LN(ABS(`float64_col`)) > 709.78 - THEN CAST('Infinity' AS FLOAT64) * CASE - WHEN `float64_col` < CAST(0 AS INT64) AND MOD(CAST(`float64_col` AS INT64), 2) = 1 - THEN -1 - ELSE 1 - END - ELSE POWER( - `float64_col`, - CASE - WHEN ABS(`float64_col`) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(`float64_col`) - ELSE `float64_col` - END - ) - END AS `float_pow_float`, - CASE - WHEN `int64_col` <> 0 AND 0 * LN(ABS(`int64_col`)) > 43.66827237527655 - THEN NULL - ELSE CAST(POWER(CAST(`int64_col` AS NUMERIC), 0) AS INT64) - END AS `int_pow_0`, - CASE - WHEN 0 = CAST(0 AS INT64) - THEN 1 - WHEN `float64_col` = 1 - THEN 1 - WHEN `float64_col` = CAST(0 AS INT64) AND 0 < CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) - WHEN ABS(`float64_col`) = CAST('Infinity' AS FLOAT64) - THEN POWER( - `float64_col`, - CASE - WHEN ABS(0) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(0) - ELSE 0 - END - ) - WHEN ABS(0) > 9007199254740992 - THEN POWER( - `float64_col`, - CASE - WHEN ABS(0) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(0) - ELSE 0 - END - ) - WHEN `float64_col` < CAST(0 AS INT64) AND NOT ( - CAST(0 AS INT64) = 0 - ) - THEN CAST('NaN' AS FLOAT64) - WHEN `float64_col` <> CAST(0 AS INT64) AND 0 * LN(ABS(`float64_col`)) > 709.78 - THEN CAST('Infinity' AS FLOAT64) * CASE - WHEN `float64_col` < CAST(0 AS INT64) AND MOD(CAST(0 AS INT64), 2) = 1 - THEN -1 - ELSE 1 - END - ELSE POWER( - `float64_col`, - CASE - WHEN ABS(0) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(0) - ELSE 0 - END - ) - END AS `float_pow_0`, - CASE - WHEN `int64_col` <> 0 AND 1 * LN(ABS(`int64_col`)) > 43.66827237527655 - THEN NULL - ELSE CAST(POWER(CAST(`int64_col` AS NUMERIC), 1) AS INT64) - END AS `int_pow_1`, - CASE - WHEN 1 = CAST(0 AS INT64) - THEN 1 - WHEN `float64_col` = 1 - THEN 1 - WHEN `float64_col` = CAST(0 AS INT64) AND 1 < CAST(0 AS INT64) - THEN CAST('Infinity' AS FLOAT64) - WHEN ABS(`float64_col`) = CAST('Infinity' AS FLOAT64) - THEN POWER( - `float64_col`, - CASE - WHEN ABS(1) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(1) - ELSE 1 - END - ) - WHEN ABS(1) > 9007199254740992 - THEN POWER( - `float64_col`, - CASE - WHEN ABS(1) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(1) - ELSE 1 - END - ) - WHEN `float64_col` < CAST(0 AS INT64) AND NOT ( - CAST(1 AS INT64) = 1 - ) - THEN CAST('NaN' AS FLOAT64) - WHEN `float64_col` <> CAST(0 AS INT64) AND 1 * LN(ABS(`float64_col`)) > 709.78 - THEN CAST('Infinity' AS FLOAT64) * CASE - WHEN `float64_col` < CAST(0 AS INT64) AND MOD(CAST(1 AS INT64), 2) = 1 - THEN -1 - ELSE 1 - END - ELSE POWER( - `float64_col`, - CASE - WHEN ABS(1) > 9007199254740992 - THEN CAST('Infinity' AS FLOAT64) * SIGN(1) - ELSE 1 - END - ) - END AS `float_pow_1`, - NULL AS `float_pow_null`, - NULL AS `null_pow_float` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9ac8e1065b5..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_round/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `float64_col`, - CAST(ROUND(`int64_col`, 0) AS INT64) AS `int_round_0`, - CAST(ROUND(`int64_col`, 1) AS INT64) AS `int_round_1`, - CAST(ROUND(`int64_col`, -1) AS INT64) AS `int_round_m1`, - ROUND(`float64_col`, 0) AS `float_round_0`, - ROUND(`float64_col`, 1) AS `float_round_1`, - ROUND(`float64_col`, -1) AS `float_round_m1` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ddc7cfab6ce..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sin/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - SIN(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a1d71a7a065..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sinh/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - CASE - WHEN ABS(`float64_col`) > 709.78 - THEN SIGN(`float64_col`) * CAST('Infinity' AS FLOAT64) - ELSE SINH(`float64_col`) - END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 6162a69d571..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sqrt/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CASE WHEN `float64_col` < 0 THEN CAST('NaN' AS FLOAT64) ELSE SQRT(`float64_col`) END AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index c1d0350a664..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_numeric/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `bool_col`, - `int64_col` - `int64_col` AS `int_sub_int`, - `int64_col` - 1 AS `int_sub_1`, - NULL AS `int_sub_null`, - `int64_col` - CAST(`bool_col` AS INT64) AS `int_sub_bool`, - CAST(`bool_col` AS INT64) - `int64_col` AS `bool_sub_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5c8b130d59d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_sub_timedelta/out.sql +++ /dev/null @@ -1,11 +0,0 @@ -SELECT - `rowindex`, - `timestamp_col`, - `duration_col`, - `date_col`, - TIMESTAMP_SUB(CAST(`date_col` AS DATETIME), INTERVAL `duration_col` MICROSECOND) AS `date_sub_timedelta`, - TIMESTAMP_SUB(`timestamp_col`, INTERVAL `duration_col` MICROSECOND) AS `timestamp_sub_timedelta`, - TIMESTAMP_DIFF(CAST(`date_col` AS DATETIME), CAST(`date_col` AS DATETIME), MICROSECOND) AS `timestamp_sub_date`, - TIMESTAMP_DIFF(`timestamp_col`, `timestamp_col`, MICROSECOND) AS `date_sub_timestamp`, - `duration_col` - `duration_col` AS `timedelta_sub_timedelta` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 138b5f84a3b..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tan/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TAN(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index c5db31c9579..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_tanh/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TANH(`float64_col`) AS `float64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 0795b64a209..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_numeric_ops/test_unsafe_pow_op/out.sql +++ /dev/null @@ -1,14 +0,0 @@ -SELECT - POWER(`int64_col`, `int64_col`) AS `int_pow_int`, - POWER(`int64_col`, `float64_col`) AS `int_pow_float`, - POWER(`float64_col`, `int64_col`) AS `float_pow_int`, - POWER(`float64_col`, `float64_col`) AS `float_pow_float`, - POWER(`int64_col`, CAST(`bool_col` AS INT64)) AS `int_pow_bool`, - POWER(CAST(`bool_col` AS INT64), `int64_col`) AS `bool_pow_int` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -WHERE - ( - `int64_col` >= 0 - ) AND ( - `int64_col` <= 10 - ) \ 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 deleted file mode 100644 index cf4051464b7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_add_string/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CONCAT(`string_col`, 'a') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index d11ce9b9934..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_capitalize/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - INITCAP(`string_col`, '') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 0295af27992..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_endswith/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - ENDS_WITH(`string_col`, 'ab') AS `single`, - ENDS_WITH(`string_col`, 'ab') OR ENDS_WITH(`string_col`, 'cd') AS `double`, - FALSE AS `empty` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7654299c79e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalnum/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, '^(\\p{N}|\\p{L})+$') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 33a08ff054d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isalpha/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, '^\\p{L}+$') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 7f266cbf604..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdecimal/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, '^(\\p{Nd})+$') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9134d035153..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isdigit/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - REGEXP_CONTAINS( - `string_col`, - '^[\\p{Nd}\\x{00B9}\\x{00B2}\\x{00B3}\\x{2070}\\x{2074}-\\x{2079}\\x{2080}-\\x{2089}]+$' - ) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index bce92035e5c..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_islower/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LOWER(`string_col`) = `string_col` AND UPPER(`string_col`) <> `string_col` AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 82baa081f54..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isnumeric/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, '^\\pN+$') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 2b44a592d93..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isspace/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, '^\\s+$') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 17ac14ac53f..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_isupper/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - UPPER(`string_col`) = `string_col` AND LOWER(`string_col`) <> `string_col` AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cff109d09dc..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LENGTH(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 1862deb6015..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_len_w_array/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - ARRAY_LENGTH(`int_list_col`) AS `int_list_col` -FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ 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 deleted file mode 100644 index 851de35ecb0..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lower/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LOWER(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4023605f8c9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_lstrip/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - LTRIM(`string_col`, ' ') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 4728c961ab3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_regex_replace_str/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_REPLACE(`string_col`, 'e', 'a') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 154af44b500..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_replace_str/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REPLACE(`string_col`, 'e', 'a') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 97bf57f79e1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_reverse/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REVERSE(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index c3d25b56e24..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_rstrip/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - RTRIM(`string_col`, ' ') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 760d3db7745..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_startswith/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - STARTS_WITH(`string_col`, 'ab') AS `single`, - STARTS_WITH(`string_col`, 'ab') OR STARTS_WITH(`string_col`, 'cd') AS `double`, - FALSE AS `empty` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 9071a252bc1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - `string_col` LIKE '%e%' AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 958f9af6f39..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_contains_regex/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REGEXP_CONTAINS(`string_col`, 'e') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index a87f5d9836d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_extract/out.sql +++ /dev/null @@ -1,12 +0,0 @@ -SELECT - IF( - REGEXP_CONTAINS(`string_col`, '([a-z]*)'), - REGEXP_REPLACE(`string_col`, CONCAT('.*?(', '([a-z]*)', ').*'), '\\1'), - NULL - ) AS `zero`, - IF( - REGEXP_CONTAINS(`string_col`, '([a-z]*)'), - REGEXP_REPLACE(`string_col`, CONCAT('.*?', '([a-z]*)', '.*'), '\\1'), - NULL - ) AS `one` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cf21fb8234a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_find/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT - INSTR(`string_col`, 'e', 1) - 1 AS `none_none`, - INSTR(`string_col`, 'e', 3) - 1 AS `start_none`, - INSTR(SUBSTRING(`string_col`, 1, 5), 'e') - 1 AS `none_end`, - INSTR(SUBSTRING(`string_col`, 3, 3), 'e') - 1 AS `start_end` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index b4c7c504fc9..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_get/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - IF(SUBSTRING(`string_col`, 2, 1) <> '', SUBSTRING(`string_col`, 2, 1), NULL) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 29766cca6c1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_pad/out.sql +++ /dev/null @@ -1,13 +0,0 @@ -SELECT - LPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `left`, - RPAD(`string_col`, GREATEST(LENGTH(`string_col`), 10), '-') AS `right`, - 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 `both` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index ed3d06ed35a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_repeat/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - REPEAT(`string_col`, 2) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index f011480ad30..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_str_slice/out.sql +++ /dev/null @@ -1,18 +0,0 @@ -SELECT - SUBSTRING(`string_col`, 2, 2) AS `1_3`, - SUBSTRING(`string_col`, 1, 3) AS `none_3`, - SUBSTRING(`string_col`, 2) AS `1_none`, - SUBSTRING(`string_col`, -3) AS `m3_none`, - SUBSTRING(`string_col`, 1, GREATEST(0, LENGTH(`string_col`) + -3)) AS `none_m3`, - SUBSTRING( - `string_col`, - GREATEST(1, LENGTH(`string_col`) + -4), - GREATEST(0, LENGTH(`string_col`) + -3) - GREATEST(0, LENGTH(`string_col`) + -5) - ) AS `m5_m3`, - SUBSTRING(`string_col`, 2, GREATEST(0, LENGTH(`string_col`) + -4)) AS `1_m3`, - SUBSTRING( - `string_col`, - GREATEST(1, LENGTH(`string_col`) + -2), - GREATEST(0, 5 - GREATEST(0, LENGTH(`string_col`) + -3)) - ) AS `m3_5` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index cf4051464b7..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strconcat/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - CONCAT(`string_col`, 'a') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 5145d6686a8..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_string_split/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - SPLIT(`string_col`, ',') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index e07185292bb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_strip/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - TRIM(`string_col`, ' ') AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 88bd78bd095..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_upper/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - UPPER(`string_col`) AS `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 818d2907add..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_string_ops/test_zfill/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -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 `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 6c3760aa36e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_field/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - `people`.`name` AS `string`, - `people`.`name` AS `int` -FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` AS `bft_0` \ 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 deleted file mode 100644 index 3549149609a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_struct_ops/test_struct_op/out.sql +++ /dev/null @@ -1,8 +0,0 @@ -SELECT - STRUCT( - `bool_col` AS bool_col, - `int64_col` AS int64_col, - `float64_col` AS float64_col, - `string_col` AS string_col - ) AS `result_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 6eb6f8e989d..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_timedelta_floor/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - FLOOR(`int64_col`) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ 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 deleted file mode 100644 index 59aa3d9b0b3..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_timedelta_ops/test_to_timedelta/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `float64_col`, - `int64_col` AS `duration_us`, - CAST(FLOOR(`float64_col` * 1000000) AS INT64) AS `duration_s`, - `int64_col` * 3600000000 AS `duration_w`, - `int64_col` AS `duration_on_duration` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_abs/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_abs/out.sql new file mode 100644 index 00000000000..6f315f8113b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_abs/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ABS(`bfcol_0`) 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_unary_compiler/test_arccos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccos/out.sql new file mode 100644 index 00000000000..df695b7fbc1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccos/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ACOS(`bfcol_0`) 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_unary_compiler/test_arccosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccosh/out.sql new file mode 100644 index 00000000000..5272e4a6a83 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arccosh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` < 1 THEN CAST('NaN' AS FLOAT64) ELSE ACOSH(`bfcol_0`) 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_unary_compiler/test_arcsin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsin/out.sql new file mode 100644 index 00000000000..3afc7c64b88 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsin/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ASIN(`bfcol_0`) 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_unary_compiler/test_arcsinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsinh/out.sql new file mode 100644 index 00000000000..6313e80e5f6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arcsinh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ASINH(`bfcol_0`) 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_unary_compiler/test_arctan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctan/out.sql new file mode 100644 index 00000000000..ec6a22e653d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctan/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ATAN(`bfcol_0`) 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_unary_compiler/test_arctanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctanh/out.sql new file mode 100644 index 00000000000..39b5f565feb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_arctanh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN ABS(`bfcol_0`) > 1 THEN CAST('NaN' AS FLOAT64) ELSE ATANH(`bfcol_0`) 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_unary_compiler/test_array_index/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_index/out.sql new file mode 100644 index 00000000000..43980842274 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_index/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0`[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_unary_compiler/test_array_slice_with_only_start/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_only_start/out.sql new file mode 100644 index 00000000000..1ffc3ee8f95 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_only_start/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY( + SELECT + el + FROM UNNEST(`bfcol_0`) 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_unary_compiler/test_array_slice_with_start_and_stop/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_start_and_stop/out.sql new file mode 100644 index 00000000000..878b60e5e23 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_slice_with_start_and_stop/out.sql @@ -0,0 +1,19 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY( + SELECT + el + FROM UNNEST(`bfcol_0`) 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_unary_compiler/test_array_to_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_to_string/out.sql new file mode 100644 index 00000000000..4dbd602beaf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_array_to_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_list_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` +), `bfcte_1` AS ( + SELECT + *, + ARRAY_TO_STRING(`bfcol_0`, '.') 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_unary_compiler/test_capitalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_capitalize/out.sql new file mode 100644 index 00000000000..7af17083472 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_capitalize/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INITCAP(`bfcol_0`) 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_unary_compiler/test_ceil/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ceil/out.sql new file mode 100644 index 00000000000..0959f3a0adf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ceil/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CEIL(`bfcol_0`) 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_unary_compiler/test_compile_numerical_add_w_scalar/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql new file mode 100644 index 00000000000..9c4b01a6df3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_numerical_add_w_scalar/out.sql @@ -0,0 +1,16 @@ +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_4`, + `bfcol_0` + 1 AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `rowindex`, + `bfcol_5` AS `int64_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql new file mode 100644 index 00000000000..7a8ab83df1b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_compile_string_add/out.sql @@ -0,0 +1,16 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` AS `bfcol_4`, + CONCAT(`bfcol_1`, 'a') AS `bfcol_5` + FROM `bfcte_0` +) +SELECT + `bfcol_4` AS `rowindex`, + `bfcol_5` AS `string_col` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cos/out.sql new file mode 100644 index 00000000000..126d2a63f2e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cos/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + COS(`bfcol_0`) 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_unary_compiler/test_cosh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cosh/out.sql new file mode 100644 index 00000000000..f44dfaac410 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_cosh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`bfcol_0`) > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE COSH(`bfcol_0`) + 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_unary_compiler/test_date/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_date/out.sql new file mode 100644 index 00000000000..615a4a92bb3 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_date/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + DATE(`bfcol_0`) 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_unary_compiler/test_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_day/out.sql new file mode 100644 index 00000000000..460823fa200 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_day/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAY FROM `bfcol_0`) 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_unary_compiler/test_dayofweek/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofweek/out.sql new file mode 100644 index 00000000000..e6c17587d03 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofweek/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAYOFWEEK FROM `bfcol_0`) - 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_unary_compiler/test_dayofyear/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofyear/out.sql new file mode 100644 index 00000000000..4b60bcc4cab --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_dayofyear/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAYOFYEAR FROM `bfcol_0`) 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_unary_compiler/test_endswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql new file mode 100644 index 00000000000..f224471e790 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/multiple_patterns.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ENDS_WITH(`bfcol_0`, 'ab') OR ENDS_WITH(`bfcol_0`, 'cd') 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_unary_compiler/test_endswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql new file mode 100644 index 00000000000..e9f61ddd7cd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/no_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FALSE 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_unary_compiler/test_endswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql new file mode 100644 index 00000000000..a4e259f0b2e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_endswith/single_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ENDS_WITH(`bfcol_0`, 'ab') 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_unary_compiler/test_exp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_exp/out.sql new file mode 100644 index 00000000000..6afa3f85a5e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_exp/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE EXP(`bfcol_0`) + 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_unary_compiler/test_expm1/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_expm1/out.sql new file mode 100644 index 00000000000..f3768deb4a8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_expm1/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN `bfcol_0` > 709.78 + THEN CAST('Infinity' AS FLOAT64) + ELSE EXP(`bfcol_0`) + 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_unary_compiler/test_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor/out.sql new file mode 100644 index 00000000000..56be1019e5d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FLOOR(`bfcol_0`) 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_unary_compiler/test_floor_dt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql new file mode 100644 index 00000000000..3c7efd30986 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_floor_dt/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_TRUNC(`bfcol_0`, 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_unary_compiler/test_geo_area/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_area/out.sql new file mode 100644 index 00000000000..9b4b6894e09 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_area/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_AREA(`bfcol_0`) 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_unary_compiler/test_geo_st_astext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_astext/out.sql new file mode 100644 index 00000000000..9557e2f1d64 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_astext/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_ASTEXT(`bfcol_0`) 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_unary_compiler/test_geo_st_boundary/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_boundary/out.sql new file mode 100644 index 00000000000..31c0b45034a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_boundary/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_BOUNDARY(`bfcol_0`) 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_unary_compiler/test_geo_st_buffer/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql new file mode 100644 index 00000000000..9669c39a9fd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_buffer/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_BUFFER(`bfcol_0`, 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_unary_compiler/test_geo_st_centroid/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql new file mode 100644 index 00000000000..97867318adf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_centroid/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CENTROID(`bfcol_0`) 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_unary_compiler/test_geo_st_convexhull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql new file mode 100644 index 00000000000..8bb58011737 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_convexhull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_CONVEXHULL(`bfcol_0`) 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_unary_compiler/test_geo_st_geogfromtext/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_geogfromtext/out.sql new file mode 100644 index 00000000000..ba4d9dd1827 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_geogfromtext/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_GEOGFROMTEXT(`bfcol_0`) 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_unary_compiler/test_geo_st_isclosed/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_isclosed/out.sql new file mode 100644 index 00000000000..d905e8470b6 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_isclosed/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_ISCLOSED(`bfcol_0`) 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_unary_compiler/test_geo_st_length/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_length/out.sql new file mode 100644 index 00000000000..a023691d638 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_st_length/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ST_LENGTH(`bfcol_0`) 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_unary_compiler/test_geo_x/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_x/out.sql new file mode 100644 index 00000000000..d4c0370ca8a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_x/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_X(`bfcol_0`) 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_unary_compiler/test_geo_y/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_y/out.sql new file mode 100644 index 00000000000..196c2fcad6e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_geo_y/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `geography_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SAFE.ST_Y(`bfcol_0`) 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_unary_compiler/test_hash/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hash/out.sql new file mode 100644 index 00000000000..14d6df6d221 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hash/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FARM_FINGERPRINT(`bfcol_0`) 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_unary_compiler/test_hour/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hour/out.sql new file mode 100644 index 00000000000..8cc9b9081fd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_hour/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(HOUR FROM `bfcol_0`) 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_unary_compiler/test_invert/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_invert/out.sql new file mode 100644 index 00000000000..28f2aa6e06a --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_invert/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + ~`bfcol_0` 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_unary_compiler/test_is_in/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql new file mode 100644 index 00000000000..36941df71bb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_is_in/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` IN (1, 2, 3) 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_unary_compiler/test_isalnum/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalnum/out.sql new file mode 100644 index 00000000000..02e00947422 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalnum/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^(\\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_unary_compiler/test_isalpha/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalpha/out.sql new file mode 100644 index 00000000000..2615d0452fe --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isalpha/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^\\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_unary_compiler/test_isdecimal/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdecimal/out.sql new file mode 100644 index 00000000000..bc1fce3dbc8 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdecimal/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^\\d+$') 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_unary_compiler/test_isdigit/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdigit/out.sql new file mode 100644 index 00000000000..1cb3a883abd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isdigit/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^\\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_unary_compiler/test_islower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_islower/out.sql new file mode 100644 index 00000000000..a621b71a3be --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_islower/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LOWER(`bfcol_0`) = `bfcol_0` AND UPPER(`bfcol_0`) <> `bfcol_0` 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_unary_compiler/test_isnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnull/out.sql new file mode 100644 index 00000000000..55a2ebb9702 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` 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_unary_compiler/test_isnumeric/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnumeric/out.sql new file mode 100644 index 00000000000..6566c1dd4c2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isnumeric/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^\\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_unary_compiler/test_iso_day/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_day/out.sql new file mode 100644 index 00000000000..d389172fdac --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_day/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(DAYOFWEEK FROM `bfcol_0`) 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_unary_compiler/test_iso_week/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_week/out.sql new file mode 100644 index 00000000000..f22e963bc3c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_week/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(ISOWEEK FROM `bfcol_0`) 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_unary_compiler/test_iso_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_year/out.sql new file mode 100644 index 00000000000..13b56f709cc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_iso_year/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(ISOYEAR FROM `bfcol_0`) 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_unary_compiler/test_isspace/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isspace/out.sql new file mode 100644 index 00000000000..aff12102be5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isspace/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '^\\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_unary_compiler/test_isupper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isupper/out.sql new file mode 100644 index 00000000000..03fe0059102 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_isupper/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UPPER(`bfcol_0`) = `bfcol_0` AND LOWER(`bfcol_0`) <> `bfcol_0` 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_unary_compiler/test_json_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract/out.sql new file mode 100644 index 00000000000..3d23bd1e3eb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT(`bfcol_0`, '$') 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_unary_compiler/test_json_extract_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_array/out.sql new file mode 100644 index 00000000000..1ddb3999b3b --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT_ARRAY(`bfcol_0`, '$') 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_unary_compiler/test_json_extract_string_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_string_array/out.sql new file mode 100644 index 00000000000..cbc3df74c06 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_extract_string_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_EXTRACT_STRING_ARRAY(`bfcol_0`, '$') 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_unary_compiler/test_json_query/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query/out.sql new file mode 100644 index 00000000000..b5d98b80d21 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_QUERY(`bfcol_0`, '$') 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_unary_compiler/test_json_query_array/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query_array/out.sql new file mode 100644 index 00000000000..1b7a5908ebe --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_query_array/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_QUERY_ARRAY(`bfcol_0`, '$') 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_unary_compiler/test_json_value/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_value/out.sql new file mode 100644 index 00000000000..3a84a1a92a5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_json_value/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + JSON_VALUE(`bfcol_0`, '$') 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_unary_compiler/test_len/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_len/out.sql new file mode 100644 index 00000000000..35fd087bc7f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_len/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LENGTH(`bfcol_0`) 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_unary_compiler/test_ln/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ln/out.sql new file mode 100644 index 00000000000..1372c088d95 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_ln/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE LN(`bfcol_0`) 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_unary_compiler/test_log10/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log10/out.sql new file mode 100644 index 00000000000..b4cced439b7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log10/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE LOG(10, `bfcol_0`) 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_unary_compiler/test_log1p/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log1p/out.sql new file mode 100644 index 00000000000..c3902ec1747 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_log1p/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` < -1 THEN CAST('NaN' AS FLOAT64) ELSE LN(1 + `bfcol_0`) 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_unary_compiler/test_lower/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lower/out.sql new file mode 100644 index 00000000000..e730cdee156 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lower/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LOWER(`bfcol_0`) 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_unary_compiler/test_lstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lstrip/out.sql new file mode 100644 index 00000000000..49ed89b40b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_lstrip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TRIM(`bfcol_0`, ' ') 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_unary_compiler/test_map/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql new file mode 100644 index 00000000000..a17d6584cea --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_map/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE `bfcol_0` WHEN 'value1' THEN 'mapped1' 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_unary_compiler/test_minute/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_minute/out.sql new file mode 100644 index 00000000000..4ef9b8142f2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_minute/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(MINUTE FROM `bfcol_0`) 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_unary_compiler/test_month/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_month/out.sql new file mode 100644 index 00000000000..49126228986 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_month/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(MONTH FROM `bfcol_0`) 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_unary_compiler/test_neg/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_neg/out.sql new file mode 100644 index 00000000000..46c58f766dd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_neg/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + -`bfcol_0` 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_unary_compiler/test_normalize/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_normalize/out.sql new file mode 100644 index 00000000000..3c7efd30986 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_normalize/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_TRUNC(`bfcol_0`, 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_unary_compiler/test_notnull/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_notnull/out.sql new file mode 100644 index 00000000000..c1961f9d624 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_notnull/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + NOT `bfcol_0` 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_unary_compiler/test_obj_fetch_metadata/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_fetch_metadata/out.sql new file mode 100644 index 00000000000..134fdc363b5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_fetch_metadata/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`bfcol_1`, '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 + `bfcol_0` AS `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_unary_compiler/test_obj_get_access_url/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_get_access_url/out.sql new file mode 100644 index 00000000000..4a963b49720 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_obj_get_access_url/out.sql @@ -0,0 +1,25 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `string_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + OBJ.MAKE_REF(`bfcol_1`, '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 + `bfcol_0` AS `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_unary_compiler/test_parse_json/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_parse_json/out.sql new file mode 100644 index 00000000000..cdb091ae395 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_parse_json/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + PARSE_JSON(`bfcol_0`) 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_unary_compiler/test_pos/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_pos/out.sql new file mode 100644 index 00000000000..2d6322a1820 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_pos/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` 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_unary_compiler/test_quarter/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_quarter/out.sql new file mode 100644 index 00000000000..2be28666614 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_quarter/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(QUARTER FROM `bfcol_0`) 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_unary_compiler/test_regex_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql new file mode 100644 index 00000000000..149df6706c1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_regex_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_REPLACE(`bfcol_0`, '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_unary_compiler/test_replace_str/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql new file mode 100644 index 00000000000..3bd7e0e47eb --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_replace_str/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPLACE(`bfcol_0`, '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_unary_compiler/test_reverse/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_reverse/out.sql new file mode 100644 index 00000000000..1ef1074149d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_reverse/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REVERSE(`bfcol_0`) 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_unary_compiler/test_rstrip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_rstrip/out.sql new file mode 100644 index 00000000000..49ed89b40b4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_rstrip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TRIM(`bfcol_0`, ' ') 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_unary_compiler/test_second/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_second/out.sql new file mode 100644 index 00000000000..144b7047882 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_second/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(SECOND FROM `bfcol_0`) 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_unary_compiler/test_sin/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sin/out.sql new file mode 100644 index 00000000000..62a5cff0b5e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sin/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SIN(`bfcol_0`) 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_unary_compiler/test_sinh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sinh/out.sql new file mode 100644 index 00000000000..711dba94a96 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sinh/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN ABS(`bfcol_0`) > 709.78 + THEN SIGN(`bfcol_0`) * CAST('Infinity' AS FLOAT64) + ELSE SINH(`bfcol_0`) + 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_unary_compiler/test_sqrt/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sqrt/out.sql new file mode 100644 index 00000000000..e6a93e5e6c9 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_sqrt/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE WHEN `bfcol_0` < 0 THEN CAST('NaN' AS FLOAT64) ELSE SQRT(`bfcol_0`) 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_unary_compiler/test_startswith/multiple_patterns.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql new file mode 100644 index 00000000000..061b57e208c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/multiple_patterns.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STARTS_WITH(`bfcol_0`, 'ab') OR STARTS_WITH(`bfcol_0`, 'cd') 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_unary_compiler/test_startswith/no_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql new file mode 100644 index 00000000000..e9f61ddd7cd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/no_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FALSE 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_unary_compiler/test_startswith/single_pattern.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql new file mode 100644 index 00000000000..726ce05b8c7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_startswith/single_pattern.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + STARTS_WITH(`bfcol_0`, 'ab') 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_unary_compiler/test_str_contains/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains/out.sql new file mode 100644 index 00000000000..a1aa0539eed --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0` 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_unary_compiler/test_str_contains_regex/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains_regex/out.sql new file mode 100644 index 00000000000..d0383172cb4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_contains_regex/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_CONTAINS(`bfcol_0`, '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_unary_compiler/test_str_extract/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql new file mode 100644 index 00000000000..a7fac093e2f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_extract/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REGEXP_EXTRACT(`bfcol_0`, '([a-z]*)') 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_unary_compiler/test_str_find/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql new file mode 100644 index 00000000000..dfc100e4132 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(`bfcol_0`, 'e', 1) - 1 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_unary_compiler/test_str_find/out_with_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql new file mode 100644 index 00000000000..78edf662b99 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_end.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(SUBSTRING(`bfcol_0`, 1, 5), 'e') - 1 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_unary_compiler/test_str_find/out_with_start.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql new file mode 100644 index 00000000000..d0dfc11a53e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(`bfcol_0`, 'e', 3) - 1 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_unary_compiler/test_str_find/out_with_start_and_end.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql new file mode 100644 index 00000000000..a91ab329461 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_find/out_with_start_and_end.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + INSTR(SUBSTRING(`bfcol_0`, 3, 3), 'e') - 1 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_unary_compiler/test_str_get/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_get/out.sql new file mode 100644 index 00000000000..1278c3435de --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_get/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SUBSTRING(`bfcol_0`, 2, 1) 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_unary_compiler/test_str_pad/both.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql new file mode 100644 index 00000000000..4701b0237a5 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/both.sql @@ -0,0 +1,21 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RPAD( + LPAD( + `bfcol_0`, + CAST(SAFE_DIVIDE(GREATEST(LENGTH(`bfcol_0`), 10) - LENGTH(`bfcol_0`), 2) AS INT64) + LENGTH(`bfcol_0`), + '-' + ), + GREATEST(LENGTH(`bfcol_0`), 10), + '-' + ) 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_unary_compiler/test_str_pad/left.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql new file mode 100644 index 00000000000..ee95900b3ee --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/left.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + LPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') 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_unary_compiler/test_str_pad/right.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql new file mode 100644 index 00000000000..17e59c553f0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_pad/right.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + RPAD(`bfcol_0`, GREATEST(LENGTH(`bfcol_0`), 10), '-') 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_unary_compiler/test_str_repeat/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql new file mode 100644 index 00000000000..1c94cfafe28 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_repeat/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + REPEAT(`bfcol_0`, 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_unary_compiler/test_str_slice/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_slice/out.sql new file mode 100644 index 00000000000..4f97ab3ac65 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_str_slice/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SUBSTRING(`bfcol_0`, 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_unary_compiler/test_strftime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strftime/out.sql new file mode 100644 index 00000000000..077c30e7cbf --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strftime/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FORMAT_TIMESTAMP('%Y-%m-%d', `bfcol_0`) 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_unary_compiler/test_string_split/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql new file mode 100644 index 00000000000..fea0d6eaf16 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_string_split/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + SPLIT(`bfcol_0`, ',') 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_unary_compiler/test_strip/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strip/out.sql new file mode 100644 index 00000000000..311f2c17271 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_strip/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TRIM(' ', `bfcol_0`) 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_unary_compiler/test_struct_field/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql new file mode 100644 index 00000000000..b3e8fde0b27 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_struct_field/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `people` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_0`.`name` AS `bfcol_1` + FROM `bfcte_0` +) +SELECT + `bfcol_1` AS `people` +FROM `bfcte_1` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tan/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tan/out.sql new file mode 100644 index 00000000000..5fac274b6bc --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tan/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TAN(`bfcol_0`) 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_unary_compiler/test_tanh/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tanh/out.sql new file mode 100644 index 00000000000..5d1a5a53207 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_tanh/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `float64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TANH(`bfcol_0`) 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_unary_compiler/test_time/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_time/out.sql new file mode 100644 index 00000000000..6b74efafd5d --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_time/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIME(`bfcol_0`) 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_unary_compiler/test_timedelta_floor/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_timedelta_floor/out.sql new file mode 100644 index 00000000000..1a8b9f4e390 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_timedelta_floor/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + FLOOR(`bfcol_0`) 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_unary_compiler/test_to_datetime/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_datetime/out.sql new file mode 100644 index 00000000000..096f14cc85c --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_datetime/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CAST(TIMESTAMP_SECONDS(`bfcol_0`) AS DATETIME) 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_unary_compiler/test_to_json_string/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_json_string/out.sql new file mode 100644 index 00000000000..27869739330 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_json_string/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `json_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +), `bfcte_1` AS ( + SELECT + *, + TO_JSON_STRING(`bfcol_0`) 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_unary_compiler/test_to_timedelta/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql new file mode 100644 index 00000000000..01ebebc455f --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timedelta/out.sql @@ -0,0 +1,37 @@ +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_4`, + `bfcol_0` AS `bfcol_5`, + INTERVAL `bfcol_0` MICROSECOND AS `bfcol_6` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + *, + `bfcol_4` AS `bfcol_10`, + `bfcol_5` AS `bfcol_11`, + `bfcol_6` AS `bfcol_12`, + INTERVAL (`bfcol_5` * 1000000) MICROSECOND AS `bfcol_13` + FROM `bfcte_1` +), `bfcte_3` AS ( + SELECT + *, + `bfcol_10` AS `bfcol_18`, + `bfcol_11` AS `bfcol_19`, + `bfcol_12` AS `bfcol_20`, + `bfcol_13` AS `bfcol_21`, + INTERVAL (`bfcol_11` * 604800000000) MICROSECOND AS `bfcol_22` + FROM `bfcte_2` +) +SELECT + `bfcol_18` AS `rowindex`, + `bfcol_19` AS `int64_col`, + `bfcol_20` AS `duration_us`, + `bfcol_21` AS `duration_s`, + `bfcol_22` AS `duration_w` +FROM `bfcte_3` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timestamp/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timestamp/out.sql new file mode 100644 index 00000000000..b1e66ce3e79 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_to_timestamp/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + TIMESTAMP_SECONDS(`bfcol_0`) 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_unary_compiler/test_unix_micros/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_micros/out.sql new file mode 100644 index 00000000000..dcbf0be5c28 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_micros/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MICROS(`bfcol_0`) 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_unary_compiler/test_unix_millis/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_millis/out.sql new file mode 100644 index 00000000000..ca58fbc97cd --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_millis/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_MILLIS(`bfcol_0`) 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_unary_compiler/test_unix_seconds/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_seconds/out.sql new file mode 100644 index 00000000000..21f0b7b8c85 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_unix_seconds/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UNIX_SECONDS(`bfcol_0`) 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_unary_compiler/test_upper/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_upper/out.sql new file mode 100644 index 00000000000..d22c8cff5a4 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_upper/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + UPPER(`bfcol_0`) 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_unary_compiler/test_year/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_year/out.sql new file mode 100644 index 00000000000..8352a65e9e2 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_year/out.sql @@ -0,0 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `timestamp_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + EXTRACT(YEAR FROM `bfcol_0`) 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_unary_compiler/test_zfill/out.sql b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql new file mode 100644 index 00000000000..e5d70ab44b1 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/snapshots/test_unary_compiler/test_zfill/out.sql @@ -0,0 +1,17 @@ +WITH `bfcte_0` AS ( + SELECT + `string_col` AS `bfcol_0` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + CASE + WHEN SUBSTRING(`bfcol_0`, 1, 1) = '-' + THEN CONCAT('-', LPAD(SUBSTRING(`bfcol_0`, 1), 9, '0')) + ELSE LPAD(`bfcol_0`, 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/test_ai_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py deleted file mode 100644 index 57c52490860..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_ai_ops.py +++ /dev/null @@ -1,477 +0,0 @@ -# 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 - -import pytest - -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), - endpoint="gemini-2.5-flash", - request_type="SHARED", - ) - - 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", - ) - - 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", - 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): - col_name = "string_col" - - op = ops.AIGenerate( - prompt_context=(None, " is the same as ", None), - 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_bool(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIGenerateBool( - prompt_context=(None, " is the same as ", None), - endpoint="gemini-2.5-flash", - ) - - 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", - ) - - 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 -): - col_name = "string_col" - - op = ops.AIGenerateBool( - prompt_context=(None, " is the same as ", None), - 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), - endpoint="gemini-2.5-flash", - ) - - 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", - ) - - 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 -): - 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), - 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), - endpoint="gemini-2.5-flash", - ) - - 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", - ) - - 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 -): - 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), - 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_embed(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIEmbed( - endpoint="text-embedding-005", - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ai_embed_with_connection_id(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIEmbed( - endpoint="text-embedding-005", - 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_embed_with_model(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIEmbed( - model="embeddinggemma-300m", - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ai_embed_with_task_type_and_title( - scalar_types_df: dataframe.DataFrame, snapshot -): - col_name = "string_col" - - op = ops.AIEmbed( - endpoint="text-embedding-005", - task_type="RETRIEVAL_DOCUMENT", - title="My Document", - model_params=json.dumps({"outputDimensionality": 256}), - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -@pytest.mark.parametrize("connection_id", [None, CONNECTION_ID]) -def test_ai_if(scalar_types_df: dataframe.DataFrame, snapshot, connection_id): - col_name = "string_col" - - op = ops.AIIf( - prompt_context=(None, " is the same as ", None), - connection_id=connection_id, - optimization_mode="MINIMIZE_COST", - max_error_ratio=0.5, - ) - - 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_with_endpoint(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIIf( - prompt_context=(None, " is the same as ", None), - endpoint="gemini-2.5-flash", - ) - - sql = utils._apply_ops_to_sql( - scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] - ) - - snapshot.assert_match(sql, "out.sql") - - -@pytest.mark.parametrize("connection_id", [None, CONNECTION_ID]) -def test_ai_classify(scalar_types_df: dataframe.DataFrame, snapshot, connection_id): - 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_classify_with_params(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIClassify( - prompt_context=(None,), - categories=("greeting", "rejection"), - examples=(("hi", "greeting"), ("bye", "rejection")), - endpoint="gemini-2.5-flash", - max_error_ratio=0.1, - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ai_classify_with_output_mode(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AIClassify( - prompt_context=(None,), - categories=("greeting", "rejection"), - output_mode="multi", - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -def test_ai_classify_multi_with_list_examples( - scalar_types_df: dataframe.DataFrame, snapshot -): - col_name = "string_col" - - examples = ( - ("hi", ("greeting", "positive")), - ("bye", ("rejection", "negative")), - ) - op = ops.AIClassify( - prompt_context=(None,), - categories=("greeting", "rejection"), - examples=examples, - output_mode="multi", - ) - - sql = utils._apply_ops_to_sql(scalar_types_df, [op.as_expr(col_name)], ["result"]) - - snapshot.assert_match(sql, "out.sql") - - -@pytest.mark.parametrize("connection_id", [None, CONNECTION_ID]) -def test_ai_score(scalar_types_df: dataframe.DataFrame, snapshot, connection_id): - 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") - - -def test_ai_score_with_endpoint_and_max_error_ratio( - scalar_types_df: dataframe.DataFrame, snapshot -): - col_name = "string_col" - - op = ops.AIScore( - prompt_context=(None, " is the same as ", None), - endpoint="gemini-2.5-flash", - max_error_ratio=0.5, - ) - - sql = utils._apply_ops_to_sql( - scalar_types_df, [op.as_expr(col_name, col_name)], ["result"] - ) - - snapshot.assert_match(sql, "out.sql") - - -@pytest.mark.parametrize("connection_id", [None, CONNECTION_ID]) -def test_ai_similarity(scalar_types_df: dataframe.DataFrame, snapshot, connection_id): - col_name = "string_col" - - op = ops.AISimilarity( - endpoint="text-embedding-005", - 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_similarity_with_model(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AISimilarity( - model="embeddinggemma-300m", - ) - - 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_similarity_with_model_param(scalar_types_df: dataframe.DataFrame, snapshot): - col_name = "string_col" - - op = ops.AISimilarity( - endpoint="text-embedding-005", - model_params=json.dumps({"outputDimensionality": 256}), - ) - - 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 deleted file mode 100644 index 1b358b3a3b1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_array_ops.py +++ /dev/null @@ -1,117 +0,0 @@ -# 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.operations.aggregations as agg_ops -import bigframes.pandas as bpd -from bigframes import operations as ops -from bigframes.core import expression -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(scalar_types_df: bpd.DataFrame, snapshot): - ops_map = { - "string_index": ops.GetItemOp(key=1).as_expr("string_col"), - "array_index": expression.OpExpression( - ops.GetItemOp(key=1), - (ops.ToArrayOp().as_expr("int64_col", "int64_too"),), - ), - } - - sql = utils._apply_ops_to_sql( - scalar_types_df, list(ops_map.values()), list(ops_map.keys()) - ) - 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"), - "array_agg_str": ops.ArrayReduceOp(agg_ops.ArrayAggOp()).as_expr( - "string_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(scalar_types_df: bpd.DataFrame, snapshot): - array_expr = ops.ToArrayOp().as_expr("int64_col", "int64_too") - ops_map = { - "string_slice": ops.ArraySliceOp(start=1, stop=5).as_expr("string_col"), - "slice_only_start": expression.OpExpression( - ops.ArraySliceOp(start=1, stop=None), - (array_expr,), - ), - "slice_start_stop": expression.OpExpression( - ops.ArraySliceOp(start=1, stop=5), - (array_expr,), - ), - } - - sql = utils._apply_ops_to_sql( - scalar_types_df, list(ops_map.values()), list(ops_map.keys()) - ) - 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") - - -def test_to_array_with_subquery_expression(repeated_types_df: bpd.DataFrame, snapshot): - reduced = ops.ArrayReduceOp(agg_ops.SumOp()).as_expr("float_list_col") - coalesced_reduced = ops.coalesce_op.as_expr(reduced, expression.const(0.0)) - array_expr = ops.ToArrayOp().as_expr(coalesced_reduced) - - sql = utils._apply_ops_to_sql( - repeated_types_df, [array_expr], ["arr_subquery_coalesce"] - ) - snapshot.assert_match(sql, "out.sql") diff --git a/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py new file mode 100644 index 00000000000..a2218d0afa7 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_binary_compiler.py @@ -0,0 +1,278 @@ +# 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 pandas as pd +import pytest + +from bigframes import operations as ops +import bigframes.core.expression as ex +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_binary_op( + obj: bpd.DataFrame, + op: ops.BinaryOp, + l_arg: str, + r_arg: typing.Union[str, ex.Expression], +) -> str: + array_value = obj._block.expr + op_expr = op.as_expr(l_arg, r_arg) + 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]: l_arg}).select_columns([l_arg]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return 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 = _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): + _apply_binary_op(scalar_types_df, ops.add_op, "timestamp_col", "date_col") + + with pytest.raises(TypeError): + _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_eq_null_match(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col", "bool_col"]] + sql = _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_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_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_json_set(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_binary_op( + bf_df, ops.JSONSet(json_path="$.a"), "json_col", ex.const(100) + ) + + snapshot.assert_match(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_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): + _apply_binary_op(scalar_types_df, ops.sub_op, "string_col", "string_col") + + with pytest.raises(TypeError): + _apply_binary_op(scalar_types_df, ops.sub_op, "int64_col", "string_col") + + +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_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") + + +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_bool_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py deleted file mode 100644 index bd51ea905a2..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_bool_ops.py +++ /dev/null @@ -1,50 +0,0 @@ -# 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 - -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"] - bf_df["bool_and_null"] = bf_df["bool_col"] & pd.NA # type: ignore - bf_df["null_and_bool"] = pd.NA & bf_df["bool_col"] # type: ignore - 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"] - bf_df["bool_and_null"] = bf_df["bool_col"] | pd.NA # type: ignore - bf_df["null_and_bool"] = pd.NA | bf_df["bool_col"] # type: ignore - 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"] - bf_df["bool_and_null"] = bf_df["bool_col"] ^ pd.NA # type: ignore - bf_df["null_and_bool"] = pd.NA ^ bf_df["bool_col"] # type: ignore - 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 deleted file mode 100644 index 73aceaedeeb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_comparison_ops.py +++ /dev/null @@ -1,167 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -from bigframes import operations as ops -from bigframes.testing import utils - -pytest.importorskip("pytest_snapshot") - - -def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): - bool_col = "bool_col" - int_col = "int64_col" - float_col = "float64_col" - bf_df = scalar_types_df[[bool_col, int_col, float_col]] - ops_map = { - "bools": ops.IsInOp(values=(True, False)).as_expr(bool_col), - "ints": ops.IsInOp(values=(1, 2, 3)).as_expr(int_col), - "ints_w_null": ops.IsInOp(values=(None, pd.NA)).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, - 1e-10, - float("inf"), - float("nan"), - 0, - ) - ).as_expr(int_col), - "empty": ops.IsInOp(values=()).as_expr(int_col), - "empty_wo_match_nulls": ops.IsInOp(values=(), match_nulls=False).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), - "mixed_with_null": ops.IsInOp( - values=("1.0", 2, None), match_nulls=True - ).as_expr(int_col), - "bool_in_mixed": ops.IsInOp(values=(1, 2.5)).as_expr(bool_col), - "only_null_match": ops.IsInOp(values=(None,), match_nulls=True).as_expr( - int_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_eq_int"] = bf_df["int64_col"] == bf_df["int64_col"] - bf_df["int_eq_1"] = bf_df["int64_col"] == 1 - bf_df["int_eq_null"] = bf_df["int64_col"] == pd.NA - bf_df["null_eq_int"] = pd.NA == bf_df["int64_col"] - - bf_df["int_eq_bool"] = bf_df["int64_col"] == bf_df["bool_col"] - bf_df["bool_eq_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["null_gt_int"] = pd.NA > bf_df["int64_col"] - - 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["null_ge_int"] = pd.NA >= bf_df["int64_col"] - - 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["null_lt_int"] = pd.NA < bf_df["int64_col"] - - 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["null_le_int"] = pd.NA <= bf_df["int64_col"] - - 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_null"] = bf_df["int64_col"] != pd.NA - bf_df["null_ne_int"] = pd.NA != bf_df["int64_col"] - - 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 deleted file mode 100644 index e86059b160a..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_datetime_ops.py +++ /dev/null @@ -1,404 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -from bigframes import operations as ops -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(), # type: ignore[arg-type] - origin="start", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "origin_epoch": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.Day(), # type: ignore[arg-type] - origin="epoch", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "origin_start_day": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.Day(), # type: ignore[arg-type] - origin="start_day", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "non_fixed_freq_weekly": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.Week(weekday=6), # type: ignore[arg-type] - origin="start", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "non_fixed_freq_monthly": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.MonthEnd(), # type: ignore[arg-type] - origin="start", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "non_fixed_freq_quarterly": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.QuarterEnd(startingMonth=12), # type: ignore[arg-type] - origin="start", - closed="left", # type: ignore - ).as_expr("datetime_col", "timestamp_col"), - "non_fixed_freq_yearly": ops.DatetimeToIntegerLabelOp( - freq=pd.tseries.offsets.YearEnd(), # type: ignore[arg-type] - 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", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = {col_name: ops.ToDatetimeOp().as_expr(col_name) for col_name in col_names} - ops_map["string_col_fmt"] = ops.ToDatetimeOp(format="%Y-%m-%d").as_expr( - "string_col" - ) - - sql = utils._apply_ops_to_sql(bf_df, list(ops_map.values()), list(ops_map.keys())) - snapshot.assert_match(sql + "\n", "out.sql") - - -def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "string_col", "float64_col", "datetime_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"), - "datetime_col": ops.ToTimestampOp().as_expr("datetime_col"), - "string_col_fmt": ops.ToTimestampOp(format="%Y-%m-%d").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_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") - - -def test_integer_label_to_datetime_fixed(scalar_types_df: bpd.DataFrame, snapshot): - col_names = ["rowindex", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = { - "fixed_freq": ops.IntegerLabelToDatetimeOp( - freq=pd.tseries.offsets.Day(), # type: ignore[arg-type] - origin="start", - label="left", # type: ignore - ).as_expr("rowindex", "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_integer_label_to_datetime_week(scalar_types_df: bpd.DataFrame, snapshot): - col_names = ["rowindex", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = { - "non_fixed_freq_weekly": ops.IntegerLabelToDatetimeOp( - freq=pd.tseries.offsets.Week(weekday=6), # type: ignore[arg-type] - origin="start", - label="left", # type: ignore - ).as_expr("rowindex", "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_integer_label_to_datetime_month(scalar_types_df: bpd.DataFrame, snapshot): - col_names = ["rowindex", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = { - "non_fixed_freq_monthly": ops.IntegerLabelToDatetimeOp( - freq=pd.tseries.offsets.MonthEnd(), # type: ignore - origin="start", - label="left", - ).as_expr("rowindex", "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_integer_label_to_datetime_quarter(scalar_types_df: bpd.DataFrame, snapshot): - col_names = ["rowindex", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = { - "non_fixed_freq": ops.IntegerLabelToDatetimeOp( - freq=pd.tseries.offsets.QuarterEnd(startingMonth=12), # type: ignore - origin="start", - label="left", - ).as_expr("rowindex", "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_integer_label_to_datetime_year(scalar_types_df: bpd.DataFrame, snapshot): - col_names = ["rowindex", "timestamp_col"] - bf_df = scalar_types_df[col_names] - ops_map = { - "non_fixed_freq_yearly": ops.IntegerLabelToDatetimeOp( - freq=pd.tseries.offsets.YearEnd(month=12), # type: ignore - origin="start", - label="left", - ).as_expr("rowindex", "timestamp_col"), - } - - 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_generic_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py deleted file mode 100644 index e3669e1b0ed..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_generic_ops.py +++ /dev/null @@ -1,357 +0,0 @@ -# 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 google.cloud import bigquery - -import bigframes.pandas as bpd -from bigframes import dtypes -from bigframes import operations as ops -from bigframes.core import expression as ex -from bigframes.functions import udf_def -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 + "\n", "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 + "\n", "out.sql") - - -def test_to_json(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df - - ops_map = { - "int64_col": ops.ToJSON().as_expr("int64_col"), - "float64_col": ops.ToJSON().as_expr("float64_col"), - "bool_col": ops.ToJSON().as_expr("bool_col"), - "string_col": ops.ToJSON().as_expr("string_col"), - "bool_w_safe": ops.ToJSON(safe=True).as_expr("bool_col"), - "string_w_safe": ops.ToJSON(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.JSONDecode(to_type=dtypes.INT_DTYPE).as_expr("json_col"), - "float64_col": ops.JSONDecode(to_type=dtypes.FLOAT_DTYPE).as_expr("json_col"), - "bool_col": ops.JSONDecode(to_type=dtypes.BOOL_DTYPE).as_expr("json_col"), - "string_col": ops.JSONDecode(to_type=dtypes.STRING_DTYPE).as_expr("json_col"), - "int64_w_safe": ops.JSONDecode(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_tojson_invalid(scalar_types_df: bpd.DataFrame, json_types_df: bpd.DataFrame): - # Test invalid cast to JSON - with pytest.raises(TypeError): - ops_map_to = { - "datetime_to_json": ops.ToJSON().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): - ops_map_from = { - "json_to_datetime": ops.JSONDecode(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_remote_function_op(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["int64_col", "float64_col", "string_col"]] - op = ops.RemoteFunctionOp( - function_def=udf_def.BigqueryUdf( - routine_ref=bigquery.RoutineReference.from_string( - "my_project.my_dataset.my_routine" - ), - signature=udf_def.UdfSignature( - inputs=( - udf_def.UdfArg( - "x", - udf_def.DirectScalarType(int), - ), - udf_def.UdfArg( - "y", - udf_def.DirectScalarType(float), - ), - udf_def.UdfArg( - "z", - udf_def.DirectScalarType(str), - ), - ), - output=udf_def.DirectScalarType(float), - ), - ) - ) - sql = utils._apply_nary_op(bf_df, op, "int64_col", "float64_col", "string_col") - snapshot.assert_match(sql, "out.sql") - - -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"), (pd.NA, "UNKNOWN"))).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 deleted file mode 100644 index 85e374c76db..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_geo_ops.py +++ /dev/null @@ -1,148 +0,0 @@ -# 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 -from bigframes import operations as ops -from bigframes.testing import utils - -pytest.importorskip("pytest_snapshot") - - -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_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 deleted file mode 100644 index 69eb8681abc..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_json_ops.py +++ /dev/null @@ -1,142 +0,0 @@ -# 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.expression as ex -import bigframes.pandas as bpd -from bigframes import operations as ops -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_json_value_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.JSONValueArray(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_literals.py b/tests/unit/core/compile/sqlglot/expressions/test_literals.py deleted file mode 100644 index aa0d7a1e5b1..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_literals.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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.expression as ex -import bigframes.pandas as bpd -from bigframes.testing import utils - -pytest.importorskip("pytest_snapshot") - - -def test_float_literals(scalar_types_df: bpd.DataFrame, snapshot): - bf_df = scalar_types_df[["float64_col"]] - ops_map = { - "inf": ex.const(float("inf")), - "ninf": ex.const(float("-inf")), - "nan": ex.const(float("nan")), - "neg_zero": ex.const(-0.0), - "0.00001": ex.const(0.00001), - "1E-10": ex.const(1e-10), - } - 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_numeric_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py deleted file mode 100644 index b0442f6992e..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_numeric_ops.py +++ /dev/null @@ -1,513 +0,0 @@ -# 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 - -import bigframes.core.expression as ex -import bigframes.pandas as bpd -from bigframes import operations as ops -from bigframes.operations import numeric_ops -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_isfinite(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, [numeric_ops.isfinite_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 - - bf_df["float_pow_null"] = bf_df["float64_col"] ** pd.NA - bf_df["null_pow_float"] = pd.NA ** bf_df["float64_col"] - - 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_null"] = bf_df["int64_col"] + pd.NA - - 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_null"] = bf_df["int64_col"] / pd.NA - - 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_null"] = bf_df["int64_col"] // pd.NA - - 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["float_div_null"] = bf_df["float64_col"] // pd.NA - - 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_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_null"] = bf_df["int64_col"] * pd.NA - - 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 - - bf_df["float_mod_null"] = bf_df["float64_col"] % pd.NA - - 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_sub_int"] = bf_df["int64_col"] - bf_df["int64_col"] - bf_df["int_sub_1"] = bf_df["int64_col"] - 1 - bf_df["int_sub_null"] = bf_df["int64_col"] - pd.NA - - bf_df["int_sub_bool"] = bf_df["int64_col"] - bf_df["bool_col"] - bf_df["bool_sub_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_op_registration.py b/tests/unit/core/compile/sqlglot/expressions/test_op_registration.py new file mode 100644 index 00000000000..1c49dde6ca0 --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_op_registration.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 +from sqlglot import expressions as sge + +from bigframes.core.compile.sqlglot.expressions import op_registration +from bigframes.operations import numeric_ops + + +def test_register_then_get(): + reg = op_registration.OpRegistration() + input = sge.to_identifier("A") + op = numeric_ops.add_op + + @reg.register(numeric_ops.AddOp) + def test_func(op: numeric_ops.AddOp, input: sge.Expression) -> sge.Expression: + return input + + assert reg[numeric_ops.add_op](op, input) == test_func(op, input) + assert reg[numeric_ops.add_op.name](op, input) == test_func(op, input) + + +def test_register_function_first_argument_is_not_scalar_op_raise_error(): + reg = op_registration.OpRegistration() + + @reg.register(numeric_ops.AddOp) + def test_func(input: sge.Expression) -> sge.Expression: + return input + + with pytest.raises(ValueError, match=r".*first parameter must be an operator.*"): + test_func(sge.to_identifier("A")) diff --git a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py b/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py deleted file mode 100644 index 67efcfb08ca..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_string_ops.py +++ /dev/null @@ -1,339 +0,0 @@ -# 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.expression as ex -import bigframes.pandas as bpd -from bigframes import operations as ops -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.GetItemOp(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]] - ops_map = { - "1_3": ops.StrSliceOp(1, 3).as_expr(col_name), - "none_3": ops.StrSliceOp(None, 3).as_expr(col_name), - "1_none": ops.StrSliceOp(1, None).as_expr(col_name), - "m3_none": ops.StrSliceOp(-3, None).as_expr(col_name), - "none_m3": ops.StrSliceOp(None, -3).as_expr(col_name), - "m5_m3": ops.StrSliceOp(-5, -3).as_expr(col_name), - "1_m3": ops.StrSliceOp(1, -3).as_expr(col_name), - "m3_5": ops.StrSliceOp(-3, 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_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]] - ops_map = { - "zero": ops.StrExtractOp(r"([a-z]*)", 0).as_expr(col_name), - "one": ops.StrExtractOp(r"([a-z]*)", 1).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_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 deleted file mode 100644 index 5e1f3d505cb..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_struct_ops.py +++ /dev/null @@ -1,68 +0,0 @@ -# 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 - -import bigframes.pandas as bpd -from bigframes import operations as ops -from bigframes.core import expression as ex -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 deleted file mode 100644 index ae1f6d017c4..00000000000 --- a/tests/unit/core/compile/sqlglot/expressions/test_timedelta_ops.py +++ /dev/null @@ -1,41 +0,0 @@ -# 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 -from bigframes import operations as ops -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/expressions/test_unary_compiler.py b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py new file mode 100644 index 00000000000..f011721ee5e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/expressions/test_unary_compiler.py @@ -0,0 +1,828 @@ +# 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.operations._op_converters import convert_index, convert_slice +import bigframes.pandas as bpd + +pytest.importorskip("pytest_snapshot") + + +def _apply_unary_op(obj: bpd.DataFrame, op: ops.UnaryOp, arg: str) -> str: + array_value = obj._block.expr + op_expr = op.as_expr(arg) + 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]: arg}).select_columns([arg]) + + sql = result.session._executor.to_sql(result, enable_cache=False) + return sql + + +def test_arccosh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arccosh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_arccos(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arccos_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsin(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arcsin_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_arcsinh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arcsinh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_arctan(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arctan_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_arctanh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.arctanh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_abs(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.abs_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_capitalize(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.capitalize_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_ceil(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.ceil_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_date(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.date_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_day(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.day_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_dayofweek(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.dayofweek_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_dayofyear(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.dayofyear_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_endswith(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab",)), "string_col") + snapshot.assert_match(sql, "single_pattern.sql") + + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=("ab", "cd")), "string_col") + snapshot.assert_match(sql, "multiple_patterns.sql") + + sql = _apply_unary_op(bf_df, ops.EndsWithOp(pat=()), "string_col") + snapshot.assert_match(sql, "no_pattern.sql") + + +def test_exp(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.exp_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_expm1(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.expm1_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_floor_dt(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.FloorDtOp("DAY"), "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_floor(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.floor_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_area(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_area_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_astext(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_astext_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_boundary(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_boundary_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_buffer(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.GeoStBufferOp(1.0, 8.0, False), "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_centroid(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_centroid_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_convexhull(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_convexhull_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_geogfromtext(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_geogfromtext_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_isclosed(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_st_isclosed_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_st_length(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.GeoStLengthOp(True), "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_x(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_x_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_geo_y(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["geography_col"]] + sql = _apply_unary_op(bf_df, ops.geo_y_op, "geography_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_array_to_string(repeated_types_df: bpd.DataFrame, snapshot): + bf_df = repeated_types_df[["string_list_col"]] + sql = _apply_unary_op(bf_df, ops.ArrayToStringOp(delimiter="."), "string_list_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_array_index(repeated_types_df: bpd.DataFrame, snapshot): + bf_df = repeated_types_df[["string_list_col"]] + sql = _apply_unary_op(bf_df, convert_index(1), "string_list_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_only_start(repeated_types_df: bpd.DataFrame, snapshot): + bf_df = repeated_types_df[["string_list_col"]] + sql = _apply_unary_op(bf_df, convert_slice(slice(1, None)), "string_list_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_array_slice_with_start_and_stop(repeated_types_df: bpd.DataFrame, snapshot): + bf_df = repeated_types_df[["string_list_col"]] + sql = _apply_unary_op(bf_df, convert_slice(slice(1, 5)), "string_list_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_cos(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.cos_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_cosh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.cosh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_hash(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.hash_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_hour(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.hour_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_invert(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, ops.invert_op, "int64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_is_in(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, ops.IsInOp(values=(1, 2, 3)), "int64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isalnum(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isalnum_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isalpha(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isalpha_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isdecimal(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isdecimal_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isdigit(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isdigit_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_islower(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.islower_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isnumeric(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isnumeric_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isspace(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isspace_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isupper(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.isupper_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_len(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.len_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_ln(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.ln_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_log10(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.log10_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_log1p(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.log1p_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_lower(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.lower_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_map(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op( + bf_df, ops.MapOp(mappings=(("value1", "mapped1"),)), "string_col" + ) + + snapshot.assert_match(sql, "out.sql") + + +def test_lstrip(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrLstripOp(" "), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_minute(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.minute_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_month(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.month_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_neg(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.neg_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_normalize(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.normalize_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +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_pos(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.pos_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_quarter(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.quarter_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.ReplaceStrOp("e", "a"), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_regex_replace_str(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.RegexReplaceStrOp(r"e", "a"), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_reverse(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.reverse_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_second(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.second_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_rstrip(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrRstripOp(" "), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_sqrt(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.sqrt_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_startswith(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab",)), "string_col") + snapshot.assert_match(sql, "single_pattern.sql") + + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=("ab", "cd")), "string_col") + snapshot.assert_match(sql, "multiple_patterns.sql") + + sql = _apply_unary_op(bf_df, ops.StartsWithOp(pat=()), "string_col") + snapshot.assert_match(sql, "no_pattern.sql") + + +def test_str_get(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrGetOp(1), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_str_pad(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="left"), "string_col" + ) + snapshot.assert_match(sql, "left.sql") + + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="right"), "string_col" + ) + snapshot.assert_match(sql, "right.sql") + + sql = _apply_unary_op( + bf_df, ops.StrPadOp(length=10, fillchar="-", side="both"), "string_col" + ) + snapshot.assert_match(sql, "both.sql") + + +def test_str_slice(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrSliceOp(1, 3), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_strftime(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.StrftimeOp("%Y-%m-%d"), "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_struct_field(nested_structs_types_df: bpd.DataFrame, snapshot): + bf_df = nested_structs_types_df[["people"]] + + # When a name string is provided. + sql = _apply_unary_op(bf_df, ops.StructFieldOp("name"), "people") + snapshot.assert_match(sql, "out.sql") + + # When an index integer is provided. + sql = _apply_unary_op(bf_df, ops.StructFieldOp(0), "people") + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrContainsOp("e"), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_str_contains_regex(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrContainsRegexOp("e"), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_str_extract(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrExtractOp(r"([a-z]*)", 1), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_str_repeat(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrRepeatOp(2), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_str_find(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=None), "string_col") + snapshot.assert_match(sql, "out.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=None), "string_col") + snapshot.assert_match(sql, "out_with_start.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=None, end=5), "string_col") + snapshot.assert_match(sql, "out_with_end.sql") + + sql = _apply_unary_op(bf_df, ops.StrFindOp("e", start=2, end=5), "string_col") + snapshot.assert_match(sql, "out_with_start_and_end.sql") + + +def test_strip(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StrStripOp(" "), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_day(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.iso_day_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_week(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.iso_week_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_iso_year(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.iso_year_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_isnull(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.isnull_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_notnull(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.notnull_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_sin(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.sin_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_sinh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.sinh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_string_split(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.StringSplitOp(pat=","), "string_col") + snapshot.assert_match(sql, "out.sql") + + +def test_tan(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.tan_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_tanh(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["float64_col"]] + sql = _apply_unary_op(bf_df, ops.tanh_op, "float64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_time(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.time_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_to_datetime(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, ops.ToDatetimeOp(), "int64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_to_timestamp(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, ops.ToTimestampOp(), "int64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_to_timedelta(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + bf_df["duration_us"] = bpd.to_timedelta(bf_df["int64_col"], "us") + bf_df["duration_s"] = bpd.to_timedelta(bf_df["int64_col"], "s") + bf_df["duration_w"] = bpd.to_timedelta(bf_df["int64_col"], "W") + + snapshot.assert_match(bf_df.sql, "out.sql") + + +def test_unix_micros(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.UnixMicros(), "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_millis(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.UnixMillis(), "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_unix_seconds(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.UnixSeconds(), "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_timedelta_floor(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["int64_col"]] + sql = _apply_unary_op(bf_df, ops.timedelta_floor_op, "int64_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONExtract(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_array(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONExtractArray(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_extract_string_array(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONExtractStringArray(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_query(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONQuery(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_query_array(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONQueryArray(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_json_value(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.JSONValue(json_path="$"), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_parse_json(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.ParseJSON(), "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_to_json_string(json_types_df: bpd.DataFrame, snapshot): + bf_df = json_types_df[["json_col"]] + sql = _apply_unary_op(bf_df, ops.ToJSONString(), "json_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_upper(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.upper_op, "string_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_year(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["timestamp_col"]] + sql = _apply_unary_op(bf_df, ops.year_op, "timestamp_col") + + snapshot.assert_match(sql, "out.sql") + + +def test_zfill(scalar_types_df: bpd.DataFrame, snapshot): + bf_df = scalar_types_df[["string_col"]] + sql = _apply_unary_op(bf_df, ops.ZfillOp(width=10), "string_col") + 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 index cfd9c7c87f0..02bba41a22c 100644 --- 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 @@ -1,15 +1,19 @@ WITH `bfcte_0` AS ( SELECT - `bool_col`, - `int64_too`, - `int64_too` AS `bfcol_2`, - `bool_col` AS `bfcol_3` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bool_col` AS `bfcol_0`, + `int64_too` 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_3`, COALESCE(SUM(`bfcol_2`), 0) AS `bfcol_6` - FROM `bfcte_0` + FROM `bfcte_1` WHERE NOT `bfcol_3` IS NULL GROUP BY @@ -18,6 +22,6 @@ WITH `bfcte_0` AS ( SELECT `bfcol_3` AS `bool_col`, `bfcol_6` AS `int64_too` -FROM `bfcte_1` +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 index e71099d82e6..b8e127eb776 100644 --- 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 @@ -1,21 +1,25 @@ WITH `bfcte_0` AS ( SELECT - `bool_col`, - `int64_too`, - `int64_too` AS `bfcol_2`, - `bool_col` AS `bfcol_3` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bool_col` AS `bfcol_0`, + `int64_too` 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_3`, COALESCE(SUM(`bfcol_2`), 0) AS `bfcol_6` - FROM `bfcte_0` + FROM `bfcte_1` GROUP BY `bfcol_3` ) SELECT `bfcol_3` AS `bool_col`, `bfcol_6` AS `int64_too` -FROM `bfcte_1` +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 index 48614357865..62e22a6a194 100644 --- 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 @@ -1,48 +1,78 @@ -WITH `bfcte_0` AS ( - SELECT - `rowindex` AS `bfcol_3`, - `rowindex` AS `bfcol_4`, - `int64_col` AS `bfcol_5`, - `string_col` AS `bfcol_6` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - `bfcol_17` AS `bfcol_23`, - `bfcol_18` AS `bfcol_24`, - `bfcol_19` AS `bfcol_25`, - `bfcol_20` AS `bfcol_26`, - `bfcol_21` AS `bfcol_27`, - `bfcol_22` AS `bfcol_28` +WITH `bfcte_1` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT(0, 123456789, 0, 'Hello, World!', 0), STRUCT(1, -987654321, 1, 'こんにちは', 1), STRUCT(2, 314159, 2, ' ¡Hola Mundo! ', 2), STRUCT(3, CAST(NULL AS INT64), 3, CAST(NULL AS STRING), 3), STRUCT(4, -234892, 4, 'Hello, World!', 4), STRUCT(5, 55555, 5, 'Güten Tag!', 5), STRUCT(6, 101202303, 6, 'capitalize, This ', 6), STRUCT(7, -214748367, 7, ' سلام', 7), STRUCT(8, 2, 8, 'T', 8)]) +), `bfcte_3` AS ( + SELECT + *, + `bfcol_4` AS `bfcol_10` + FROM `bfcte_1` +), `bfcte_5` AS ( + SELECT + *, + 0 AS `bfcol_16` + FROM `bfcte_3` +), `bfcte_6` AS ( + SELECT + `bfcol_0` AS `bfcol_17`, + `bfcol_2` AS `bfcol_18`, + `bfcol_1` AS `bfcol_19`, + `bfcol_3` AS `bfcol_20`, + `bfcol_16` AS `bfcol_21`, + `bfcol_10` AS `bfcol_22` + FROM `bfcte_5` +), `bfcte_0` AS ( + SELECT + * + FROM UNNEST(ARRAY>[STRUCT(0, 123456789, 0, 'Hello, World!', 0), STRUCT(1, -987654321, 1, 'こんにちは', 1), STRUCT(2, 314159, 2, ' ¡Hola Mundo! ', 2), STRUCT(3, CAST(NULL AS INT64), 3, CAST(NULL AS STRING), 3), STRUCT(4, -234892, 4, 'Hello, World!', 4), STRUCT(5, 55555, 5, 'Güten Tag!', 5), STRUCT(6, 101202303, 6, 'capitalize, This ', 6), STRUCT(7, -214748367, 7, ' سلام', 7), STRUCT(8, 2, 8, 'T', 8)]) +), `bfcte_2` AS ( + SELECT + *, + `bfcol_27` AS `bfcol_33` + FROM `bfcte_0` +), `bfcte_4` AS ( + SELECT + *, + 1 AS `bfcol_39` + FROM `bfcte_2` +), `bfcte_7` AS ( + SELECT + `bfcol_23` AS `bfcol_40`, + `bfcol_25` AS `bfcol_41`, + `bfcol_24` AS `bfcol_42`, + `bfcol_26` AS `bfcol_43`, + `bfcol_39` AS `bfcol_44`, + `bfcol_33` AS `bfcol_45` + FROM `bfcte_4` +), `bfcte_8` AS ( + SELECT + * FROM ( - ( - SELECT - `bfcol_3` AS `bfcol_17`, - `bfcol_4` AS `bfcol_18`, - `bfcol_5` AS `bfcol_19`, - `bfcol_6` AS `bfcol_20`, - 0 AS `bfcol_21`, - ROW_NUMBER() OVER () - 1 AS `bfcol_22` - FROM `bfcte_0` - ) + SELECT + `bfcol_17` AS `bfcol_46`, + `bfcol_18` AS `bfcol_47`, + `bfcol_19` AS `bfcol_48`, + `bfcol_20` AS `bfcol_49`, + `bfcol_21` AS `bfcol_50`, + `bfcol_22` AS `bfcol_51` + FROM `bfcte_6` UNION ALL - ( - SELECT - `bfcol_3` AS `bfcol_11`, - `bfcol_4` AS `bfcol_12`, - `bfcol_5` AS `bfcol_13`, - `bfcol_6` AS `bfcol_14`, - 1 AS `bfcol_15`, - ROW_NUMBER() OVER () - 1 AS `bfcol_16` - FROM `bfcte_0` - ) + SELECT + `bfcol_40` AS `bfcol_46`, + `bfcol_41` AS `bfcol_47`, + `bfcol_42` AS `bfcol_48`, + `bfcol_43` AS `bfcol_49`, + `bfcol_44` AS `bfcol_50`, + `bfcol_45` AS `bfcol_51` + FROM `bfcte_7` ) ) SELECT - `bfcol_23` AS `rowindex`, - `bfcol_24` AS `rowindex_1`, - `bfcol_25` AS `int64_col`, - `bfcol_26` AS `string_col` -FROM `bfcte_1` + `bfcol_46` AS `rowindex`, + `bfcol_47` AS `rowindex_1`, + `bfcol_48` AS `int64_col`, + `bfcol_49` AS `string_col` +FROM `bfcte_8` ORDER BY - `bfcol_27` ASC NULLS LAST, - `bfcol_28` ASC NULLS LAST \ No newline at end of file + `bfcol_50` ASC NULLS LAST, + `bfcol_51` 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 deleted file mode 100644 index 477a47036ae..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_concat/test_compile_concat_filter_sorted/out.sql +++ /dev/null @@ -1,63 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `float64_col` AS `bfcol_7`, - `int64_too` AS `bfcol_8` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` - WHERE - `bool_col` -), `bfcte_1` AS ( - SELECT - `float64_col` AS `bfcol_5`, - `int64_col` AS `bfcol_6` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `bfcol_21` AS `bfcol_33`, - `bfcol_22` AS `bfcol_34`, - `bfcol_23` AS `bfcol_35`, - `bfcol_24` AS `bfcol_36` - FROM ( - ( - SELECT - `bfcol_5` AS `bfcol_21`, - `bfcol_6` AS `bfcol_22`, - 0 AS `bfcol_23`, - ROW_NUMBER() OVER (ORDER BY `bfcol_6` ASC NULLS LAST) - 1 AS `bfcol_24` - FROM `bfcte_1` - ) - UNION ALL - ( - SELECT - `bfcol_7` AS `bfcol_29`, - `bfcol_8` AS `bfcol_30`, - 1 AS `bfcol_31`, - ROW_NUMBER() OVER () - 1 AS `bfcol_32` - FROM `bfcte_0` - ) - UNION ALL - ( - SELECT - `bfcol_5` AS `bfcol_17`, - `bfcol_6` AS `bfcol_18`, - 2 AS `bfcol_19`, - ROW_NUMBER() OVER (ORDER BY `bfcol_6` ASC NULLS LAST) - 1 AS `bfcol_20` - FROM `bfcte_1` - ) - UNION ALL - ( - SELECT - `bfcol_7` AS `bfcol_25`, - `bfcol_8` AS `bfcol_26`, - 3 AS `bfcol_27`, - ROW_NUMBER() OVER () - 1 AS `bfcol_28` - FROM `bfcte_0` - ) - ) -) -SELECT - `bfcol_33` AS `float64_col`, - `bfcol_34` AS `int64_col` -FROM `bfcte_2` -ORDER BY - `bfcol_35` ASC NULLS LAST, - `bfcol_36` 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 index e2a80e201bb..679da58f445 100644 --- 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 @@ -1,21 +1,21 @@ WITH `bfcte_0` AS ( SELECT - `rowindex`, - `int_list_col`, - `string_list_col` - FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` + `rowindex` AS `bfcol_0`, + `int_list_col` AS `bfcol_1`, + `string_list_col` AS `bfcol_2` + 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`) + REPLACE (`bfcol_1`[SAFE_OFFSET(`bfcol_13`)] AS `bfcol_1`, `bfcol_2`[SAFE_OFFSET(`bfcol_13`)] AS `bfcol_2`) FROM `bfcte_0` - LEFT 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` + CROSS JOIN UNNEST(GENERATE_ARRAY(0, LEAST(ARRAY_LENGTH(`bfcol_1`) - 1, ARRAY_LENGTH(`bfcol_2`) - 1))) AS `bfcol_13` WITH OFFSET AS `bfcol_7` ) SELECT - `rowindex`, - `rowindex` AS `rowindex_1`, - `int_list_col`, - `string_list_col` + `bfcol_0` AS `rowindex`, + `bfcol_0` AS `rowindex_1`, + `bfcol_1` AS `int_list_col`, + `bfcol_2` AS `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 index 03ac4d0e03a..8bfd1eb0050 100644 --- 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 @@ -1,18 +1,18 @@ WITH `bfcte_0` AS ( SELECT - `rowindex`, - `int_list_col` - FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` + `rowindex` AS `bfcol_0`, + `int_list_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`repeated_types` ), `bfcte_1` AS ( SELECT * - REPLACE (`bfcol_8` AS `int_list_col`) + REPLACE (`bfcol_8` AS `bfcol_1`) FROM `bfcte_0` - LEFT JOIN UNNEST(`int_list_col`) AS `bfcol_8` WITH OFFSET AS `bfcol_4` + CROSS JOIN UNNEST(`bfcol_1`) AS `bfcol_8` WITH OFFSET AS `bfcol_4` ) SELECT - `rowindex`, - `int_list_col` + `bfcol_0` AS `rowindex`, + `bfcol_1` AS `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 index 3e367c1e1e2..9ca7fb6a745 100644 --- 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 @@ -1,7 +1,25 @@ +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_5`, + `bfcol_1` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` >= 1 AS `bfcol_8` + FROM `bfcte_0` +), `bfcte_2` AS ( + SELECT + * + FROM `bfcte_1` + WHERE + `bfcol_8` +) SELECT - `rowindex`, - `rowindex` AS `rowindex_1`, - `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -WHERE - `rowindex` >= 1 \ No newline at end of file + `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_fromrange/test_compile_fromrange/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_fromrange/test_compile_fromrange/out.sql deleted file mode 100644 index 4f4e2496498..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_fromrange/test_compile_fromrange/out.sql +++ /dev/null @@ -1,75 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(CAST('2021-01-01T13:00:00' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:01' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:02' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:03' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:04' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:05' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:06' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:07' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:08' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:09' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:10' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:11' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:12' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:13' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:14' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:15' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:16' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:17' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:18' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:19' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:20' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:21' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:22' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:23' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:24' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:25' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:26' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:27' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:28' AS DATETIME)), STRUCT(CAST('2021-01-01T13:00:29' AS DATETIME))]) -), `bfcte_1` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(CAST('2021-01-01T13:00:00' AS DATETIME), 0, 10), STRUCT(CAST('2021-01-01T13:00:01' AS DATETIME), 1, 11), STRUCT(CAST('2021-01-01T13:00:02' AS DATETIME), 2, 12), STRUCT(CAST('2021-01-01T13:00:03' AS DATETIME), 3, 13), STRUCT(CAST('2021-01-01T13:00:04' AS DATETIME), 4, 14), STRUCT(CAST('2021-01-01T13:00:05' AS DATETIME), 5, 15), STRUCT(CAST('2021-01-01T13:00:06' AS DATETIME), 6, 16), STRUCT(CAST('2021-01-01T13:00:07' AS DATETIME), 7, 17), STRUCT(CAST('2021-01-01T13:00:08' AS DATETIME), 8, 18), STRUCT(CAST('2021-01-01T13:00:09' AS DATETIME), 9, 19), STRUCT(CAST('2021-01-01T13:00:10' AS DATETIME), 10, 20), STRUCT(CAST('2021-01-01T13:00:11' AS DATETIME), 11, 21), STRUCT(CAST('2021-01-01T13:00:12' AS DATETIME), 12, 22), STRUCT(CAST('2021-01-01T13:00:13' AS DATETIME), 13, 23), STRUCT(CAST('2021-01-01T13:00:14' AS DATETIME), 14, 24), STRUCT(CAST('2021-01-01T13:00:15' AS DATETIME), 15, 25), STRUCT(CAST('2021-01-01T13:00:16' AS DATETIME), 16, 26), STRUCT(CAST('2021-01-01T13:00:17' AS DATETIME), 17, 27), STRUCT(CAST('2021-01-01T13:00:18' AS DATETIME), 18, 28), STRUCT(CAST('2021-01-01T13:00:19' AS DATETIME), 19, 29), STRUCT(CAST('2021-01-01T13:00:20' AS DATETIME), 20, 30), STRUCT(CAST('2021-01-01T13:00:21' AS DATETIME), 21, 31), STRUCT(CAST('2021-01-01T13:00:22' AS DATETIME), 22, 32), STRUCT(CAST('2021-01-01T13:00:23' AS DATETIME), 23, 33), STRUCT(CAST('2021-01-01T13:00:24' AS DATETIME), 24, 34), STRUCT(CAST('2021-01-01T13:00:25' AS DATETIME), 25, 35), STRUCT(CAST('2021-01-01T13:00:26' AS DATETIME), 26, 36), STRUCT(CAST('2021-01-01T13:00:27' AS DATETIME), 27, 37), STRUCT(CAST('2021-01-01T13:00:28' AS DATETIME), 28, 38), STRUCT(CAST('2021-01-01T13:00:29' AS DATETIME), 29, 39)]) -), `bfcte_2` AS ( - SELECT - `bfcol_0` AS `bfcol_4` - FROM `bfcte_0` -), `bfcte_3` AS ( - SELECT - `bfcol_1` AS `bfcol_5`, - `bfcol_2` AS `bfcol_6`, - `bfcol_3` AS `bfcol_7` - FROM `bfcte_1` -), `bfcte_4` AS ( - SELECT - MIN(`bfcol_4`) AS `bfcol_8` - FROM `bfcte_2` -), `bfcte_5` AS ( - SELECT - `bfcol_6` AS `bfcol_11`, - `bfcol_7` AS `bfcol_12`, - CAST(FLOOR( - IEEE_DIVIDE( - UNIX_MICROS(CAST(`bfcol_5` AS TIMESTAMP)) - UNIX_MICROS(CAST(CAST(`bfcol_8` AS DATE) AS TIMESTAMP)), - 7000000 - ) - ) AS INT64) AS `bfcol_13` - FROM `bfcte_3` - CROSS JOIN `bfcte_4` -), `bfcte_6` AS ( - SELECT - CAST(FLOOR( - IEEE_DIVIDE( - UNIX_MICROS(CAST(`bfcol_4` AS TIMESTAMP)) - UNIX_MICROS(CAST(CAST(`bfcol_8` AS DATE) AS TIMESTAMP)), - 7000000 - ) - ) AS INT64) AS `bfcol_14` - FROM `bfcte_2` - CROSS JOIN `bfcte_4` -), `bfcte_7` AS ( - SELECT - MAX(`bfcol_14`) AS `bfcol_15` - FROM `bfcte_6` -), `bfcte_8` AS ( - SELECT - MIN(`bfcol_14`) AS `bfcol_16` - FROM `bfcte_6` -), `bfcte_9` AS ( - SELECT - `bfcol_27` AS `bfcol_17` - FROM `bfcte_8` - CROSS JOIN `bfcte_7` - CROSS JOIN UNNEST(GENERATE_ARRAY(`bfcol_16`, `bfcol_15`, 1)) AS `bfcol_27` -) -SELECT - CAST(TIMESTAMP_MICROS( - CAST(CAST(`bfcol_17` AS BIGNUMERIC) * 7000000 + CAST(UNIX_MICROS(CAST(CAST(`bfcol_8` AS DATE) AS TIMESTAMP)) AS BIGNUMERIC) AS INT64) - ) AS DATETIME) AS `timestamp_col`, - `bfcol_11` AS `int64_col`, - `bfcol_12` AS `int64_too` -FROM ( - SELECT - * - FROM `bfcte_9` - CROSS JOIN `bfcte_4` -) -LEFT JOIN `bfcte_5` - ON `bfcol_17` = `bfcol_13` -ORDER BY - `bfcol_17` ASC NULLS LAST 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 deleted file mode 100644 index 457436e98c4..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats/out.sql +++ /dev/null @@ -1,51 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) -) -SELECT - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`min`, - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`max`, - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`sum`, - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`count`, - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`mean`, - ST_REGIONSTATS( - `bfcol_0`, - 'ee://some/raster/uri', - band => 'band1', - include => 'some equation', - options => JSON '{"scale": 100}' - ).`area` -FROM `bfcte_0` -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 deleted file mode 100644 index 410909d80c5..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_regionstats_without_optional_args/out.sql +++ /dev/null @@ -1,15 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('POINT(1 1)', 0)]) -) -SELECT - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`min`, - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`max`, - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`sum`, - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`count`, - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`mean`, - ST_REGIONSTATS(`bfcol_0`, 'ee://some/raster/uri').`area` -FROM `bfcte_0` -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 deleted file mode 100644 index 177cb5292b3..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_geo/test_st_simplify/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(ST_GEOGFROMTEXT('LINESTRING(0 0, 1 1, 2 0)'), 0)]) -) -SELECT - ST_SIMPLIFY(`bfcol_0`, 123.125) AS `0` -FROM `bfcte_0` -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 deleted file mode 100644 index a062ec30e44..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin/out.sql +++ /dev/null @@ -1,36 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `int64_too` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - `rowindex` AS `bfcol_3`, - `int64_col` AS `bfcol_4` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `int64_too` - FROM `bfcte_0` - GROUP BY - `int64_too` -), `bfcte_3` AS ( - SELECT - `int64_too` AS `bfcol_0` - FROM `bfcte_2` -), `bfcte_4` AS ( - SELECT - *, - EXISTS( - SELECT - 1 - FROM `bfcte_3` - WHERE - COALESCE(`bfcol_4`, 0) = COALESCE(`bfcol_0`, 0) - AND COALESCE(`bfcol_4`, 1) = COALESCE(`bfcol_0`, 1) - ) AS `bfcol_5` - FROM `bfcte_1` -) -SELECT - `bfcol_3` 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 deleted file mode 100644 index 81c83dee6c9..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_isin/test_compile_isin_not_nullable/out.sql +++ /dev/null @@ -1,33 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `rowindex_2` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( - SELECT - `rowindex` AS `bfcol_3`, - `rowindex_2` AS `bfcol_4` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `rowindex_2` - FROM `bfcte_0` - GROUP BY - `rowindex_2` -), `bfcte_3` AS ( - SELECT - `rowindex_2` AS `bfcol_0` - FROM `bfcte_2` -), `bfcte_4` AS ( - SELECT - *, - COALESCE(`bfcol_4` IN (( - SELECT - * - FROM `bfcte_3` - )), FALSE) AS `bfcol_5` - FROM `bfcte_1` -) -SELECT - `bfcol_3` 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 index cac57d0c8c8..04ee767f8ae 100644 --- 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 @@ -1,18 +1,32 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `bfcol_1` AS `bfcol_2`, + `bfcol_0` AS `bfcol_3` + FROM `bfcte_1` +), `bfcte_0` AS ( SELECT `int64_col` AS `bfcol_4`, `int64_too` AS `bfcol_5` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcol_4` AS `bfcol_6`, + `bfcol_5` AS `bfcol_7` + FROM `bfcte_0` +), `bfcte_4` AS ( SELECT - `rowindex` AS `bfcol_6`, - `int64_col` AS `bfcol_7` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + * + 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_7` AS `int64_col`, - `bfcol_5` AS `int64_too` -FROM `bfcte_1` -LEFT JOIN `bfcte_0` - ON COALESCE(`bfcol_6`, 0) = COALESCE(`bfcol_4`, 0) - AND COALESCE(`bfcol_6`, 1) = COALESCE(`bfcol_4`, 1) \ No newline at end of file + `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 index 5042f91cd95..05d5fd0695b 100644 --- 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 @@ -1,24 +1,33 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `bool_col` AS `bfcol_0`, `rowindex` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `bfcol_1` AS `bfcol_2`, `bfcol_0` AS `bfcol_3` - FROM `bfcte_0` -), `bfcte_2` AS ( + FROM `bfcte_1` +), `bfcte_0` AS ( SELECT - `bfcol_1` AS `bfcol_4`, - `bfcol_0` AS `bfcol_5` + `bool_col` AS `bfcol_4`, + `rowindex` AS `bfcol_5` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcol_5` AS `bfcol_6`, + `bfcol_4` 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_4` AS `rowindex_x`, - `bfcol_5` AS `bool_col`, - `bfcol_2` AS `rowindex_y` -FROM `bfcte_2` -INNER JOIN `bfcte_1` - ON COALESCE(CAST(`bfcol_5` AS STRING), '0') = COALESCE(CAST(`bfcol_3` AS STRING), '0') - AND COALESCE(CAST(`bfcol_5` AS STRING), '1') = COALESCE(CAST(`bfcol_3` AS STRING), '1') \ No newline at end of file + `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 index 544fedadc5b..9e6a4094b20 100644 --- 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 @@ -1,24 +1,33 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `float64_col` AS `bfcol_0`, `rowindex` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `bfcol_1` AS `bfcol_2`, `bfcol_0` AS `bfcol_3` - FROM `bfcte_0` -), `bfcte_2` AS ( + FROM `bfcte_1` +), `bfcte_0` AS ( SELECT - `bfcol_1` AS `bfcol_4`, - `bfcol_0` AS `bfcol_5` + `float64_col` AS `bfcol_4`, + `rowindex` AS `bfcol_5` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcol_5` AS `bfcol_6`, + `bfcol_4` 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_4` AS `rowindex_x`, - `bfcol_5` AS `float64_col`, - `bfcol_2` AS `rowindex_y` -FROM `bfcte_2` -INNER JOIN `bfcte_1` - ON IF(IS_NAN(`bfcol_5`), 2.0, COALESCE(`bfcol_5`, 0.0)) = IF(IS_NAN(`bfcol_3`), 2.0, COALESCE(`bfcol_3`, 0.0)) - AND IF(IS_NAN(`bfcol_5`), 3, COALESCE(`bfcol_5`, 1.0)) = IF(IS_NAN(`bfcol_3`), 3, COALESCE(`bfcol_3`, 1.0)) \ No newline at end of file + `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 index 05b9ceec4de..bd03e05cba7 100644 --- 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 @@ -1,24 +1,33 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `int64_col` AS `bfcol_0`, `rowindex` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `bfcol_1` AS `bfcol_2`, `bfcol_0` AS `bfcol_3` - FROM `bfcte_0` -), `bfcte_2` AS ( + FROM `bfcte_1` +), `bfcte_0` AS ( SELECT - `bfcol_1` AS `bfcol_4`, - `bfcol_0` AS `bfcol_5` + `int64_col` AS `bfcol_4`, + `rowindex` AS `bfcol_5` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcol_5` AS `bfcol_6`, + `bfcol_4` 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_4` AS `rowindex_x`, - `bfcol_5` AS `int64_col`, - `bfcol_2` AS `rowindex_y` -FROM `bfcte_2` -INNER JOIN `bfcte_1` - ON COALESCE(`bfcol_5`, 0) = COALESCE(`bfcol_3`, 0) - AND COALESCE(`bfcol_5`, 1) = COALESCE(`bfcol_3`, 1) \ No newline at end of file + `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 index 2e0114593e7..6b77ead97cd 100644 --- 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 @@ -1,24 +1,33 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `numeric_col` AS `bfcol_0`, `rowindex` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( SELECT `bfcol_1` AS `bfcol_2`, `bfcol_0` AS `bfcol_3` - FROM `bfcte_0` -), `bfcte_2` AS ( + FROM `bfcte_1` +), `bfcte_0` AS ( SELECT - `bfcol_1` AS `bfcol_4`, - `bfcol_0` AS `bfcol_5` + `numeric_col` AS `bfcol_4`, + `rowindex` AS `bfcol_5` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_3` AS ( + SELECT + `bfcol_5` AS `bfcol_6`, + `bfcol_4` 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_4` AS `rowindex_x`, - `bfcol_5` AS `numeric_col`, - `bfcol_2` AS `rowindex_y` -FROM `bfcte_2` -INNER JOIN `bfcte_1` - ON COALESCE(`bfcol_5`, CAST(0 AS NUMERIC)) = COALESCE(`bfcol_3`, CAST(0 AS NUMERIC)) - AND COALESCE(`bfcol_5`, CAST(1 AS NUMERIC)) = COALESCE(`bfcol_3`, CAST(1 AS NUMERIC)) \ No newline at end of file + `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 index 36aad503435..1903d5fc221 100644 --- 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 @@ -1,19 +1,28 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `rowindex` AS `bfcol_0`, `string_col` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_0` AS ( SELECT - `bfcol_0` AS `bfcol_2`, - `bfcol_1` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `string_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `bfcol_2` AS `bfcol_4`, + `bfcol_3` AS `bfcol_5` FROM `bfcte_0` +), `bfcte_3` AS ( + SELECT + * + FROM `bfcte_1` + INNER JOIN `bfcte_2` + 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_2` AS `rowindex_y` -FROM `bfcte_0` -INNER JOIN `bfcte_1` - ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_3` AS STRING), '0') - AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_3` AS STRING), '1') \ No newline at end of file + `bfcol_4` AS `rowindex_y` +FROM `bfcte_3` \ 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 index b945a1cbf38..9e3477d4a9e 100644 --- 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 @@ -1,19 +1,28 @@ -WITH `bfcte_0` AS ( +WITH `bfcte_1` AS ( SELECT `rowindex` AS `bfcol_0`, `time_col` AS `bfcol_1` - FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -), `bfcte_1` AS ( + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_0` AS ( SELECT - `bfcol_0` AS `bfcol_2`, - `bfcol_1` AS `bfcol_3` + `rowindex` AS `bfcol_2`, + `time_col` AS `bfcol_3` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_2` AS ( + SELECT + `bfcol_2` AS `bfcol_4`, + `bfcol_3` AS `bfcol_5` FROM `bfcte_0` +), `bfcte_3` AS ( + SELECT + * + FROM `bfcte_1` + INNER JOIN `bfcte_2` + 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_2` AS `rowindex_y` -FROM `bfcte_0` -INNER JOIN `bfcte_1` - ON COALESCE(CAST(`bfcol_1` AS STRING), '0') = COALESCE(CAST(`bfcol_3` AS STRING), '0') - AND COALESCE(CAST(`bfcol_1` AS STRING), '1') = COALESCE(CAST(`bfcol_3` AS STRING), '1') \ No newline at end of file + `bfcol_4` AS `rowindex_y` +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 index 73879aa65df..aae34716d86 100644 --- 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 @@ -1,6 +1,7 @@ WITH `bfcte_0` AS ( SELECT - * + *, + RAND() AS `bfcol_16` FROM UNNEST(ARRAY>[STRUCT( TRUE, CAST(b'Hello, World!' AS BYTES), @@ -155,6 +156,12 @@ WITH `bfcte_0` AS ( 432000000000, 8 )]) +), `bfcte_1` AS ( + SELECT + * + FROM `bfcte_0` + WHERE + `bfcol_16` < 0.1 ) SELECT `bfcol_0` AS `bool_col`, @@ -172,12 +179,6 @@ SELECT `bfcol_12` AS `time_col`, `bfcol_13` AS `timestamp_col`, `bfcol_14` AS `duration_col` -FROM ( - SELECT - * - FROM `bfcte_0` - WHERE - RAND() < 0.1 -) +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_w_nested_structs_df/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql new file mode 100644 index 00000000000..42b7bc7361e --- /dev/null +++ b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_nested_structs_df/out.sql @@ -0,0 +1,19 @@ +SELECT + * +FROM UNNEST(ARRAY>, `bfcol_2` INT64>>[( + 1, + STRUCT( + 'Alice' AS `name`, + 30 AS `age`, + STRUCT('New York' AS `city`, 'USA' AS `country`) AS `address` + ), + 0 +), ( + 2, + STRUCT( + 'Bob' AS `name`, + 25 AS `age`, + STRUCT('London' AS `city`, 'UK' AS `country`) AS `address` + ), + 1 +)]) \ 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 deleted file mode 100644 index ba5e0c8f1cf..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readlocal/test_compile_readlocal_w_special_values/out.sql +++ /dev/null @@ -1,25 +0,0 @@ -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 index 58a01635b7d..7ded9cf5fff 100644 --- 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 @@ -1,145 +1,27 @@ WITH `bfcte_0` AS ( SELECT * - FROM UNNEST(ARRAY>, `bfcol_2` FLOAT64, `bfcol_3` FLOAT64, `bfcol_4` FLOAT64, `bfcol_5` STRING, `bfcol_6` JSON, `bfcol_7` STRING, `bfcol_8` STRING, `bfcol_9` STRING, `bfcol_10` TIMESTAMP, `bfcol_11` STRING, `bfcol_12` FLOAT64, `bfcol_13` FLOAT64, `bfcol_14` STRING, `bfcol_15` FLOAT64, `bfcol_16` INT64>>[STRUCT( + 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` ), - 1.0, - 123456789.0, - 1.25, - 'Hello World', - PARSE_JSON('{"a":1,"b":[1,2]}'), - '2026-06-24', - '12:34:56.789012', - '2026-06-24 12:34:56.789012', - CAST('2026-06-24T12:34:56.789012+00:00' AS TIMESTAMP), - 'SGVsbG8=', - 123456.789, - 123456.78901234567, - 'POINT(30 10)', - 1000.0, 0 ), STRUCT( 2, - STRUCT('' AS `name`, -1 AS `age`, STRUCT('' AS `city`, '' AS `country`) AS `address`), - 0.0, - -9.223372036854776e+18, - CAST('-Infinity' AS FLOAT64), - '', - PARSE_JSON('{}'), - '0001-01-01', - '00:00:00', - '0001-01-02 00:00:00', - CAST('0001-01-02T00:00:00+00:00' AS TIMESTAMP), - '', - -1e+29, - -1e+38, - 'POINT(0 0)', - -9223372036854776.0, - 1 - ), STRUCT( - 3, - STRUCT( - 'Very Long Name...' AS `name`, - 150 AS `age`, - STRUCT('City' AS `city`, 'Country' AS `country`) AS `address` - ), - 1.0, - 9.223372036854776e+18, - CAST('Infinity' AS FLOAT64), - 'Unicode: 🚀 Spark ✨', - PARSE_JSON('{"max":true,"nested":{"val":999}}'), - '9999-12-31', - '23:59:59.999999', - '9999-12-31 23:59:59.999999', - CAST('9999-12-31T23:59:59.999999+00:00' AS TIMESTAMP), - 'dmVyeSBsb25nIGJ5dGVzIHZhbHVl', - 1e+29, - 1e+38, - 'POLYGON((0 0, 10 0, 10 10, 0 10, 0 0))', - 9223372036854776.0, - 2 - ), STRUCT( - 4, - CAST(NULL AS STRUCT>), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS STRING), - CAST(NULL AS JSON), - CAST(NULL AS STRING), - CAST(NULL AS STRING), - CAST(NULL AS STRING), - CAST(NULL AS TIMESTAMP), - CAST(NULL AS STRING), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS STRING), - CAST(NULL AS FLOAT64), - 3 - ), STRUCT( - 5, STRUCT( 'Bob' AS `name`, - 0 AS `age`, - CAST(NULL AS STRUCT) AS `address` + 25 AS `age`, + STRUCT('London' AS `city`, 'UK' AS `country`) AS `address` ), - 0.0, - 0.0, - CAST(NULL AS FLOAT64), - 'Line 1\nLine 2\n"Quotes"', - PARSE_JSON('[1,"two",null]'), - '1970-01-01', - '12:00:00', - '1970-01-01 12:00:00', - CAST('1970-01-01T12:00:00+00:00' AS TIMESTAMP), - 'AA==', - 0.0, - 0.0, - 'LINESTRING(0 0, 1 1, 2 2)', - 0.0, - 4 - ), STRUCT( - 6, - CAST(NULL AS STRUCT>), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS STRING), - CAST(NULL AS JSON), - CAST(NULL AS STRING), - CAST(NULL AS STRING), - CAST(NULL AS STRING), - CAST(NULL AS TIMESTAMP), - CAST(NULL AS STRING), - CAST(NULL AS FLOAT64), - CAST(NULL AS FLOAT64), - CAST(NULL AS STRING), - CAST(NULL AS FLOAT64), - 5 + 1 )]) ) SELECT `bfcol_0` AS `id`, - `bfcol_1` AS `person`, - `bfcol_2` AS `bool_col`, - `bfcol_3` AS `int64_col`, - `bfcol_4` AS `float64_col`, - `bfcol_5` AS `string_col`, - `bfcol_6` AS `json_col`, - `bfcol_7` AS `date_col`, - `bfcol_8` AS `time_col`, - `bfcol_9` AS `datetime_col`, - `bfcol_10` AS `timestamp_col`, - `bfcol_11` AS `bytes_col`, - `bfcol_12` AS `numeric_col`, - `bfcol_13` AS `bignumeric_col`, - `bfcol_14` AS `geography_col`, - `bfcol_15` AS `duration_col` + `bfcol_1` AS `person` FROM `bfcte_0` ORDER BY - `bfcol_16` ASC NULLS LAST \ No newline at end of file + `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_astype_aliases/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_astype_aliases/out.sql deleted file mode 100644 index cd056c650fd..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_astype_aliases/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT - `rowindex`, - CAST(`timestamp_col` AS STRING) AS `timestamp_col`, - CAST(`int64_col` AS FLOAT64) AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` 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 index 626ef80d518..10c2a2088a8 100644 --- 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 @@ -1,18 +1,37 @@ +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `bytes_col` AS `bfcol_1`, + `date_col` AS `bfcol_2`, + `datetime_col` AS `bfcol_3`, + `geography_col` AS `bfcol_4`, + `int64_col` AS `bfcol_5`, + `int64_too` AS `bfcol_6`, + `numeric_col` AS `bfcol_7`, + `float64_col` AS `bfcol_8`, + `rowindex` AS `bfcol_9`, + `rowindex_2` AS `bfcol_10`, + `string_col` AS `bfcol_11`, + `time_col` AS `bfcol_12`, + `timestamp_col` AS `bfcol_13`, + `duration_col` AS `bfcol_14` + 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 `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` \ No newline at end of file + `bfcol_9` AS `rowindex`, + `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_1`, + `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_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 deleted file mode 100644 index 4d1b822245c..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_columns_filters/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -SELECT - `rowindex`, - `int64_col`, - `string_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -WHERE - `rowindex` > 0 AND `string_col` IN ('Hello, World!') \ 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 index 054e850fd36..4e8f61d75dc 100644 --- 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 @@ -1,3 +1,10 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `json_col` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`json_types` +) SELECT - * -FROM `bigframes-dev`.`sqlglot_test`.`json_types` AS `bft_0` \ No newline at end of file + `bfcol_0` AS `rowindex`, + `bfcol_1` AS `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 index ff4f0656b12..f97eb7bf06f 100644 --- 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 @@ -1,7 +1,13 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +) SELECT - `rowindex`, - `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bfcol_1` AS `rowindex`, + `bfcol_0` AS `int64_col` +FROM `bfcte_0` ORDER BY - `rowindex` ASC NULLS LAST + `bfcol_1` 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 index f75fa6f722c..75c4a86e188 100644 --- 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 @@ -1,5 +1,11 @@ +WITH `bfcte_0` AS ( + SELECT + `id` AS `bfcol_0`, + `people` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` +) SELECT - `id`, - `id` AS `id_1`, - `people` -FROM `bigframes-dev`.`sqlglot_test`.`nested_structs_types` AS `bft_0` \ No newline at end of file + `bfcol_0` AS `id`, + `bfcol_0` AS `id_1`, + `bfcol_1` AS `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 index 7e6ddfd568f..6a16b98baa9 100644 --- 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 @@ -1,6 +1,12 @@ +WITH `bfcte_0` AS ( + SELECT + `int64_col` AS `bfcol_0`, + `rowindex` AS `bfcol_1` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +) SELECT - `rowindex`, - `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bfcol_1` AS `rowindex`, + `bfcol_0` AS `int64_col` +FROM `bfcte_0` ORDER BY - `int64_col` ASC NULLS LAST \ No newline at end of file + `bfcol_0` 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 index 34b02b5209b..2436c01a445 100644 --- 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 @@ -1,11 +1,23 @@ +WITH `bfcte_0` AS ( + SELECT + `rowindex` AS `bfcol_0`, + `int_list_col` AS `bfcol_1`, + `bool_list_col` AS `bfcol_2`, + `float_list_col` AS `bfcol_3`, + `date_list_col` AS `bfcol_4`, + `date_time_list_col` AS `bfcol_5`, + `numeric_list_col` AS `bfcol_6`, + `string_list_col` AS `bfcol_7` + 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 `bigframes-dev`.`sqlglot_test`.`repeated_types` AS `bft_0` \ No newline at end of file + `bfcol_0` AS `rowindex`, + `bfcol_0` AS `rowindex_1`, + `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` \ 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 deleted file mode 100644 index dcd40d78485..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_compile_readtable/test_compile_readtable_w_system_time/out.sql +++ /dev/null @@ -1,3 +0,0 @@ -SELECT - * -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` FOR SYSTEM_TIME AS OF '2025-11-09T03:04:05.678901+00:00' \ 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 index 1051a0fb4c1..beb3caa073c 100644 --- 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 @@ -1,55 +1,76 @@ -SELECT - `bool_col`, - `rowindex`, - CASE - WHEN COALESCE( - SUM(CAST(( - `bool_col` - ) IS NOT NULL AS INT64)) OVER ( - PARTITION BY `bool_col` - ORDER BY `bool_col` ASC NULLS LAST, `rowindex` ASC NULLS LAST - ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ), - 0 - ) < 3 - THEN NULL - WHEN TRUE - THEN COALESCE( - SUM(CAST(`bool_col` AS INT64)) OVER ( - PARTITION BY `bool_col` - ORDER BY `bool_col` ASC NULLS LAST, `rowindex` ASC NULLS LAST +WITH `bfcte_0` AS ( + SELECT + `bool_col` AS `bfcol_0`, + `int64_col` AS `bfcol_1`, + `rowindex` AS `bfcol_2` + FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` +), `bfcte_1` AS ( + SELECT + *, + `bfcol_2` AS `bfcol_6`, + `bfcol_0` AS `bfcol_7`, + `bfcol_1` AS `bfcol_8`, + `bfcol_0` 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` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ), - 0 - ) - END AS `bool_col_1`, - CASE - WHEN COALESCE( - SUM(CAST(( - `int64_col` - ) IS NOT NULL AS INT64)) OVER ( - PARTITION BY `bool_col` - ORDER BY `bool_col` ASC NULLS LAST, `rowindex` ASC NULLS LAST + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(CAST(`bfcol_7` AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_15` + FROM `bfcte_2` +), `bfcte_4` AS ( + SELECT + * + FROM `bfcte_3` + WHERE + NOT `bfcol_9` IS NULL +), `bfcte_5` AS ( + SELECT + *, + CASE + WHEN SUM(CAST(NOT `bfcol_8` IS NULL AS INT64)) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ), - 0 - ) < 3 - THEN NULL - WHEN TRUE - THEN COALESCE( - SUM(`int64_col`) OVER ( - PARTITION BY `bool_col` - ORDER BY `bool_col` ASC NULLS LAST, `rowindex` ASC NULLS LAST - ROWS BETWEEN 3 PRECEDING AND CURRENT ROW - ), - 0 - ) - END AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` -WHERE - ( - `bool_col` - ) IS NOT NULL + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(`bfcol_8`) OVER ( + PARTITION BY `bfcol_9` + ORDER BY `bfcol_9` IS NULL ASC NULLS LAST, `bfcol_9` ASC NULLS LAST, `bfcol_2` IS NULL ASC NULLS LAST, `bfcol_2` ASC NULLS LAST + ROWS BETWEEN 3 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_21` + FROM `bfcte_4` +) +SELECT + `bfcol_9` AS `bool_col`, + `bfcol_6` AS `rowindex`, + `bfcol_15` AS `bool_col_1`, + `bfcol_21` AS `int64_col` +FROM `bfcte_5` ORDER BY - `bool_col` ASC NULLS LAST, - `rowindex` ASC NULLS LAST \ No newline at end of file + `bfcol_9` 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_range_rolling/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_compile_window/test_compile_window_w_range_rolling/out.sql index 887e7e9212d..581c81c6b40 100644 --- 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 @@ -2,30 +2,29 @@ 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`, - CASE - WHEN COALESCE( - SUM(CAST(( - `bfcol_1` - ) IS NOT NULL AS INT64)) OVER ( - ORDER BY UNIX_MICROS(`bfcol_0`) ASC - RANGE BETWEEN 2999999 PRECEDING AND CURRENT ROW - ), - 0 - ) < 1 - THEN NULL - WHEN TRUE - THEN COALESCE( - SUM(`bfcol_1`) OVER ( - ORDER BY UNIX_MICROS(`bfcol_0`) ASC - RANGE BETWEEN 2999999 PRECEDING AND CURRENT ROW - ), - 0 - ) - END AS `int_col` -FROM `bfcte_0` + `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 index 21bb8d5f088..6d779a40ac8 100644 --- 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 @@ -1,19 +1,30 @@ +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 + *, + CASE + WHEN SUM(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER ( + ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST + ROWS BETWEEN 2 PRECEDING AND CURRENT ROW + ) < 3 + THEN NULL + ELSE COALESCE( + SUM(`bfcol_0`) OVER ( + ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST + ROWS BETWEEN 2 PRECEDING AND CURRENT ROW + ), + 0 + ) + END AS `bfcol_4` + FROM `bfcte_0` +) SELECT - `rowindex`, - CASE - WHEN COALESCE( - SUM(CAST(( - `int64_col` - ) IS NOT NULL AS INT64)) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), - 0 - ) < 3 - THEN NULL - WHEN TRUE - THEN COALESCE( - SUM(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), - 0 - ) - END AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bfcol_1` AS `rowindex`, + `bfcol_4` AS `int64_col` +FROM `bfcte_1` ORDER BY - `rowindex` ASC NULLS LAST \ No newline at end of file + `bfcol_1` 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 index 6ae1fffab7a..1d5d9a9e459 100644 --- 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 @@ -1,13 +1,27 @@ +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 + *, + CASE + WHEN COUNT(CAST(NOT `bfcol_0` IS NULL AS INT64)) OVER ( + ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST + ROWS BETWEEN 4 PRECEDING AND CURRENT ROW + ) < 5 + THEN NULL + ELSE COUNT(`bfcol_0`) OVER ( + ORDER BY `bfcol_1` IS NULL ASC NULLS LAST, `bfcol_1` ASC NULLS LAST + ROWS BETWEEN 4 PRECEDING AND CURRENT ROW + ) + END AS `bfcol_4` + FROM `bfcte_0` +) SELECT - `rowindex`, - CASE - WHEN COUNT(( - `int64_col` - ) IS NOT NULL) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) < 5 - THEN NULL - WHEN TRUE - THEN COUNT(`int64_col`) OVER (ORDER BY `rowindex` ASC NULLS LAST ROWS BETWEEN 4 PRECEDING AND CURRENT ROW) - END AS `int64_col` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` + `bfcol_1` AS `rowindex`, + `bfcol_4` AS `int64_col` +FROM `bfcte_1` ORDER BY - `rowindex` ASC NULLS LAST \ No newline at end of file + `bfcol_1` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_bigframes_sql_scalar/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_bigframes_sql_scalar/out.sql deleted file mode 100644 index 80b3137b0b5..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_bigframes_sql_scalar/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - `rowindex`, - ROUND(`int64_col` + `int64_too`) AS `0` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` diff --git a/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_sql_scalar/out.sql b/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_sql_scalar/out.sql deleted file mode 100644 index 80b3137b0b5..00000000000 --- a/tests/unit/core/compile/sqlglot/snapshots/test_dataframe_accessor/test_sql_scalar/out.sql +++ /dev/null @@ -1,4 +0,0 @@ -SELECT - `rowindex`, - ROUND(`int64_col` + `int64_too`) AS `0` -FROM `bigframes-dev`.`sqlglot_test`.`scalar_types` AS `bft_0` diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table/out.sql deleted file mode 100644 index 867282de0e7..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table/out.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE EXTERNAL TABLE `my-project.my_dataset.my_table` ( - `col1` INT64, - `col2` STRING -) OPTIONS ( - format='CSV', - uris=['gs://bucket/path*'] -) \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_all_options/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_all_options/out.sql deleted file mode 100644 index a08ddf5ee5d..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_all_options/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -CREATE OR REPLACE EXTERNAL TABLE `my-project.my_dataset.my_table` ( - `col1` INT64, - `col2` STRING -) WITH CONNECTION `my-connection` WITH PARTITION COLUMNS ( - `part1` DATE, - `part2` STRING -) OPTIONS ( - format='CSV', - uris=['gs://bucket/path*'] -) \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_if_not_exists/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_if_not_exists/out.sql deleted file mode 100644 index e05a553317b..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_create_external_table_if_not_exists/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE EXTERNAL TABLE IF NOT EXISTS `my-project.my_dataset.my_table` ( - `col1` INT64 -) OPTIONS ( - format='CSV', - uris=['gs://bucket/path*'] -) \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_all_options/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_all_options/out.sql deleted file mode 100644 index 781019a0680..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_all_options/out.sql +++ /dev/null @@ -1,10 +0,0 @@ -LOAD DATA OVERWRITE INTO `my-project.my_dataset.my_table` ( - `col1` INT64, - `col2` STRING -) PARTITION BY `date_col` CLUSTER BY - `cluster_col` OPTIONS ( - description='my table' -) FROM FILES (format='CSV', uris=['gs://bucket/path*']) WITH PARTITION COLUMNS ( - `part1` DATE, - `part2` STRING -) WITH CONNECTION `my-connection` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_minimal/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_minimal/out.sql deleted file mode 100644 index c5f66003257..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_ddl/test_load_data_minimal/out.sql +++ /dev/null @@ -1 +0,0 @@ -LOAD DATA INTO `my-project.my_dataset.my_table` FROM FILES (format='CSV', uris=['gs://bucket/path*']) \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_select/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_select/out.sql deleted file mode 100644 index e2e9225c9f7..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_select/out.sql +++ /dev/null @@ -1,6 +0,0 @@ -INSERT INTO `bigframes-dev`.`sqlglot_test`.`dest_table` -( - SELECT - * - FROM `source_table` -) \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_table/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_table/out.sql deleted file mode 100644 index 2486d8d0a3b..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_insert_from_table/out.sql +++ /dev/null @@ -1,2 +0,0 @@ -INSERT INTO `bigframes-dev`.`sqlglot_test`.`dest_table` -`source_table` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_select/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_select/out.sql deleted file mode 100644 index c4f43f390ed..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_select/out.sql +++ /dev/null @@ -1,9 +0,0 @@ -MERGE INTO `bigframes-dev`.`sqlglot_test`.`dest_table` -USING ( - SELECT - * - FROM `source_table` -) -ON FALSE -WHEN NOT MATCHED BY SOURCE THEN DELETE -WHEN NOT MATCHED THEN INSERT ROW \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_table/out.sql b/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_table/out.sql deleted file mode 100644 index bfc1532ca2d..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/snapshots/test_dml/test_replace_from_table/out.sql +++ /dev/null @@ -1,5 +0,0 @@ -MERGE INTO `bigframes-dev`.`sqlglot_test`.`dest_table` -USING `source_table` -ON FALSE -WHEN NOT MATCHED BY SOURCE THEN DELETE -WHEN NOT MATCHED THEN INSERT ROW \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/sql/test_base.py b/tests/unit/core/compile/sqlglot/sql/test_base.py deleted file mode 100644 index 617f3636d40..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/test_base.py +++ /dev/null @@ -1,173 +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 -import decimal -import re - -import numpy as np -import pandas as pd -import pyarrow as pa -import pytest -import shapely.geometry # type: ignore - -import bigframes.core.compile.sqlglot.sql.base as sql - - -@pytest.mark.parametrize( - ("value", "expected_pattern"), - ( - pytest.param(None, "NULL", id="null"), - pytest.param(True, "TRUE", id="true"), - pytest.param(False, "FALSE", id="false"), - pytest.param(123, "123", id="int"), - pytest.param(123.75, "123.75", id="float"), - pytest.param("abc", "'abc'", id="string"), - pytest.param( - b"\x01\x02\x03ABC", "CAST(b'\\x01\\x02\\x03ABC' AS BYTES)", id="bytes" - ), - pytest.param( - decimal.Decimal("123.75"), "CAST(123.75 AS NUMERIC)", id="decimal" - ), - pytest.param( - datetime.date(2025, 1, 1), "CAST('2025-01-01' AS DATE)", id="date" - ), - pytest.param( - datetime.datetime(2025, 1, 2, 3, 45, 6, 789123), - "CAST('2025-01-02T03:45:06.789123' AS DATETIME)", - id="datetime", - ), - pytest.param( - datetime.time(12, 34, 56, 789123), - "CAST('12:34:56.789123' AS TIME)", - id="time", - ), - pytest.param( - datetime.datetime( - 2025, 1, 2, 3, 45, 6, 789123, tzinfo=datetime.timezone.utc - ), - "CAST('2025-01-02T03:45:06.789123+00:00' AS TIMESTAMP)", - id="timestamp", - ), - pytest.param(np.int64(123), "123", id="np_int64"), - pytest.param(np.float64(123.75), "123.75", id="np_float64"), - pytest.param(float("inf"), "CAST('Infinity' AS FLOAT64)", id="inf"), - pytest.param(float("-inf"), "CAST('-Infinity' AS FLOAT64)", id="neg_inf"), - pytest.param(float("nan"), "NULL", id="nan"), - pytest.param(pd.NA, "NULL", id="pd_na"), - pytest.param(datetime.timedelta(seconds=1), "1000000", id="timedelta"), - pytest.param("POINT (0 1)", "'POINT (0 1)'", id="string_geo"), - ), -) -def test_literal(value, expected_pattern): - got = sql.to_sql(sql.literal(value)) - assert got == expected_pattern - - -def test_literal_for_geo(): - value = shapely.geometry.Point(0, 1) - expected_pattern = r"ST_GEOGFROMTEXT\('POINT \(0[.]?0* 1[.]?0*\)'\)" - got = sql.to_sql(sql.literal(value)) - assert re.match(expected_pattern, got) is not None - - -@pytest.mark.parametrize( - ("value", "dtype", "expected"), - ( - pytest.param( - decimal.Decimal("1.23"), - sql.dtypes.BIGNUMERIC_DTYPE, - "CAST(1.23 AS BIGNUMERIC)", - id="bignumeric", - ), - pytest.param( - [], - pd.ArrowDtype(pa.list_(pa.int64())), - "ARRAY[]", - id="empty_array", - ), - pytest.param( - {"a": 1, "b": "hello"}, - pd.ArrowDtype(pa.struct([("a", pa.int64()), ("b", pa.string())])), - "STRUCT(1 AS `a`, 'hello' AS `b`)", - id="struct", - ), - pytest.param( - float("nan"), - sql.dtypes.FLOAT_DTYPE, - "CAST('NaN' AS FLOAT64)", - id="explicit_nan", - ), - pytest.param( - pa.scalar(123, type=pa.int64()), - None, - "123", - id="pa_scalar_int", - ), - pytest.param( - pa.scalar(None, type=pa.int64()), - None, - "CAST(NULL AS INT64)", - id="pa_scalar_null", - ), - pytest.param( - {"a": 10}, - sql.dtypes.JSON_DTYPE, - "PARSE_JSON('{\\'a\\': 10}')", - id="json", - ), - ), -) -def test_literal_explicit_dtype(value, dtype, expected): - got = sql.to_sql(sql.literal(value, dtype=dtype)) - assert got == expected - - -@pytest.mark.parametrize( - ("value", "expected"), - ( - pytest.param([True, False], "[TRUE, FALSE]", id="bool"), - pytest.param([123, 456], "[123, 456]", id="int"), - pytest.param( - [123.75, 456.78, float("nan"), float("inf"), float("-inf")], - "[\n 123.75,\n 456.78,\n CAST('NaN' AS FLOAT64),\n CAST('Infinity' AS FLOAT64),\n CAST('-Infinity' AS FLOAT64)\n]", - id="float", - ), - pytest.param( - [b"\x01\x02\x03ABC", b"\x01\x02\x03ABC"], - "[CAST(b'\\x01\\x02\\x03ABC' AS BYTES), CAST(b'\\x01\\x02\\x03ABC' AS BYTES)]", - id="bytes", - ), - pytest.param( - [datetime.date(2025, 1, 1), datetime.date(2025, 1, 1)], - "[CAST('2025-01-01' AS DATE), CAST('2025-01-01' AS DATE)]", - id="date", - ), - ), -) -def test_literal_for_list(value: list, expected: str): - got = sql.to_sql(sql.literal(value)) - assert got == expected - - -def test_literal_null_type(): - import unittest.mock as mock - - mock_dtype = mock.Mock() - with mock.patch( - "bigframes.core.compile.sqlglot.sql.base.sgt.from_bigframes_dtype", - return_value="NULL", - ): - got = sql.to_sql(sql.literal(None, dtype=mock_dtype)) - assert got == "NULL" diff --git a/tests/unit/core/compile/sqlglot/sql/test_ddl.py b/tests/unit/core/compile/sqlglot/sql/test_ddl.py deleted file mode 100644 index 48080cd6b9c..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/test_ddl.py +++ /dev/null @@ -1,87 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 - -import bigframes.bigquery -import bigframes.core.compile.sqlglot.sql as sql -import bigframes.session - -pytest.importorskip("pytest_snapshot") - - -@pytest.fixture -def mock_session(): - return mock.create_autospec(spec=bigframes.session.Session) - - -def test_load_data_minimal(snapshot): - expr = sql.load_data( - "my-project.my_dataset.my_table", - from_files_options={"format": "CSV", "uris": ["gs://bucket/path*"]}, - ) - snapshot.assert_match(sql.to_sql(expr), "out.sql") - - -def test_load_data_all_options(snapshot): - expr = sql.load_data( - "my-project.my_dataset.my_table", - write_disposition="OVERWRITE", - columns={"col1": "INT64", "col2": "STRING"}, - partition_by=["date_col"], - cluster_by=["cluster_col"], - table_options={"description": "my table"}, - from_files_options={"format": "CSV", "uris": ["gs://bucket/path*"]}, - with_partition_columns={"part1": "DATE", "part2": "STRING"}, - connection_name="my-connection", - ) - snapshot.assert_match(sql.to_sql(expr), "out.sql") - - -@mock.patch("bigframes.bigquery._operations.table._get_table_metadata") -def test_create_external_table(get_table_metadata_mock, mock_session, snapshot): - bigframes.bigquery.create_external_table( - "my-project.my_dataset.my_table", - columns={"col1": "INT64", "col2": "STRING"}, - options={"format": "CSV", "uris": ["gs://bucket/path*"]}, - session=mock_session, - ) - mock_session.read_gbq_query.assert_called_once() - generated_sql = mock_session.read_gbq_query.call_args[0][0] - snapshot.assert_match(generated_sql, "out.sql") - get_table_metadata_mock.assert_called_once() - - -def test_create_external_table_all_options(snapshot): - expr = sql.create_external_table( - "my-project.my_dataset.my_table", - replace=True, - columns={"col1": "INT64", "col2": "STRING"}, - partition_columns={"part1": "DATE", "part2": "STRING"}, - connection_name="my-connection", - options={"format": "CSV", "uris": ["gs://bucket/path*"]}, - ) - snapshot.assert_match(sql.to_sql(expr), "out.sql") - - -def test_create_external_table_if_not_exists(snapshot): - expr = sql.create_external_table( - "my-project.my_dataset.my_table", - if_not_exists=True, - columns={"col1": "INT64"}, - options={"format": "CSV", "uris": ["gs://bucket/path*"]}, - ) - snapshot.assert_match(sql.to_sql(expr), "out.sql") diff --git a/tests/unit/core/compile/sqlglot/sql/test_dml.py b/tests/unit/core/compile/sqlglot/sql/test_dml.py deleted file mode 100644 index 99f10892d90..00000000000 --- a/tests/unit/core/compile/sqlglot/sql/test_dml.py +++ /dev/null @@ -1,73 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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_vendored.sqlglot.expressions as sge -import pytest -from google.cloud import bigquery - -from bigframes.core.compile.sqlglot.sql import base, dml - -pytest.importorskip("pytest_snapshot") - - -def test_insert_from_select(snapshot): - query = sge.select("*").from_( - sge.Table(this=sge.Identifier(this="source_table", quoted=True)) - ) - destination = bigquery.TableReference.from_string( - "bigframes-dev.sqlglot_test.dest_table" - ) - - expr = dml.insert(query, destination) - sql = base.to_sql(expr) - - snapshot.assert_match(sql, "out.sql") - - -def test_insert_from_table(snapshot): - query = sge.Table(this=sge.Identifier(this="source_table", quoted=True)) - destination = bigquery.TableReference.from_string( - "bigframes-dev.sqlglot_test.dest_table" - ) - - expr = dml.insert(query, destination) - sql = base.to_sql(expr) - - snapshot.assert_match(sql, "out.sql") - - -def test_replace_from_select(snapshot): - query = sge.select("*").from_( - sge.Table(this=sge.Identifier(this="source_table", quoted=True)) - ) - destination = bigquery.TableReference.from_string( - "bigframes-dev.sqlglot_test.dest_table" - ) - - expr = dml.replace(query, destination) - sql = base.to_sql(expr) - - snapshot.assert_match(sql, "out.sql") - - -def test_replace_from_table(snapshot): - query = sge.Table(this=sge.Identifier(this="source_table", quoted=True)) - destination = bigquery.TableReference.from_string( - "bigframes-dev.sqlglot_test.dest_table" - ) - - expr = dml.replace(query, destination) - sql = base.to_sql(expr) - - snapshot.assert_match(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 index d13da8ec570..79f73d3113a 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_concat.py +++ b/tests/unit/core/compile/sqlglot/test_compile_concat.py @@ -12,37 +12,21 @@ # See the License for the specific language governing permissions and # limitations under the License. +import pandas as pd import pytest +import bigframes import bigframes.pandas as bpd -from bigframes.core import ordering pytest.importorskip("pytest_snapshot") -def test_compile_concat(scalar_types_df: bpd.DataFrame, snapshot): +def test_compile_concat( + scalar_types_pandas_df: pd.DataFrame, compiler_session: bigframes.Session, snapshot +): # TODO: concat two same dataframes, which SQL does not get reused. - df1 = scalar_types_df[["rowindex", "int64_col", "string_col"]] + # TODO: concat dataframes from a gbq table but trigger a windows compiler. + df1 = bpd.DataFrame(scalar_types_pandas_df, session=compiler_session) + df1 = df1[["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_fromrange.py b/tests/unit/core/compile/sqlglot/test_compile_fromrange.py deleted file mode 100644 index 8c25ca0310c..00000000000 --- a/tests/unit/core/compile/sqlglot/test_compile_fromrange.py +++ /dev/null @@ -1,35 +0,0 @@ -# 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 - -import bigframes.pandas as bpd - -pytest.importorskip("pytest_snapshot") - - -def test_compile_fromrange(compiler_session, snapshot): - 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), - } - df = bpd.DataFrame(data, session=compiler_session).set_index("timestamp_col") - sql, _, _ = df.resample(rule="7s")._block.to_sql_query( - include_index=True, enable_cache=False - ) - snapshot.assert_match(sql.strip() + "\n", "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_geo.py b/tests/unit/core/compile/sqlglot/test_compile_geo.py deleted file mode 100644 index 4aad2dfa315..00000000000 --- a/tests/unit/core/compile/sqlglot/test_compile_geo.py +++ /dev/null @@ -1,55 +0,0 @@ -# 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 shapely.geometry import LineString # type: ignore - -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( - [LineString([(0, 0), (1, 1), (2, 0)])], 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 deleted file mode 100644 index 8b3e7f7291f..00000000000 --- a/tests/unit/core/compile/sqlglot/test_compile_isin.py +++ /dev/null @@ -1,31 +0,0 @@ -# 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_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_random_sample.py b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py index 6aec633238c..6e333f04211 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_random_sample.py +++ b/tests/unit/core/compile/sqlglot/test_compile_random_sample.py @@ -14,9 +14,9 @@ import pytest -import bigframes.core as core -import bigframes.core.compile as compile from bigframes.core import nodes +import bigframes.core as core +import bigframes.core.compile.sqlglot as sqlglot pytest.importorskip("pytest_snapshot") @@ -31,5 +31,5 @@ def test_compile_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 + sql = sqlglot.compiler.SQLGlotCompiler().compile(node) 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 index 03a8b39d9a0..7307fd9b4e6 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_readlocal.py +++ b/tests/unit/core/compile/sqlglot/test_compile_readlocal.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -import numpy as np import pandas as pd import pytest @@ -34,6 +33,7 @@ def test_compile_readlocal_w_structs_df( 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 ) @@ -58,21 +58,3 @@ def test_compile_readlocal_w_json_df( ): 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 -): - 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 index 0f2058f21f6..a5692e5fbf6 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_readtable.py +++ b/tests/unit/core/compile/sqlglot/test_compile_readtable.py @@ -12,13 +12,9 @@ # 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 -from bigframes.core import bq_data pytest.importorskip("pytest_snapshot") @@ -51,44 +47,3 @@ 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), - bq_data.GbqNativeTable.from_table(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") - - -def test_compile_astype_aliases(scalar_types_df: bpd.DataFrame, snapshot): - # Test case for issue #17394 (CAST columns lose their aliases) - bf_df = scalar_types_df[["timestamp_col", "int64_col"]] - result = bf_df.astype( - { - "timestamp_col": "string[pyarrow]", - "int64_col": "Float64", - } - ) - snapshot.assert_match(result.sql + "\n", "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_compile_window.py b/tests/unit/core/compile/sqlglot/test_compile_window.py index 1602ec2c478..1fc70dc30f8 100644 --- a/tests/unit/core/compile/sqlglot/test_compile_window.py +++ b/tests/unit/core/compile/sqlglot/test_compile_window.py @@ -12,6 +12,8 @@ # 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 @@ -21,6 +23,13 @@ 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 diff --git a/tests/unit/core/compile/sqlglot/test_dataframe_accessor.py b/tests/unit/core/compile/sqlglot/test_dataframe_accessor.py deleted file mode 100644 index e430f566497..00000000000 --- a/tests/unit/core/compile/sqlglot/test_dataframe_accessor.py +++ /dev/null @@ -1,64 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 pandas as pd -import pytest - -import bigframes.pandas as bpd -import bigframes.session - -pytest.importorskip("pytest_snapshot") - -# Only test on the latest pandas since column naming behavior is slightly -# different across versions, e.g. unnamed vs 0 for unnamed Series. -pytest.importorskip("pandas", minversion="3.0.0") - - -def test_sql_scalar(scalar_types_df: bpd.DataFrame, snapshot, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - session.read_pandas.return_value = scalar_types_df - - def to_pandas(series, *, ordered): - assert ordered is True - sql, _, _ = series.to_frame()._to_sql_query(include_index=True) - return sql - - monkeypatch.setattr(bpd.Series, "to_pandas", to_pandas) - - df = pd.DataFrame({"int64_col": [1, 2], "int64_too": [3, 4]}) - result = df.bigquery.sql_scalar( - "ROUND({int64_col} + {int64_too})", - output_dtype=pd.Int64Dtype(), - session=session, - ) - - session.read_pandas.assert_called_once() - snapshot.assert_match(result.strip() + "\n", "out.sql") - - -def test_bigframes_sql_scalar(scalar_types_df: bpd.DataFrame, snapshot): - session = mock.create_autospec(bigframes.session.Session) - - result = scalar_types_df.bigquery.sql_scalar( - "ROUND({int64_col} + {int64_too})", - output_dtype=pd.Int64Dtype(), - session=session, - ) - - session.read_pandas.assert_not_called() - # Bigframes implementation returns a bigframes.series.Series - sql, _, _ = result.to_frame()._to_sql_query(include_index=True) - snapshot.assert_match(sql.strip() + "\n", "out.sql") diff --git a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py b/tests/unit/core/compile/sqlglot/test_scalar_compiler.py deleted file mode 100644 index d8a59420452..00000000000 --- a/tests/unit/core/compile/sqlglot/test_scalar_compiler.py +++ /dev/null @@ -1,226 +0,0 @@ -# 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 bigframes_vendored.sqlglot.expressions as sge -import pytest - -import bigframes.core.compile.sqlglot.expression_compiler as expression_compiler -import bigframes.operations as ops -from bigframes.core.compile.sqlglot.expressions.typed_expr import TypedExpr - - -def test_register_unary_op(): - compiler = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 = expression_compiler.ExpressionCompiler() - - 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 index 5c2d84383d7..a9108e5daf6 100644 --- a/tests/unit/core/compile/sqlglot/test_sqlglot_types.py +++ b/tests/unit/core/compile/sqlglot/test_sqlglot_types.py @@ -20,34 +20,34 @@ 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" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.INT_DTYPE) == "INT64" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.FLOAT_DTYPE) == "FLOAT64" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.STRING_DTYPE) == "STRING" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BOOL_DTYPE) == "BOOLEAN" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.DATE_DTYPE) == "DATE" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.TIME_DTYPE) == "TIME" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.DATETIME_DTYPE) == "DATETIME" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.TIMESTAMP_DTYPE) == "TIMESTAMP" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BYTES_DTYPE) == "BYTES" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.NUMERIC_DTYPE) == "NUMERIC" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.BIGNUMERIC_DTYPE) == "BIGNUMERIC" + assert sgt.SQLGlotType.from_bigframes_dtype(dtypes.JSON_DTYPE) == "JSON" + assert sgt.SQLGlotType.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 + assert sgt.SQLGlotType.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" + assert sgt.SQLGlotType.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" + assert sgt.SQLGlotType.from_bigframes_dtype(string_array_type) == "ARRAY" def test_from_bigframes_multi_nested_dtypes(): @@ -61,4 +61,4 @@ def test_from_bigframes_multi_nested_dtypes(): expected = ( "ARRAY>>" ) - assert sgt.from_bigframes_dtype(array_type) == expected + assert sgt.SQLGlotType.from_bigframes_dtype(array_type) == expected diff --git a/tests/unit/core/compile/sqlglot/tpch/conftest.py b/tests/unit/core/compile/sqlglot/tpch/conftest.py deleted file mode 100644 index b351b6988eb..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/conftest.py +++ /dev/null @@ -1,165 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 functools -import unittest.mock as mock - -import pytest -from google.cloud import bigquery - -import bigframes.testing.mocks as mocks -from bigframes.testing import compiler_session - -freezegun = pytest.importorskip("freezegun") - -PROJECT_NAME = "bigframes-dev-perf" -DATASET_NAME = "tpch_0001t" -LOCATION_NAME = "test-region" - -TPCH_SCHEMAS = { - "LINEITEM": [ - bigquery.SchemaField("L_ORDERKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("L_PARTKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("L_SUPPKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("L_LINENUMBER", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("L_QUANTITY", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("L_EXTENDEDPRICE", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("L_DISCOUNT", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("L_TAX", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("L_RETURNFLAG", "STRING", mode="REQUIRED"), - bigquery.SchemaField("L_LINESTATUS", "STRING", mode="REQUIRED"), - bigquery.SchemaField("L_SHIPDATE", "DATE", mode="REQUIRED"), - bigquery.SchemaField("L_COMMITDATE", "DATE", mode="REQUIRED"), - bigquery.SchemaField("L_RECEIPTDATE", "DATE", mode="REQUIRED"), - bigquery.SchemaField("L_SHIPINSTRUCT", "STRING", mode="REQUIRED"), - bigquery.SchemaField("L_SHIPMODE", "STRING", mode="REQUIRED"), - bigquery.SchemaField("L_COMMENT", "STRING"), - ], - "ORDERS": [ - bigquery.SchemaField("O_ORDERKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("O_CUSTKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("O_ORDERSTATUS", "STRING", mode="REQUIRED"), - bigquery.SchemaField("O_TOTALPRICE", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("O_ORDERDATE", "DATE", mode="REQUIRED"), - bigquery.SchemaField("O_ORDERPRIORITY", "STRING", mode="REQUIRED"), - bigquery.SchemaField("O_CLERK", "STRING", mode="REQUIRED"), - bigquery.SchemaField("O_SHIPPRIORITY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("O_COMMENT", "STRING"), - ], - "PART": [ - bigquery.SchemaField("P_PARTKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("P_NAME", "STRING", mode="REQUIRED"), - bigquery.SchemaField("P_MFGR", "STRING", mode="REQUIRED"), - bigquery.SchemaField("P_BRAND", "STRING", mode="REQUIRED"), - bigquery.SchemaField("P_TYPE", "STRING", mode="REQUIRED"), - bigquery.SchemaField("P_SIZE", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("P_CONTAINER", "STRING", mode="REQUIRED"), - bigquery.SchemaField("P_RETAILPRICE", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("P_COMMENT", "STRING"), - ], - "SUPPLIER": [ - bigquery.SchemaField("S_SUPPKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("S_NAME", "STRING", mode="REQUIRED"), - bigquery.SchemaField("S_ADDRESS", "STRING", mode="REQUIRED"), - bigquery.SchemaField("S_NATIONKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("S_PHONE", "STRING", mode="REQUIRED"), - bigquery.SchemaField("S_ACCTBAL", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("S_COMMENT", "STRING"), - ], - "PARTSUPP": [ - bigquery.SchemaField("PS_PARTKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("PS_SUPPKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("PS_AVAILQTY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("PS_SUPPLYCOST", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("PS_COMMENT", "STRING"), - ], - "CUSTOMER": [ - bigquery.SchemaField("C_CUSTKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("C_NAME", "STRING", mode="REQUIRED"), - bigquery.SchemaField("C_ADDRESS", "STRING", mode="REQUIRED"), - bigquery.SchemaField("C_NATIONKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("C_PHONE", "STRING", mode="REQUIRED"), - bigquery.SchemaField("C_ACCTBAL", "FLOAT", mode="REQUIRED"), - bigquery.SchemaField("C_MKTSEGMENT", "STRING", mode="REQUIRED"), - bigquery.SchemaField("C_COMMENT", "STRING"), - ], - "NATION": [ - bigquery.SchemaField("N_NATIONKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("N_NAME", "STRING", mode="REQUIRED"), - bigquery.SchemaField("N_REGIONKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("N_COMMENT", "STRING"), - ], - "REGION": [ - bigquery.SchemaField("R_REGIONKEY", "INTEGER", mode="REQUIRED"), - bigquery.SchemaField("R_NAME", "STRING", mode="REQUIRED"), - bigquery.SchemaField("R_COMMENT", "STRING"), - ], -} - - -def _create_mock_bqclient(): - """Helper function to create a compiler session.""" - - bqclient = mock.create_autospec(bigquery.Client, instance=True) - bqclient.project = DATASET_NAME - bqclient.location = LOCATION_NAME - table_create_time = datetime.datetime.now() - - def get_table_mock(table_ref): - if isinstance(table_ref, str): - table_ref = bigquery.TableReference.from_string(table_ref) - - table_id = table_ref.table_id - schema = TPCH_SCHEMAS.get(table_id, []) - - table = mock.create_autospec(bigquery.Table, instance=True) - table._properties = {} - type(table).created = mock.PropertyMock(return_value=table_create_time) - type(table).location = mock.PropertyMock(return_value=LOCATION_NAME) - type(table).schema = mock.PropertyMock(return_value=schema) - type(table).project = table_ref.project - type(table).dataset_id = table_ref.dataset_id - type(table).table_id = table_id - type(table).num_rows = mock.PropertyMock(return_value=1000000000) - return table - - bqclient.get_table.side_effect = get_table_mock - return bqclient - - -@pytest.fixture(scope="session") -def tpch_session(): - anonymous_dataset = bigquery.DatasetReference.from_string( - f"{PROJECT_NAME}.{DATASET_NAME}" - ) - session = mocks.create_bigquery_session( - bqclient=_create_mock_bqclient(), - anonymous_dataset=anonymous_dataset, - ) - - # Disable snapshotting for TPC-H tests to keep snapshots clean - original_read_gbq_table = session._loader.read_gbq_table - - @functools.wraps(original_read_gbq_table) - def read_gbq_table_no_snapshot(*args, **kwargs): - kwargs["enable_snapshot"] = False - return original_read_gbq_table(*args, **kwargs) - - session._executor = compiler_session.SQLCompilerExecutor() - - with mock.patch.object( - session._loader, "read_gbq_table", new=read_gbq_table_no_snapshot - ): - yield session diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/1/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/1/out.sql deleted file mode 100644 index 84ed65ec174..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/1/out.sql +++ /dev/null @@ -1,75 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `L_QUANTITY`, - `L_EXTENDEDPRICE`, - `L_DISCOUNT`, - `L_TAX`, - `L_RETURNFLAG`, - `L_LINESTATUS`, - `L_SHIPDATE`, - `L_QUANTITY` AS `bfcol_7`, - `L_EXTENDEDPRICE` AS `bfcol_8`, - `L_DISCOUNT` AS `bfcol_9`, - `L_TAX` AS `bfcol_10`, - `L_RETURNFLAG` AS `bfcol_11`, - `L_LINESTATUS` AS `bfcol_12`, - `L_SHIPDATE` <= CAST('1998-09-02' AS DATE) AS `bfcol_13`, - `L_QUANTITY` AS `bfcol_27`, - `L_EXTENDEDPRICE` AS `bfcol_28`, - `L_DISCOUNT` AS `bfcol_29`, - `L_TAX` AS `bfcol_30`, - `L_RETURNFLAG` AS `bfcol_31`, - `L_LINESTATUS` AS `bfcol_32`, - `L_EXTENDEDPRICE` * ( - 1.0 - `L_DISCOUNT` - ) AS `bfcol_33`, - `L_QUANTITY` AS `bfcol_41`, - `L_EXTENDEDPRICE` AS `bfcol_42`, - `L_DISCOUNT` AS `bfcol_43`, - `L_RETURNFLAG` AS `bfcol_44`, - `L_LINESTATUS` AS `bfcol_45`, - `L_EXTENDEDPRICE` * ( - 1.0 - `L_DISCOUNT` - ) AS `bfcol_46`, - ( - `L_EXTENDEDPRICE` * ( - 1.0 - `L_DISCOUNT` - ) - ) * ( - 1.0 + `L_TAX` - ) AS `bfcol_47` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` - WHERE - `L_SHIPDATE` <= CAST('1998-09-02' AS DATE) -), `bfcte_1` AS ( - SELECT - `bfcol_44`, - `bfcol_45`, - COALESCE(SUM(`bfcol_41`), 0) AS `bfcol_55`, - COALESCE(SUM(`bfcol_42`), 0) AS `bfcol_56`, - COALESCE(SUM(`bfcol_46`), 0) AS `bfcol_57`, - COALESCE(SUM(`bfcol_47`), 0) AS `bfcol_58`, - AVG(`bfcol_41`) AS `bfcol_59`, - AVG(`bfcol_42`) AS `bfcol_60`, - AVG(`bfcol_43`) AS `bfcol_61`, - COUNT(`bfcol_41`) AS `bfcol_62` - FROM `bfcte_0` - GROUP BY - `bfcol_44`, - `bfcol_45` -) -SELECT - `bfcol_44` AS `L_RETURNFLAG`, - `bfcol_45` AS `L_LINESTATUS`, - `bfcol_55` AS `SUM_QTY`, - `bfcol_56` AS `SUM_BASE_PRICE`, - `bfcol_57` AS `SUM_DISC_PRICE`, - `bfcol_58` AS `SUM_CHARGE`, - `bfcol_59` AS `AVG_QTY`, - `bfcol_60` AS `AVG_PRICE`, - `bfcol_61` AS `AVG_DISC`, - `bfcol_62` AS `COUNT_ORDER` -FROM `bfcte_1` -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/tpch/snapshots/test_tpch/test_tpch_query/10/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/10/out.sql deleted file mode 100644 index 39dc2484342..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/10/out.sql +++ /dev/null @@ -1,162 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_0`, - `N_NAME` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_3` -), `bfcte_1` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_2`, - `L_EXTENDEDPRICE` AS `bfcol_3`, - `L_DISCOUNT` AS `bfcol_4`, - `L_RETURNFLAG` AS `bfcol_5` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_2` -), `bfcte_2` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_6`, - `O_CUSTKEY` AS `bfcol_7`, - `O_ORDERDATE` AS `bfcol_8` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` -), `bfcte_3` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_9`, - `C_NAME` AS `bfcol_10`, - `C_ADDRESS` AS `bfcol_11`, - `C_NATIONKEY` AS `bfcol_12`, - `C_PHONE` AS `bfcol_13`, - `C_ACCTBAL` AS `bfcol_14`, - `C_COMMENT` AS `bfcol_15` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` -), `bfcte_4` AS ( - SELECT - `bfcol_9` AS `bfcol_16`, - `bfcol_10` AS `bfcol_17`, - `bfcol_11` AS `bfcol_18`, - `bfcol_12` AS `bfcol_19`, - `bfcol_13` AS `bfcol_20`, - `bfcol_14` AS `bfcol_21`, - `bfcol_15` AS `bfcol_22`, - `bfcol_6` AS `bfcol_23`, - `bfcol_8` AS `bfcol_24` - FROM `bfcte_3` - INNER JOIN `bfcte_2` - ON `bfcol_9` = `bfcol_7` -), `bfcte_5` AS ( - SELECT - `bfcol_16` AS `bfcol_25`, - `bfcol_17` AS `bfcol_26`, - `bfcol_18` AS `bfcol_27`, - `bfcol_19` AS `bfcol_28`, - `bfcol_20` AS `bfcol_29`, - `bfcol_21` AS `bfcol_30`, - `bfcol_22` AS `bfcol_31`, - `bfcol_24` AS `bfcol_32`, - `bfcol_3` AS `bfcol_33`, - `bfcol_4` AS `bfcol_34`, - `bfcol_5` AS `bfcol_35` - FROM `bfcte_4` - INNER JOIN `bfcte_1` - ON `bfcol_23` = `bfcol_2` -), `bfcte_6` AS ( - SELECT - `bfcol_25`, - `bfcol_26`, - `bfcol_27`, - `bfcol_28`, - `bfcol_29`, - `bfcol_30`, - `bfcol_31`, - `bfcol_32`, - `bfcol_33`, - `bfcol_34`, - `bfcol_35`, - `bfcol_0`, - `bfcol_1`, - `bfcol_25` AS `bfcol_47`, - `bfcol_26` AS `bfcol_48`, - `bfcol_27` AS `bfcol_49`, - `bfcol_29` AS `bfcol_50`, - `bfcol_30` AS `bfcol_51`, - `bfcol_31` AS `bfcol_52`, - `bfcol_33` AS `bfcol_53`, - `bfcol_34` AS `bfcol_54`, - `bfcol_1` AS `bfcol_55`, - ( - ( - `bfcol_32` >= CAST('1993-10-01' AS DATE) - ) - AND ( - `bfcol_32` < CAST('1994-01-01' AS DATE) - ) - ) - AND ( - `bfcol_35` = 'R' - ) AS `bfcol_56`, - `bfcol_25` AS `bfcol_76`, - `bfcol_26` AS `bfcol_77`, - `bfcol_27` AS `bfcol_78`, - `bfcol_29` AS `bfcol_79`, - `bfcol_30` AS `bfcol_80`, - `bfcol_31` AS `bfcol_81`, - `bfcol_1` AS `bfcol_82`, - ROUND(( - `bfcol_33` * ( - 1 - `bfcol_34` - ) - ), 2) AS `bfcol_83` - FROM `bfcte_5` - INNER JOIN `bfcte_0` - ON `bfcol_28` = `bfcol_0` - WHERE - ( - ( - `bfcol_32` >= CAST('1993-10-01' AS DATE) - ) - AND ( - `bfcol_32` < CAST('1994-01-01' AS DATE) - ) - ) - AND ( - `bfcol_35` = 'R' - ) -), `bfcte_7` AS ( - SELECT - `bfcol_76`, - `bfcol_77`, - `bfcol_80`, - `bfcol_79`, - `bfcol_82`, - `bfcol_78`, - `bfcol_81`, - COALESCE(SUM(`bfcol_83`), 0) AS `bfcol_92` - FROM `bfcte_6` - WHERE - NOT `bfcol_81` IS NULL - GROUP BY - `bfcol_76`, - `bfcol_77`, - `bfcol_80`, - `bfcol_79`, - `bfcol_82`, - `bfcol_78`, - `bfcol_81` -) -SELECT - `bfcol_76` AS `C_CUSTKEY`, - `bfcol_77` AS `C_NAME`, - `bfcol_92` AS `REVENUE`, - `bfcol_80` AS `C_ACCTBAL`, - `bfcol_82` AS `N_NAME`, - `bfcol_78` AS `C_ADDRESS`, - `bfcol_79` AS `C_PHONE`, - `bfcol_81` AS `C_COMMENT` -FROM `bfcte_7` -ORDER BY - `bfcol_92` DESC, - `bfcol_76` ASC NULLS LAST, - `bfcol_77` ASC NULLS LAST, - `bfcol_80` ASC NULLS LAST, - `bfcol_79` ASC NULLS LAST, - `bfcol_82` ASC NULLS LAST, - `bfcol_78` ASC NULLS LAST, - `bfcol_81` ASC NULLS LAST -LIMIT 20 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/11/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/11/out.sql deleted file mode 100644 index 31a357be7c0..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/11/out.sql +++ /dev/null @@ -1,115 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(0.0, 0, 0)]) -), `bfcte_1` AS ( - SELECT - `PS_SUPPKEY` AS `bfcol_0`, - `PS_AVAILQTY` AS `bfcol_1`, - `PS_SUPPLYCOST` AS `bfcol_2` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_2` -), `bfcte_2` AS ( - SELECT - `PS_PARTKEY` AS `bfcol_10`, - `PS_SUPPKEY` AS `bfcol_11`, - `PS_AVAILQTY` AS `bfcol_12`, - `PS_SUPPLYCOST` AS `bfcol_13` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_2` -), `bfcte_3` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_3`, - `S_NATIONKEY` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_1` -), `bfcte_4` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_18` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_0` - WHERE - `N_NAME` = 'GERMANY' -), `bfcte_5` AS ( - SELECT - `bfcol_3` AS `bfcol_19` - FROM `bfcte_4` - INNER JOIN `bfcte_3` - ON `bfcol_18` = `bfcol_4` -), `bfcte_6` AS ( - SELECT - `bfcol_19`, - `bfcol_0`, - `bfcol_1`, - `bfcol_2`, - `bfcol_1` AS `bfcol_25`, - `bfcol_2` AS `bfcol_26`, - `bfcol_2` AS `bfcol_33`, - `bfcol_1` AS `bfcol_34`, - `bfcol_2` * `bfcol_1` AS `bfcol_40` - FROM `bfcte_5` - INNER JOIN `bfcte_1` - ON `bfcol_19` = `bfcol_0` -), `bfcte_7` AS ( - SELECT - `bfcol_19`, - `bfcol_10`, - `bfcol_11`, - `bfcol_12`, - `bfcol_13`, - `bfcol_10` AS `bfcol_27`, - `bfcol_13` * `bfcol_12` AS `bfcol_28` - FROM `bfcte_5` - INNER JOIN `bfcte_2` - ON `bfcol_19` = `bfcol_11` -), `bfcte_8` AS ( - SELECT - COALESCE(SUM(`bfcol_40`), 0) AS `bfcol_44` - FROM `bfcte_6` -), `bfcte_9` AS ( - SELECT - `bfcol_27`, - COALESCE(SUM(`bfcol_28`), 0) AS `bfcol_35` - FROM `bfcte_7` - GROUP BY - `bfcol_27` -), `bfcte_10` AS ( - SELECT - `bfcol_44`, - 0 AS `bfcol_45` - FROM `bfcte_8` -), `bfcte_11` AS ( - SELECT - `bfcol_27` AS `bfcol_41`, - ROUND(`bfcol_35`, 2) AS `bfcol_42` - FROM `bfcte_9` -), `bfcte_12` AS ( - SELECT - `bfcol_7`, - `bfcol_8`, - `bfcol_9`, - `bfcol_44`, - `bfcol_45`, - CASE WHEN `bfcol_9` = 0 THEN `bfcol_44` END AS `bfcol_46`, - IF(`bfcol_45` = 0, CASE WHEN `bfcol_9` = 0 THEN `bfcol_44` END, NULL) AS `bfcol_51` - FROM `bfcte_0` - CROSS JOIN `bfcte_10` -), `bfcte_13` AS ( - SELECT - `bfcol_7`, - `bfcol_8`, - ANY_VALUE(`bfcol_51`) AS `bfcol_55` - FROM `bfcte_12` - GROUP BY - `bfcol_7`, - `bfcol_8` -), `bfcte_14` AS ( - SELECT - `bfcol_55` * 0.0001 AS `bfcol_58` - FROM `bfcte_13` -) -SELECT - `bfcol_41` AS `PS_PARTKEY`, - `bfcol_42` AS `VALUE` -FROM `bfcte_11` -CROSS JOIN `bfcte_14` -WHERE - `bfcol_42` > `bfcol_58` -ORDER BY - `bfcol_42` DESC \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/12/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/12/out.sql deleted file mode 100644 index d5ab954a20b..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/12/out.sql +++ /dev/null @@ -1,90 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_0`, - `L_SHIPDATE` AS `bfcol_1`, - `L_COMMITDATE` AS `bfcol_2`, - `L_RECEIPTDATE` AS `bfcol_3`, - `L_SHIPMODE` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_1` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_5`, - `O_ORDERPRIORITY` AS `bfcol_6` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `bfcol_5`, - `bfcol_6`, - `bfcol_0`, - `bfcol_1`, - `bfcol_2`, - `bfcol_3`, - `bfcol_4`, - `bfcol_6` AS `bfcol_12`, - `bfcol_4` AS `bfcol_13`, - ( - ( - ( - COALESCE(COALESCE(`bfcol_4` IN ('MAIL', 'SHIP'), FALSE), FALSE) - AND ( - `bfcol_2` < `bfcol_3` - ) - ) - AND ( - `bfcol_1` < `bfcol_2` - ) - ) - AND ( - `bfcol_3` >= CAST('1994-01-01' AS DATE) - ) - ) - AND ( - `bfcol_3` < CAST('1995-01-01' AS DATE) - ) AS `bfcol_14`, - `bfcol_6` AS `bfcol_20`, - `bfcol_4` AS `bfcol_21`, - CAST(COALESCE(COALESCE(`bfcol_6` IN ('1-URGENT', '2-HIGH'), FALSE), FALSE) AS INT64) AS `bfcol_22`, - `bfcol_4` AS `bfcol_26`, - CAST(COALESCE(COALESCE(`bfcol_6` IN ('1-URGENT', '2-HIGH'), FALSE), FALSE) AS INT64) AS `bfcol_27`, - CAST(NOT ( - COALESCE(COALESCE(`bfcol_6` IN ('1-URGENT', '2-HIGH'), FALSE), FALSE) - ) AS INT64) AS `bfcol_28` - FROM `bfcte_1` - INNER JOIN `bfcte_0` - ON `bfcol_5` = `bfcol_0` - WHERE - ( - ( - ( - COALESCE(COALESCE(`bfcol_4` IN ('MAIL', 'SHIP'), FALSE), FALSE) - AND ( - `bfcol_2` < `bfcol_3` - ) - ) - AND ( - `bfcol_1` < `bfcol_2` - ) - ) - AND ( - `bfcol_3` >= CAST('1994-01-01' AS DATE) - ) - ) - AND ( - `bfcol_3` < CAST('1995-01-01' AS DATE) - ) -), `bfcte_3` AS ( - SELECT - `bfcol_26`, - COALESCE(SUM(`bfcol_27`), 0) AS `bfcol_32`, - COALESCE(SUM(`bfcol_28`), 0) AS `bfcol_33` - FROM `bfcte_2` - GROUP BY - `bfcol_26` -) -SELECT - `bfcol_26` AS `L_SHIPMODE`, - `bfcol_32` AS `HIGH_LINE_COUNT`, - `bfcol_33` AS `LOW_LINE_COUNT` -FROM `bfcte_3` -ORDER BY - `bfcol_26` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/13/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/13/out.sql deleted file mode 100644 index 6aab2b4fec7..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/13/out.sql +++ /dev/null @@ -1,39 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_10`, - `O_CUSTKEY` AS `bfcol_11` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` - WHERE - NOT ( - REGEXP_CONTAINS(`O_COMMENT`, 'special.*requests') - ) -), `bfcte_1` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `bfcol_3`, - COUNT(`bfcol_10`) AS `bfcol_14` - FROM `bfcte_1` - LEFT JOIN `bfcte_0` - ON `bfcol_3` = `bfcol_11` - GROUP BY - `bfcol_3` -), `bfcte_3` AS ( - SELECT - `bfcol_14`, - COUNT(1) AS `bfcol_16` - FROM `bfcte_2` - WHERE - NOT `bfcol_14` IS NULL - GROUP BY - `bfcol_14` -) -SELECT - `bfcol_14` AS `C_COUNT`, - `bfcol_16` AS `CUSTDIST` -FROM `bfcte_3` -ORDER BY - `bfcol_16` DESC, - `bfcol_14` DESC \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/14/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/14/out.sql deleted file mode 100644 index bde638a0f4c..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/14/out.sql +++ /dev/null @@ -1,164 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('TEMP', 0, 0)]) -), `bfcte_1` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('TEMP', 0, 0)]) -), `bfcte_2` AS ( - SELECT - `L_PARTKEY` AS `bfcol_0`, - `L_EXTENDEDPRICE` AS `bfcol_1`, - `L_DISCOUNT` AS `bfcol_2`, - `L_SHIPDATE` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_3` AS ( - SELECT - `P_PARTKEY` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_4` AS ( - SELECT - `P_PARTKEY` AS `bfcol_8`, - `P_TYPE` AS `bfcol_9` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_5` AS ( - SELECT - `bfcol_4`, - `bfcol_0`, - `bfcol_1`, - `bfcol_2`, - `bfcol_3`, - `bfcol_1` AS `bfcol_20`, - `bfcol_2` AS `bfcol_21`, - ( - `bfcol_3` >= CAST('1995-09-01' AS DATE) - ) - AND ( - `bfcol_3` < CAST('1995-10-01' AS DATE) - ) AS `bfcol_22`, - `bfcol_1` AS `bfcol_39`, - `bfcol_2` AS `bfcol_40`, - `bfcol_1` AS `bfcol_45`, - 1 - `bfcol_2` AS `bfcol_46`, - `bfcol_1` * ( - 1 - `bfcol_2` - ) AS `bfcol_51` - FROM `bfcte_3` - INNER JOIN `bfcte_2` - ON `bfcol_4` = `bfcol_0` - WHERE - ( - `bfcol_3` >= CAST('1995-09-01' AS DATE) - ) - AND ( - `bfcol_3` < CAST('1995-10-01' AS DATE) - ) -), `bfcte_6` AS ( - SELECT - `bfcol_8`, - `bfcol_9`, - `bfcol_0`, - `bfcol_1`, - `bfcol_2`, - `bfcol_3`, - `bfcol_9` AS `bfcol_23`, - `bfcol_1` AS `bfcol_24`, - `bfcol_2` AS `bfcol_25`, - ( - `bfcol_3` >= CAST('1995-09-01' AS DATE) - ) - AND ( - `bfcol_3` < CAST('1995-10-01' AS DATE) - ) AS `bfcol_26`, - ( - `bfcol_1` * ( - 1 - `bfcol_2` - ) - ) * CAST(REGEXP_CONTAINS(`bfcol_9`, 'PROMO') AS INT64) AS `bfcol_41` - FROM `bfcte_4` - INNER JOIN `bfcte_2` - ON `bfcol_8` = `bfcol_0` - WHERE - ( - `bfcol_3` >= CAST('1995-09-01' AS DATE) - ) - AND ( - `bfcol_3` < CAST('1995-10-01' AS DATE) - ) -), `bfcte_7` AS ( - SELECT - COALESCE(SUM(`bfcol_51`), 0) AS `bfcol_54` - FROM `bfcte_5` -), `bfcte_8` AS ( - SELECT - COALESCE(SUM(`bfcol_41`), 0) AS `bfcol_47` - FROM `bfcte_6` -), `bfcte_9` AS ( - SELECT - `bfcol_54`, - 0 AS `bfcol_59` - FROM `bfcte_7` -), `bfcte_10` AS ( - SELECT - `bfcol_47`, - 0 AS `bfcol_50` - FROM `bfcte_8` -), `bfcte_11` AS ( - SELECT - `bfcol_5`, - `bfcol_6`, - `bfcol_7`, - `bfcol_54`, - `bfcol_59`, - CASE WHEN `bfcol_7` = 0 THEN `bfcol_54` END AS `bfcol_64`, - IF(`bfcol_59` = 0, CASE WHEN `bfcol_7` = 0 THEN `bfcol_54` END, NULL) AS `bfcol_72` - FROM `bfcte_0` - CROSS JOIN `bfcte_9` -), `bfcte_12` AS ( - SELECT - `bfcol_10`, - `bfcol_11`, - `bfcol_12`, - `bfcol_47`, - `bfcol_50`, - CASE WHEN `bfcol_12` = 0 THEN `bfcol_47` END AS `bfcol_53`, - IF(`bfcol_50` = 0, CASE WHEN `bfcol_12` = 0 THEN `bfcol_47` END, NULL) AS `bfcol_60` - FROM `bfcte_1` - CROSS JOIN `bfcte_10` -), `bfcte_13` AS ( - SELECT - `bfcol_5`, - `bfcol_6`, - ANY_VALUE(`bfcol_72`) AS `bfcol_79` - FROM `bfcte_11` - GROUP BY - `bfcol_5`, - `bfcol_6` -), `bfcte_14` AS ( - SELECT - `bfcol_10`, - `bfcol_11`, - ANY_VALUE(`bfcol_60`) AS `bfcol_65` - FROM `bfcte_12` - GROUP BY - `bfcol_10`, - `bfcol_11` -), `bfcte_15` AS ( - SELECT - `bfcol_5` AS `bfcol_80`, - `bfcol_79` AS `bfcol_81` - FROM `bfcte_13` -), `bfcte_16` AS ( - SELECT - `bfcol_10` AS `bfcol_77`, - 100.0 * `bfcol_65` AS `bfcol_78` - FROM `bfcte_14` -) -SELECT - ROUND(IEEE_DIVIDE(`bfcol_78`, `bfcol_81`), 2) AS `PROMO_REVENUE` -FROM `bfcte_16` -FULL OUTER JOIN `bfcte_15` - ON `bfcol_77` = `bfcol_80` -ORDER BY - COALESCE(`bfcol_77`, `bfcol_80`) ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/15/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/15/out.sql deleted file mode 100644 index e3cc2bd9743..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/15/out.sql +++ /dev/null @@ -1,112 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('TOTAL_REVENUE', 0, 0)]) -), `bfcte_1` AS ( - SELECT - `L_SUPPKEY`, - `L_EXTENDEDPRICE`, - `L_DISCOUNT`, - `L_SHIPDATE`, - `L_SUPPKEY` AS `bfcol_12`, - `L_EXTENDEDPRICE` AS `bfcol_13`, - `L_DISCOUNT` AS `bfcol_14`, - ( - `L_SHIPDATE` >= CAST('1996-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1996-04-01' AS DATE) - ) AS `bfcol_15`, - `L_SUPPKEY` AS `bfcol_23`, - `L_EXTENDEDPRICE` * ( - 1 - `L_DISCOUNT` - ) AS `bfcol_24` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` - WHERE - ( - `L_SHIPDATE` >= CAST('1996-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1996-04-01' AS DATE) - ) -), `bfcte_2` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_0` -), `bfcte_3` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_8`, - `S_NAME` AS `bfcol_9`, - `S_ADDRESS` AS `bfcol_10`, - `S_PHONE` AS `bfcol_11` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_0` -), `bfcte_4` AS ( - SELECT - `bfcol_23`, - COALESCE(SUM(`bfcol_24`), 0) AS `bfcol_27` - FROM `bfcte_1` - GROUP BY - `bfcol_23` -), `bfcte_5` AS ( - SELECT - `bfcol_23` AS `bfcol_30`, - ROUND(`bfcol_27`, 2) AS `bfcol_31` - FROM `bfcte_4` -), `bfcte_6` AS ( - SELECT - MAX(`bfcol_31`) AS `bfcol_38` - FROM `bfcte_2` - INNER JOIN `bfcte_5` - ON `bfcol_4` = `bfcol_30` -), `bfcte_7` AS ( - SELECT - `bfcol_8` AS `bfcol_33`, - `bfcol_9` AS `bfcol_34`, - `bfcol_10` AS `bfcol_35`, - `bfcol_11` AS `bfcol_36`, - `bfcol_31` AS `bfcol_37` - FROM `bfcte_3` - INNER JOIN `bfcte_5` - ON `bfcol_8` = `bfcol_30` -), `bfcte_8` AS ( - SELECT - `bfcol_38`, - 0 AS `bfcol_39` - FROM `bfcte_6` -), `bfcte_9` AS ( - SELECT - `bfcol_5`, - `bfcol_6`, - `bfcol_7`, - `bfcol_38`, - `bfcol_39`, - CASE WHEN `bfcol_7` = 0 THEN `bfcol_38` END AS `bfcol_40`, - IF(`bfcol_39` = 0, CASE WHEN `bfcol_7` = 0 THEN `bfcol_38` END, NULL) AS `bfcol_45` - FROM `bfcte_0` - CROSS JOIN `bfcte_8` -), `bfcte_10` AS ( - SELECT - `bfcol_5`, - `bfcol_6`, - ANY_VALUE(`bfcol_45`) AS `bfcol_49` - FROM `bfcte_9` - GROUP BY - `bfcol_5`, - `bfcol_6` -), `bfcte_11` AS ( - SELECT - `bfcol_49` AS `bfcol_50` - FROM `bfcte_10` -) -SELECT - `bfcol_33` AS `S_SUPPKEY`, - `bfcol_34` AS `S_NAME`, - `bfcol_35` AS `S_ADDRESS`, - `bfcol_36` AS `S_PHONE`, - `bfcol_37` AS `TOTAL_REVENUE` -FROM `bfcte_7` -CROSS JOIN `bfcte_11` -WHERE - `bfcol_37` = `bfcol_50` -ORDER BY - `bfcol_33` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/16/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/16/out.sql deleted file mode 100644 index 228d51a76c7..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/16/out.sql +++ /dev/null @@ -1,88 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `S_SUPPKEY`, - `S_COMMENT`, - `S_SUPPKEY` AS `bfcol_8`, - NOT ( - REGEXP_CONTAINS(`S_COMMENT`, 'Customer.*Complaints') - ) AS `bfcol_9` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_2` - WHERE - NOT ( - REGEXP_CONTAINS(`S_COMMENT`, 'Customer.*Complaints') - ) -), `bfcte_1` AS ( - SELECT - `PS_PARTKEY` AS `bfcol_2`, - `PS_SUPPKEY` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_1` -), `bfcte_2` AS ( - SELECT - `P_PARTKEY` AS `bfcol_4`, - `P_BRAND` AS `bfcol_5`, - `P_TYPE` AS `bfcol_6`, - `P_SIZE` AS `bfcol_7` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_3` AS ( - SELECT - `bfcol_8` - FROM `bfcte_0` - GROUP BY - `bfcol_8` -), `bfcte_4` AS ( - SELECT - `bfcol_5` AS `bfcol_55`, - `bfcol_6` AS `bfcol_56`, - `bfcol_7` AS `bfcol_57`, - `bfcol_3` AS `bfcol_58` - FROM `bfcte_2` - INNER JOIN `bfcte_1` - ON `bfcol_4` = `bfcol_2` - WHERE - `bfcol_5` <> 'Brand#45' - AND NOT ( - REGEXP_CONTAINS(`bfcol_6`, 'MEDIUM POLISHED') - ) - AND COALESCE(COALESCE(`bfcol_7` IN (49, 14, 23, 45, 19, 3, 36, 9), FALSE), FALSE) -), `bfcte_5` AS ( - SELECT - `bfcol_8` AS `bfcol_21` - FROM `bfcte_3` -), `bfcte_6` AS ( - SELECT - *, - COALESCE(`bfcol_58` IN (( - SELECT - * - FROM `bfcte_5` - )), FALSE) AS `bfcol_59` - FROM `bfcte_4` -), `bfcte_7` AS ( - SELECT - * - FROM `bfcte_6` - WHERE - `bfcol_59` -), `bfcte_8` AS ( - SELECT - `bfcol_55`, - `bfcol_56`, - `bfcol_57`, - COUNT(DISTINCT `bfcol_58`) AS `bfcol_69` - FROM `bfcte_7` - GROUP BY - `bfcol_55`, - `bfcol_56`, - `bfcol_57` -) -SELECT - `bfcol_55` AS `P_BRAND`, - `bfcol_56` AS `P_TYPE`, - `bfcol_57` AS `P_SIZE`, - `bfcol_69` AS `SUPPLIER_CNT` -FROM `bfcte_8` -ORDER BY - `bfcol_69` DESC, - `bfcol_55` ASC NULLS LAST, - `bfcol_56` ASC NULLS LAST, - `bfcol_57` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/17/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/17/out.sql deleted file mode 100644 index 40aacf917f1..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/17/out.sql +++ /dev/null @@ -1,97 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('L_EXTENDEDPRICE', 0, 0)]) -), `bfcte_1` AS ( - SELECT - `P_PARTKEY` AS `bfcol_15` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_1` - WHERE - ( - `P_BRAND` = 'Brand#23' - ) AND ( - `P_CONTAINER` = 'MED BOX' - ) -), `bfcte_2` AS ( - SELECT - `L_PARTKEY` AS `bfcol_3`, - `L_QUANTITY` AS `bfcol_4`, - `L_EXTENDEDPRICE` AS `bfcol_5` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` -), `bfcte_3` AS ( - SELECT - `L_PARTKEY` AS `bfcol_6`, - `L_QUANTITY` AS `bfcol_7` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` -), `bfcte_4` AS ( - SELECT - `bfcol_4` AS `bfcol_16`, - `bfcol_5` AS `bfcol_17`, - `bfcol_15` AS `bfcol_18` - FROM `bfcte_2` - RIGHT JOIN `bfcte_1` - ON `bfcol_3` = `bfcol_15` -), `bfcte_5` AS ( - SELECT - `bfcol_15`, - AVG(`bfcol_7`) AS `bfcol_21` - FROM `bfcte_3` - RIGHT JOIN `bfcte_1` - ON `bfcol_6` = `bfcol_15` - GROUP BY - `bfcol_15` -), `bfcte_6` AS ( - SELECT - `bfcol_15` AS `bfcol_24`, - `bfcol_21` * 0.2 AS `bfcol_25` - FROM `bfcte_5` -), `bfcte_7` AS ( - SELECT - `bfcol_24`, - `bfcol_25`, - `bfcol_16`, - `bfcol_17`, - `bfcol_18`, - `bfcol_17` AS `bfcol_29`, - `bfcol_16` < `bfcol_25` AS `bfcol_30` - FROM `bfcte_6` - INNER JOIN `bfcte_4` - ON `bfcol_24` = `bfcol_18` - WHERE - `bfcol_16` < `bfcol_25` -), `bfcte_8` AS ( - SELECT - COALESCE(SUM(`bfcol_29`), 0) AS `bfcol_34` - FROM `bfcte_7` -), `bfcte_9` AS ( - SELECT - `bfcol_34`, - 0 AS `bfcol_35` - FROM `bfcte_8` -), `bfcte_10` AS ( - SELECT - `bfcol_8`, - `bfcol_9`, - `bfcol_10`, - `bfcol_34`, - `bfcol_35`, - CASE WHEN `bfcol_10` = 0 THEN `bfcol_34` END AS `bfcol_36`, - IF(`bfcol_35` = 0, CASE WHEN `bfcol_10` = 0 THEN `bfcol_34` END, NULL) AS `bfcol_41` - FROM `bfcte_0` - CROSS JOIN `bfcte_9` -), `bfcte_11` AS ( - SELECT - `bfcol_8`, - `bfcol_9`, - ANY_VALUE(`bfcol_41`) AS `bfcol_45` - FROM `bfcte_10` - GROUP BY - `bfcol_8`, - `bfcol_9` -) -SELECT - ROUND(IEEE_DIVIDE(`bfcol_45`, 7.0), 2) AS `AVG_YEARLY` -FROM `bfcte_11` -ORDER BY - `bfcol_9` ASC NULLS LAST, - `bfcol_8` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/18/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/18/out.sql deleted file mode 100644 index 6fcdb343940..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/18/out.sql +++ /dev/null @@ -1,104 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_0`, - `C_NAME` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_2` -), `bfcte_1` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_2`, - `L_QUANTITY` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_2` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_4`, - `O_CUSTKEY` AS `bfcol_5`, - `O_TOTALPRICE` AS `bfcol_6`, - `O_ORDERDATE` AS `bfcol_7` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_0` -), `bfcte_3` AS ( - SELECT - `bfcol_2`, - COALESCE(SUM(`bfcol_3`), 0) AS `bfcol_8` - FROM `bfcte_1` - GROUP BY - `bfcol_2` -), `bfcte_4` AS ( - SELECT - `bfcol_2`, - `bfcol_8`, - `bfcol_2` AS `bfcol_9`, - `bfcol_8` > 300 AS `bfcol_10` - FROM `bfcte_3` - WHERE - `bfcol_8` > 300 -), `bfcte_5` AS ( - SELECT - `bfcol_9` - FROM `bfcte_4` - GROUP BY - `bfcol_9` -), `bfcte_6` AS ( - SELECT - `bfcol_9` AS `bfcol_13` - FROM `bfcte_5` -), `bfcte_7` AS ( - SELECT - *, - COALESCE(`bfcol_4` IN (( - SELECT - * - FROM `bfcte_6` - )), FALSE) AS `bfcol_14` - FROM `bfcte_2` -), `bfcte_8` AS ( - SELECT - `bfcol_4` AS `bfcol_20`, - `bfcol_5` AS `bfcol_21`, - `bfcol_6` AS `bfcol_22`, - `bfcol_7` AS `bfcol_23` - FROM `bfcte_7` - WHERE - `bfcol_14` -), `bfcte_9` AS ( - SELECT - `bfcol_20` AS `bfcol_24`, - `bfcol_21` AS `bfcol_25`, - `bfcol_22` AS `bfcol_26`, - `bfcol_23` AS `bfcol_27`, - `bfcol_3` AS `bfcol_28` - FROM `bfcte_8` - INNER JOIN `bfcte_1` - ON `bfcol_20` = `bfcol_2` -), `bfcte_10` AS ( - SELECT - `bfcol_1`, - `bfcol_0`, - `bfcol_24`, - `bfcol_27`, - `bfcol_26`, - COALESCE(SUM(`bfcol_28`), 0) AS `bfcol_35` - FROM `bfcte_9` - INNER JOIN `bfcte_0` - ON `bfcol_25` = `bfcol_0` - GROUP BY - `bfcol_1`, - `bfcol_0`, - `bfcol_24`, - `bfcol_27`, - `bfcol_26` -) -SELECT - `bfcol_1` AS `C_NAME`, - `bfcol_0` AS `C_CUSTKEY`, - `bfcol_24` AS `O_ORDERKEY`, - `bfcol_27` AS `O_ORDERDAT`, - `bfcol_26` AS `O_TOTALPRICE`, - `bfcol_35` AS `COL6` -FROM `bfcte_10` -ORDER BY - `bfcol_26` DESC, - `bfcol_27` ASC NULLS LAST, - `bfcol_1` ASC NULLS LAST, - `bfcol_0` ASC NULLS LAST, - `bfcol_24` ASC NULLS LAST -LIMIT 100 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/19/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/19/out.sql deleted file mode 100644 index e7b817ecfd9..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/19/out.sql +++ /dev/null @@ -1,226 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(0)]) -), `bfcte_1` AS ( - SELECT - `L_PARTKEY` AS `bfcol_1`, - `L_QUANTITY` AS `bfcol_2`, - `L_EXTENDEDPRICE` AS `bfcol_3`, - `L_DISCOUNT` AS `bfcol_4`, - `L_SHIPINSTRUCT` AS `bfcol_5`, - `L_SHIPMODE` AS `bfcol_6` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_2` AS ( - SELECT - `P_PARTKEY` AS `bfcol_7`, - `P_BRAND` AS `bfcol_8`, - `P_SIZE` AS `bfcol_9`, - `P_CONTAINER` AS `bfcol_10` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_3` AS ( - SELECT - `bfcol_7`, - `bfcol_8`, - `bfcol_9`, - `bfcol_10`, - `bfcol_1`, - `bfcol_2`, - `bfcol_3`, - `bfcol_4`, - `bfcol_5`, - `bfcol_6`, - `bfcol_3` AS `bfcol_19`, - `bfcol_4` AS `bfcol_20`, - ( - COALESCE(COALESCE(`bfcol_6` IN ('AIR', 'AIR REG'), FALSE), FALSE) - AND ( - `bfcol_5` = 'DELIVER IN PERSON' - ) - ) - AND ( - ( - ( - ( - ( - ( - `bfcol_8` = 'Brand#12' - ) - AND COALESCE(COALESCE(`bfcol_10` IN ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG'), FALSE), FALSE) - ) - AND ( - ( - `bfcol_2` >= 1 - ) AND ( - `bfcol_2` <= 11 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 5 - ) - ) - ) - OR ( - ( - ( - ( - `bfcol_8` = 'Brand#23' - ) - AND COALESCE( - COALESCE(`bfcol_10` IN ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK'), FALSE), - FALSE - ) - ) - AND ( - ( - `bfcol_2` >= 10 - ) AND ( - `bfcol_2` <= 20 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 10 - ) - ) - ) - ) - OR ( - ( - ( - ( - `bfcol_8` = 'Brand#34' - ) - AND COALESCE(COALESCE(`bfcol_10` IN ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG'), FALSE), FALSE) - ) - AND ( - ( - `bfcol_2` >= 20 - ) AND ( - `bfcol_2` <= 30 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 15 - ) - ) - ) - ) AS `bfcol_21`, - `bfcol_3` AS `bfcol_27`, - 1 - `bfcol_4` AS `bfcol_28`, - `bfcol_3` * ( - 1 - `bfcol_4` - ) AS `bfcol_31` - FROM `bfcte_2` - INNER JOIN `bfcte_1` - ON `bfcol_7` = `bfcol_1` - WHERE - ( - COALESCE(COALESCE(`bfcol_6` IN ('AIR', 'AIR REG'), FALSE), FALSE) - AND ( - `bfcol_5` = 'DELIVER IN PERSON' - ) - ) - AND ( - ( - ( - ( - ( - ( - `bfcol_8` = 'Brand#12' - ) - AND COALESCE(COALESCE(`bfcol_10` IN ('SM CASE', 'SM BOX', 'SM PACK', 'SM PKG'), FALSE), FALSE) - ) - AND ( - ( - `bfcol_2` >= 1 - ) AND ( - `bfcol_2` <= 11 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 5 - ) - ) - ) - OR ( - ( - ( - ( - `bfcol_8` = 'Brand#23' - ) - AND COALESCE( - COALESCE(`bfcol_10` IN ('MED BAG', 'MED BOX', 'MED PKG', 'MED PACK'), FALSE), - FALSE - ) - ) - AND ( - ( - `bfcol_2` >= 10 - ) AND ( - `bfcol_2` <= 20 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 10 - ) - ) - ) - ) - OR ( - ( - ( - ( - `bfcol_8` = 'Brand#34' - ) - AND COALESCE(COALESCE(`bfcol_10` IN ('LG CASE', 'LG BOX', 'LG PACK', 'LG PKG'), FALSE), FALSE) - ) - AND ( - ( - `bfcol_2` >= 20 - ) AND ( - `bfcol_2` <= 30 - ) - ) - ) - AND ( - ( - `bfcol_9` >= 1 - ) AND ( - `bfcol_9` <= 15 - ) - ) - ) - ) -), `bfcte_4` AS ( - SELECT - COALESCE(SUM(`bfcol_31`), 0) AS `bfcol_33` - FROM `bfcte_3` -), `bfcte_5` AS ( - SELECT - * - FROM `bfcte_4` -) -SELECT - CASE WHEN `bfcol_0` = 0 THEN `bfcol_33` END AS `REVENUE` -FROM `bfcte_5` -CROSS JOIN `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/2/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/2/out.sql deleted file mode 100644 index ae7be6a71da..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/2/out.sql +++ /dev/null @@ -1,197 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `R_REGIONKEY` AS `bfcol_0`, - `R_NAME` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`REGION` AS `bft_4` -), `bfcte_1` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_2`, - `N_NAME` AS `bfcol_3`, - `N_REGIONKEY` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_3` -), `bfcte_2` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_19`, - `N_REGIONKEY` AS `bfcol_20` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_3` -), `bfcte_3` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_5`, - `S_NAME` AS `bfcol_6`, - `S_ADDRESS` AS `bfcol_7`, - `S_NATIONKEY` AS `bfcol_8`, - `S_PHONE` AS `bfcol_9`, - `S_ACCTBAL` AS `bfcol_10`, - `S_COMMENT` AS `bfcol_11` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_2` -), `bfcte_4` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_21`, - `S_NATIONKEY` AS `bfcol_22` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_2` -), `bfcte_5` AS ( - SELECT - `PS_PARTKEY` AS `bfcol_12`, - `PS_SUPPKEY` AS `bfcol_13`, - `PS_SUPPLYCOST` AS `bfcol_14` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_1` -), `bfcte_6` AS ( - SELECT - `P_PARTKEY` AS `bfcol_15`, - `P_MFGR` AS `bfcol_16`, - `P_TYPE` AS `bfcol_17`, - `P_SIZE` AS `bfcol_18` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_7` AS ( - SELECT - `P_PARTKEY` AS `bfcol_23`, - `P_TYPE` AS `bfcol_24`, - `P_SIZE` AS `bfcol_25` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_8` AS ( - SELECT - `bfcol_15` AS `bfcol_26`, - `bfcol_16` AS `bfcol_27`, - `bfcol_17` AS `bfcol_28`, - `bfcol_18` AS `bfcol_29`, - `bfcol_13` AS `bfcol_30`, - `bfcol_14` AS `bfcol_31` - FROM `bfcte_6` - INNER JOIN `bfcte_5` - ON `bfcol_15` = `bfcol_12` -), `bfcte_9` AS ( - SELECT - `bfcol_23` AS `bfcol_32`, - `bfcol_24` AS `bfcol_33`, - `bfcol_25` AS `bfcol_34`, - `bfcol_13` AS `bfcol_35`, - `bfcol_14` AS `bfcol_36` - FROM `bfcte_7` - INNER JOIN `bfcte_5` - ON `bfcol_23` = `bfcol_12` -), `bfcte_10` AS ( - SELECT - `bfcol_26` AS `bfcol_37`, - `bfcol_27` AS `bfcol_38`, - `bfcol_28` AS `bfcol_39`, - `bfcol_29` AS `bfcol_40`, - `bfcol_31` AS `bfcol_41`, - `bfcol_6` AS `bfcol_42`, - `bfcol_7` AS `bfcol_43`, - `bfcol_8` AS `bfcol_44`, - `bfcol_9` AS `bfcol_45`, - `bfcol_10` AS `bfcol_46`, - `bfcol_11` AS `bfcol_47` - FROM `bfcte_8` - INNER JOIN `bfcte_3` - ON `bfcol_30` = `bfcol_5` -), `bfcte_11` AS ( - SELECT - `bfcol_32` AS `bfcol_48`, - `bfcol_33` AS `bfcol_49`, - `bfcol_34` AS `bfcol_50`, - `bfcol_36` AS `bfcol_51`, - `bfcol_22` AS `bfcol_52` - FROM `bfcte_9` - INNER JOIN `bfcte_4` - ON `bfcol_35` = `bfcol_21` -), `bfcte_12` AS ( - SELECT - `bfcol_37` AS `bfcol_53`, - `bfcol_38` AS `bfcol_54`, - `bfcol_39` AS `bfcol_55`, - `bfcol_40` AS `bfcol_56`, - `bfcol_41` AS `bfcol_57`, - `bfcol_42` AS `bfcol_58`, - `bfcol_43` AS `bfcol_59`, - `bfcol_45` AS `bfcol_60`, - `bfcol_46` AS `bfcol_61`, - `bfcol_47` AS `bfcol_62`, - `bfcol_3` AS `bfcol_63`, - `bfcol_4` AS `bfcol_64` - FROM `bfcte_10` - INNER JOIN `bfcte_1` - ON `bfcol_44` = `bfcol_2` -), `bfcte_13` AS ( - SELECT - `bfcol_48` AS `bfcol_65`, - `bfcol_49` AS `bfcol_66`, - `bfcol_50` AS `bfcol_67`, - `bfcol_51` AS `bfcol_68`, - `bfcol_20` AS `bfcol_69` - FROM `bfcte_11` - INNER JOIN `bfcte_2` - ON `bfcol_52` = `bfcol_19` -), `bfcte_14` AS ( - SELECT - `bfcol_53` AS `bfcol_205`, - `bfcol_54` AS `bfcol_206`, - `bfcol_57` AS `bfcol_207`, - `bfcol_58` AS `bfcol_208`, - `bfcol_59` AS `bfcol_209`, - `bfcol_60` AS `bfcol_210`, - `bfcol_61` AS `bfcol_211`, - `bfcol_62` AS `bfcol_212`, - `bfcol_63` AS `bfcol_213` - FROM `bfcte_12` - INNER JOIN `bfcte_0` - ON `bfcol_64` = `bfcol_0` - WHERE - `bfcol_56` = 15 AND ENDS_WITH(`bfcol_55`, 'BRASS') AND `bfcol_1` = 'EUROPE' -), `bfcte_15` AS ( - SELECT - `bfcol_65`, - `bfcol_66`, - `bfcol_67`, - `bfcol_68`, - `bfcol_69`, - `bfcol_0`, - `bfcol_1`, - `bfcol_65` AS `bfcol_99`, - `bfcol_66` AS `bfcol_100`, - `bfcol_68` AS `bfcol_101`, - `bfcol_1` AS `bfcol_102`, - `bfcol_67` = 15 AS `bfcol_103`, - `bfcol_65` AS `bfcol_147`, - `bfcol_68` AS `bfcol_148`, - `bfcol_1` AS `bfcol_149`, - ENDS_WITH(`bfcol_66`, 'BRASS') AS `bfcol_150`, - `bfcol_65` AS `bfcol_189`, - `bfcol_68` AS `bfcol_190`, - `bfcol_1` = 'EUROPE' AS `bfcol_191` - FROM `bfcte_13` - INNER JOIN `bfcte_0` - ON `bfcol_69` = `bfcol_0` - WHERE - `bfcol_67` = 15 AND ENDS_WITH(`bfcol_66`, 'BRASS') AND `bfcol_1` = 'EUROPE' -), `bfcte_16` AS ( - SELECT - `bfcol_189`, - MIN(`bfcol_190`) AS `bfcol_216` - FROM `bfcte_15` - GROUP BY - `bfcol_189` -), `bfcte_17` AS ( - SELECT - `bfcol_189` AS `bfcol_214`, - `bfcol_216` - FROM `bfcte_16` -) -SELECT - `bfcol_211` AS `S_ACCTBAL`, - `bfcol_208` AS `S_NAME`, - `bfcol_213` AS `N_NAME`, - `bfcol_214` AS `P_PARTKEY`, - `bfcol_206` AS `P_MFGR`, - `bfcol_209` AS `S_ADDRESS`, - `bfcol_210` AS `S_PHONE`, - `bfcol_212` AS `S_COMMENT` -FROM `bfcte_17` -INNER JOIN `bfcte_14` - ON `bfcol_214` = `bfcol_205` AND `bfcol_216` = `bfcol_207` -ORDER BY - `bfcol_211` DESC, - `bfcol_213` ASC NULLS LAST, - `bfcol_208` ASC NULLS LAST, - `bfcol_214` ASC NULLS LAST -LIMIT 100 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/20/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/20/out.sql deleted file mode 100644 index 197588f5c84..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/20/out.sql +++ /dev/null @@ -1,144 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `P_PARTKEY`, - `P_NAME`, - `P_PARTKEY` AS `bfcol_15`, - STARTS_WITH(`P_NAME`, 'forest') AS `bfcol_16` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_4` - WHERE - STARTS_WITH(`P_NAME`, 'forest') -), `bfcte_1` AS ( - SELECT - `PS_PARTKEY` AS `bfcol_2`, - `PS_SUPPKEY` AS `bfcol_3`, - `PS_AVAILQTY` AS `bfcol_4` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_3` -), `bfcte_2` AS ( - SELECT - `L_PARTKEY`, - `L_SUPPKEY`, - `L_QUANTITY`, - `L_SHIPDATE`, - `L_PARTKEY` AS `bfcol_17`, - `L_SUPPKEY` AS `bfcol_18`, - `L_QUANTITY` AS `bfcol_19`, - ( - `L_SHIPDATE` >= CAST('1994-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1995-01-01' AS DATE) - ) AS `bfcol_20` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_2` - WHERE - ( - `L_SHIPDATE` >= CAST('1994-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1995-01-01' AS DATE) - ) -), `bfcte_3` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_35` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_1` - WHERE - `N_NAME` = 'CANADA' -), `bfcte_4` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_11`, - `S_NAME` AS `bfcol_12`, - `S_ADDRESS` AS `bfcol_13`, - `S_NATIONKEY` AS `bfcol_14` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_0` -), `bfcte_5` AS ( - SELECT - `bfcol_15` - FROM `bfcte_0` - GROUP BY - `bfcol_15` -), `bfcte_6` AS ( - SELECT - `bfcol_17`, - `bfcol_18`, - COALESCE(SUM(`bfcol_19`), 0) AS `bfcol_36` - FROM `bfcte_2` - GROUP BY - `bfcol_17`, - `bfcol_18` -), `bfcte_7` AS ( - SELECT - `bfcol_11` AS `bfcol_41`, - `bfcol_12` AS `bfcol_42`, - `bfcol_13` AS `bfcol_43` - FROM `bfcte_4` - INNER JOIN `bfcte_3` - ON `bfcol_14` = `bfcol_35` -), `bfcte_8` AS ( - SELECT - `bfcol_15` AS `bfcol_31` - FROM `bfcte_5` -), `bfcte_9` AS ( - SELECT - `bfcol_17` AS `bfcol_48`, - `bfcol_18` AS `bfcol_49`, - `bfcol_36` * 0.5 AS `bfcol_50` - FROM `bfcte_6` -), `bfcte_10` AS ( - SELECT - *, - COALESCE(`bfcol_2` IN (( - SELECT - * - FROM `bfcte_8` - )), FALSE) AS `bfcol_37` - FROM `bfcte_1` -), `bfcte_11` AS ( - SELECT - `bfcol_2` AS `bfcol_51`, - `bfcol_3` AS `bfcol_52`, - `bfcol_4` AS `bfcol_53` - FROM `bfcte_10` - WHERE - `bfcol_37` -), `bfcte_12` AS ( - SELECT - `bfcol_48`, - `bfcol_49`, - `bfcol_50`, - `bfcol_51`, - `bfcol_52`, - `bfcol_53`, - `bfcol_52` AS `bfcol_57`, - `bfcol_53` > `bfcol_50` AS `bfcol_58` - FROM `bfcte_9` - INNER JOIN `bfcte_11` - ON `bfcol_49` = `bfcol_52` AND `bfcol_48` = `bfcol_51` - WHERE - `bfcol_53` > `bfcol_50` -), `bfcte_13` AS ( - SELECT - `bfcol_57` - FROM `bfcte_12` - GROUP BY - `bfcol_57` -), `bfcte_14` AS ( - SELECT - `bfcol_57` AS `bfcol_61` - FROM `bfcte_13` -), `bfcte_15` AS ( - SELECT - *, - COALESCE(`bfcol_41` IN (( - SELECT - * - FROM `bfcte_14` - )), FALSE) AS `bfcol_62` - FROM `bfcte_7` -) -SELECT - `bfcol_42` AS `S_NAME`, - `bfcol_43` AS `S_ADDRESS` -FROM `bfcte_15` -WHERE - `bfcol_62` -ORDER BY - `bfcol_42` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/21/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/21/out.sql deleted file mode 100644 index 0caf29ca617..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/21/out.sql +++ /dev/null @@ -1,142 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_0`, - `O_ORDERSTATUS` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_3` -), `bfcte_1` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_2`, - `N_NAME` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_2` -), `bfcte_2` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_4`, - `S_NAME` AS `bfcol_5`, - `S_NATIONKEY` AS `bfcol_6` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_1` -), `bfcte_3` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_30`, - `L_SUPPKEY` AS `bfcol_31` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` - WHERE - `L_RECEIPTDATE` > `L_COMMITDATE` -), `bfcte_4` AS ( - SELECT - `L_ORDERKEY` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` -), `bfcte_5` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_32` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` - WHERE - `L_RECEIPTDATE` > `L_COMMITDATE` -), `bfcte_6` AS ( - SELECT - `L_ORDERKEY`, - COUNT(1) AS `bfcol_18` - FROM `bfcte_4` - GROUP BY - `L_ORDERKEY` -), `bfcte_7` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_33` - FROM `bfcte_6` - WHERE - `bfcol_18` > 1 -), `bfcte_8` AS ( - SELECT - `bfcol_33` AS `bfcol_34`, - `bfcol_31` AS `bfcol_35` - FROM `bfcte_7` - INNER JOIN `bfcte_3` - ON `bfcol_33` = `bfcol_30` -), `bfcte_9` AS ( - SELECT - `bfcol_33`, - COUNT(1) AS `bfcol_37` - FROM `bfcte_7` - INNER JOIN `bfcte_5` - ON `bfcol_33` = `bfcol_32` - GROUP BY - `bfcol_33` -), `bfcte_10` AS ( - SELECT - `bfcol_33` AS `bfcol_36`, - `bfcol_37` - FROM `bfcte_9` -), `bfcte_11` AS ( - SELECT - `bfcol_36` AS `bfcol_38`, - `bfcol_37` AS `bfcol_39`, - `bfcol_35` AS `bfcol_40` - FROM `bfcte_10` - INNER JOIN `bfcte_8` - ON `bfcol_36` = `bfcol_34` -), `bfcte_12` AS ( - SELECT - `bfcol_38` AS `bfcol_41`, - `bfcol_39` AS `bfcol_42`, - `bfcol_5` AS `bfcol_43`, - `bfcol_6` AS `bfcol_44` - FROM `bfcte_11` - INNER JOIN `bfcte_2` - ON `bfcol_40` = `bfcol_4` -), `bfcte_13` AS ( - SELECT - `bfcol_41` AS `bfcol_45`, - `bfcol_42` AS `bfcol_46`, - `bfcol_43` AS `bfcol_47`, - `bfcol_3` AS `bfcol_48` - FROM `bfcte_12` - INNER JOIN `bfcte_1` - ON `bfcol_44` = `bfcol_2` -), `bfcte_14` AS ( - SELECT - `bfcol_45`, - `bfcol_46`, - `bfcol_47`, - `bfcol_48`, - `bfcol_0`, - `bfcol_1`, - `bfcol_47` AS `bfcol_53`, - ( - ( - `bfcol_46` = 1 - ) AND ( - `bfcol_48` = 'SAUDI ARABIA' - ) - ) - AND ( - `bfcol_1` = 'F' - ) AS `bfcol_54` - FROM `bfcte_13` - INNER JOIN `bfcte_0` - ON `bfcol_45` = `bfcol_0` - WHERE - ( - ( - `bfcol_46` = 1 - ) AND ( - `bfcol_48` = 'SAUDI ARABIA' - ) - ) - AND ( - `bfcol_1` = 'F' - ) -), `bfcte_15` AS ( - SELECT - `bfcol_53`, - COUNT(1) AS `bfcol_58` - FROM `bfcte_14` - GROUP BY - `bfcol_53` -) -SELECT - `bfcol_53` AS `S_NAME`, - `bfcol_58` AS `NUMWAIT` -FROM `bfcte_15` -ORDER BY - `bfcol_58` DESC, - `bfcol_53` ASC NULLS LAST -LIMIT 100 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/22/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/22/out.sql deleted file mode 100644 index 5ab22d3cdaf..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/22/out.sql +++ /dev/null @@ -1,132 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT('C_ACCTBAL', 0, 0)]) -), `bfcte_1` AS ( - SELECT - `O_CUSTKEY` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` -), `bfcte_2` AS ( - SELECT - `C_PHONE`, - `C_ACCTBAL`, - `C_ACCTBAL` AS `bfcol_9`, - SUBSTRING(`C_PHONE`, 1, 2) AS `bfcol_10`, - `C_ACCTBAL` AS `bfcol_19`, - COALESCE( - COALESCE(SUBSTRING(`C_PHONE`, 1, 2) IN ('13', '31', '23', '29', '30', '18', '17'), FALSE), - FALSE - ) AS `bfcol_20`, - `C_ACCTBAL` AS `bfcol_35`, - `C_ACCTBAL` > 0.0 AS `bfcol_36` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` - WHERE - COALESCE( - COALESCE(SUBSTRING(`C_PHONE`, 1, 2) IN ('13', '31', '23', '29', '30', '18', '17'), FALSE), - FALSE - ) - AND `C_ACCTBAL` > 0.0 -), `bfcte_3` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_32`, - `C_ACCTBAL` AS `bfcol_33`, - SUBSTRING(`C_PHONE`, 1, 2) AS `bfcol_34` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` - WHERE - COALESCE( - COALESCE(SUBSTRING(`C_PHONE`, 1, 2) IN ('13', '31', '23', '29', '30', '18', '17'), FALSE), - FALSE - ) -), `bfcte_4` AS ( - SELECT - `O_CUSTKEY` - FROM `bfcte_1` - GROUP BY - `O_CUSTKEY` -), `bfcte_5` AS ( - SELECT - AVG(`bfcol_35`) AS `bfcol_40` - FROM `bfcte_2` -), `bfcte_6` AS ( - SELECT - `O_CUSTKEY` AS `bfcol_0` - FROM `bfcte_4` -), `bfcte_7` AS ( - SELECT - `bfcol_40`, - 0 AS `bfcol_41` - FROM `bfcte_5` -), `bfcte_8` AS ( - SELECT - `bfcol_3`, - `bfcol_4`, - `bfcol_5`, - `bfcol_40`, - `bfcol_41`, - CASE WHEN `bfcol_5` = 0 THEN `bfcol_40` END AS `bfcol_42`, - IF(`bfcol_41` = 0, CASE WHEN `bfcol_5` = 0 THEN `bfcol_40` END, NULL) AS `bfcol_47` - FROM `bfcte_0` - CROSS JOIN `bfcte_7` -), `bfcte_9` AS ( - SELECT - `bfcol_3`, - `bfcol_4`, - ANY_VALUE(`bfcol_47`) AS `bfcol_51` - FROM `bfcte_8` - GROUP BY - `bfcol_3`, - `bfcol_4` -), `bfcte_10` AS ( - SELECT - `bfcol_51` AS `bfcol_52` - FROM `bfcte_9` -), `bfcte_11` AS ( - SELECT - `bfcol_32` AS `bfcol_61`, - `bfcol_33` AS `bfcol_62`, - `bfcol_34` AS `bfcol_63` - FROM `bfcte_3` - CROSS JOIN `bfcte_10` - WHERE - `bfcol_33` > `bfcol_52` -), `bfcte_12` AS ( - SELECT - *, - COALESCE(`bfcol_61` IN (( - SELECT - * - FROM `bfcte_6` - )), FALSE) AS `bfcol_64` - FROM `bfcte_11` -), `bfcte_13` AS ( - SELECT - `bfcol_61`, - `bfcol_62`, - `bfcol_63`, - `bfcol_64`, - NOT ( - `bfcol_64` - ) AS `bfcol_65` - FROM `bfcte_12` - WHERE - NOT ( - `bfcol_64` - ) -), `bfcte_14` AS ( - SELECT - `bfcol_63`, - COUNT(`bfcol_61`) AS `bfcol_73`, - COALESCE(SUM(`bfcol_62`), 0) AS `bfcol_74` - FROM `bfcte_13` - WHERE - NOT `bfcol_63` IS NULL - GROUP BY - `bfcol_63` -) -SELECT - `bfcol_63` AS `CNTRYCODE`, - `bfcol_73` AS `NUMCUST`, - `bfcol_74` AS `TOTACCTBAL` -FROM `bfcte_14` -ORDER BY - `bfcol_63` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/3/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/3/out.sql deleted file mode 100644 index 71779e1adf9..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/3/out.sql +++ /dev/null @@ -1,76 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_32`, - `O_CUSTKEY` AS `bfcol_33`, - `O_ORDERDATE` AS `bfcol_34`, - `O_SHIPPRIORITY` AS `bfcol_35` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_2` - WHERE - `O_ORDERDATE` < CAST('1995-03-15' AS DATE) -), `bfcte_1` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_36`, - `L_EXTENDEDPRICE` AS `bfcol_37`, - `L_DISCOUNT` AS `bfcol_38` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` - WHERE - `L_SHIPDATE` > CAST('1995-03-15' AS DATE) -), `bfcte_2` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_39` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` - WHERE - `C_MKTSEGMENT` = 'BUILDING' -), `bfcte_3` AS ( - SELECT - `bfcol_37` AS `bfcol_40`, - `bfcol_38` AS `bfcol_41`, - `bfcol_32` AS `bfcol_42`, - `bfcol_33` AS `bfcol_43`, - `bfcol_34` AS `bfcol_44`, - `bfcol_35` AS `bfcol_45` - FROM `bfcte_1` - INNER JOIN `bfcte_0` - ON `bfcol_36` = `bfcol_32` -), `bfcte_4` AS ( - SELECT - `bfcol_39`, - `bfcol_40`, - `bfcol_41`, - `bfcol_42`, - `bfcol_43`, - `bfcol_44`, - `bfcol_45`, - `bfcol_42` AS `bfcol_51`, - `bfcol_44` AS `bfcol_52`, - `bfcol_45` AS `bfcol_53`, - `bfcol_40` * ( - 1 - `bfcol_41` - ) AS `bfcol_54` - FROM `bfcte_2` - INNER JOIN `bfcte_3` - ON `bfcol_39` = `bfcol_43` -), `bfcte_5` AS ( - SELECT - `bfcol_51`, - `bfcol_52`, - `bfcol_53`, - COALESCE(SUM(`bfcol_54`), 0) AS `bfcol_59` - FROM `bfcte_4` - GROUP BY - `bfcol_51`, - `bfcol_52`, - `bfcol_53` -) -SELECT - `bfcol_51` AS `L_ORDERKEY`, - `bfcol_59` AS `REVENUE`, - `bfcol_52` AS `O_ORDERDATE`, - `bfcol_53` AS `O_SHIPPRIORITY` -FROM `bfcte_5` -ORDER BY - `bfcol_59` DESC, - `bfcol_52` ASC NULLS LAST, - `bfcol_51` ASC NULLS LAST, - `bfcol_53` ASC NULLS LAST -LIMIT 10 \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/4/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/4/out.sql deleted file mode 100644 index 3235239710e..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/4/out.sql +++ /dev/null @@ -1,67 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_0`, - `O_ORDERDATE` AS `bfcol_1`, - `O_ORDERPRIORITY` AS `bfcol_2` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` -), `bfcte_1` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_3`, - `L_COMMITDATE` AS `bfcol_4`, - `L_RECEIPTDATE` AS `bfcol_5` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` -), `bfcte_2` AS ( - SELECT - `bfcol_3`, - `bfcol_4`, - `bfcol_5`, - `bfcol_0`, - `bfcol_1`, - `bfcol_2`, - `bfcol_3` AS `bfcol_11`, - `bfcol_4` AS `bfcol_12`, - `bfcol_5` AS `bfcol_13`, - `bfcol_2` AS `bfcol_14`, - ( - `bfcol_1` >= CAST('1993-07-01' AS DATE) - ) - AND ( - `bfcol_1` < CAST('1993-10-01' AS DATE) - ) AS `bfcol_15`, - `bfcol_3` AS `bfcol_25`, - `bfcol_2` AS `bfcol_26`, - `bfcol_4` < `bfcol_5` AS `bfcol_27` - FROM `bfcte_1` - INNER JOIN `bfcte_0` - ON `bfcol_3` = `bfcol_0` - WHERE - ( - `bfcol_1` >= CAST('1993-07-01' AS DATE) - ) - AND ( - `bfcol_1` < CAST('1993-10-01' AS DATE) - ) - AND `bfcol_4` < `bfcol_5` -), `bfcte_3` AS ( - SELECT - `bfcol_26`, - `bfcol_25`, - COUNT(1) AS `bfcol_33` - FROM `bfcte_2` - GROUP BY - `bfcol_26`, - `bfcol_25` -), `bfcte_4` AS ( - SELECT - `bfcol_26`, - COUNT(`bfcol_25`) AS `bfcol_36` - FROM `bfcte_3` - GROUP BY - `bfcol_26` -) -SELECT - `bfcol_26` AS `O_ORDERPRIORITY`, - `bfcol_36` AS `ORDER_COUNT` -FROM `bfcte_4` -ORDER BY - `bfcol_26` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/5/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/5/out.sql deleted file mode 100644 index 5b707ce59ac..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/5/out.sql +++ /dev/null @@ -1,91 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_0`, - `S_NATIONKEY` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_5` -), `bfcte_1` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_2`, - `C_NATIONKEY` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_4` -), `bfcte_2` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_4`, - `N_NAME` AS `bfcol_5`, - `N_REGIONKEY` AS `bfcol_6` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_3` -), `bfcte_3` AS ( - SELECT - `R_REGIONKEY` AS `bfcol_32` - FROM `bigframes-dev-perf`.`tpch_0001t`.`REGION` AS `bft_2` - WHERE - `R_NAME` = 'ASIA' -), `bfcte_4` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_33`, - `O_CUSTKEY` AS `bfcol_34` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` - WHERE - ( - `O_ORDERDATE` >= CAST('1994-01-01' AS DATE) - ) - AND ( - `O_ORDERDATE` < CAST('1995-01-01' AS DATE) - ) -), `bfcte_5` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_29`, - `L_SUPPKEY` AS `bfcol_30`, - `L_EXTENDEDPRICE` * ( - 1.0 - `L_DISCOUNT` - ) AS `bfcol_31` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` -), `bfcte_6` AS ( - SELECT - `bfcol_4` AS `bfcol_35`, - `bfcol_5` AS `bfcol_36` - FROM `bfcte_3` - INNER JOIN `bfcte_2` - ON `bfcol_32` = `bfcol_6` -), `bfcte_7` AS ( - SELECT - `bfcol_35` AS `bfcol_37`, - `bfcol_36` AS `bfcol_38`, - `bfcol_2` AS `bfcol_39` - FROM `bfcte_6` - INNER JOIN `bfcte_1` - ON `bfcol_35` = `bfcol_3` -), `bfcte_8` AS ( - SELECT - `bfcol_33` AS `bfcol_40`, - `bfcol_37` AS `bfcol_41`, - `bfcol_38` AS `bfcol_42` - FROM `bfcte_4` - INNER JOIN `bfcte_7` - ON `bfcol_34` = `bfcol_39` -), `bfcte_9` AS ( - SELECT - `bfcol_30` AS `bfcol_43`, - `bfcol_31` AS `bfcol_44`, - `bfcol_41` AS `bfcol_45`, - `bfcol_42` AS `bfcol_46` - FROM `bfcte_5` - INNER JOIN `bfcte_8` - ON `bfcol_29` = `bfcol_40` -), `bfcte_10` AS ( - SELECT - `bfcol_46`, - COALESCE(SUM(`bfcol_44`), 0) AS `bfcol_49` - FROM `bfcte_9` - INNER JOIN `bfcte_0` - ON `bfcol_43` = `bfcol_0` AND `bfcol_45` = `bfcol_1` - GROUP BY - `bfcol_46` -) -SELECT - `bfcol_46` AS `N_NAME`, - `bfcol_49` AS `REVENUE` -FROM `bfcte_10` -ORDER BY - `bfcol_49` DESC, - `bfcol_46` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/6/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/6/out.sql deleted file mode 100644 index 3544fd18e48..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/6/out.sql +++ /dev/null @@ -1,61 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - * - FROM UNNEST(ARRAY>[STRUCT(0)]) -), `bfcte_1` AS ( - SELECT - `L_QUANTITY`, - `L_EXTENDEDPRICE`, - `L_DISCOUNT`, - `L_SHIPDATE`, - `L_QUANTITY` AS `bfcol_5`, - `L_EXTENDEDPRICE` AS `bfcol_6`, - `L_DISCOUNT` AS `bfcol_7`, - ( - `L_SHIPDATE` >= CAST('1994-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1995-01-01' AS DATE) - ) AS `bfcol_8`, - `L_QUANTITY` AS `bfcol_16`, - `L_EXTENDEDPRICE` AS `bfcol_17`, - `L_DISCOUNT` AS `bfcol_18`, - ( - `L_DISCOUNT` >= 0.05 - ) AND ( - `L_DISCOUNT` <= 0.07 - ) AS `bfcol_19`, - `L_EXTENDEDPRICE` AS `bfcol_27`, - `L_DISCOUNT` AS `bfcol_28`, - `L_QUANTITY` < 24 AS `bfcol_29`, - `L_EXTENDEDPRICE` AS `bfcol_35`, - `L_DISCOUNT` AS `bfcol_36`, - `L_EXTENDEDPRICE` * `L_DISCOUNT` AS `bfcol_39` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_0` - WHERE - ( - `L_SHIPDATE` >= CAST('1994-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` < CAST('1995-01-01' AS DATE) - ) - AND ( - `L_DISCOUNT` >= 0.05 - ) - AND ( - `L_DISCOUNT` <= 0.07 - ) - AND `L_QUANTITY` < 24 -), `bfcte_2` AS ( - SELECT - COALESCE(SUM(`bfcol_39`), 0) AS `bfcol_41` - FROM `bfcte_1` -), `bfcte_3` AS ( - SELECT - * - FROM `bfcte_2` -) -SELECT - CASE WHEN `bfcol_0` = 0 THEN `bfcol_41` END AS `REVENUE` -FROM `bfcte_3` -CROSS JOIN `bfcte_0` \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/7/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/7/out.sql deleted file mode 100644 index f180ca1b3ea..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/7/out.sql +++ /dev/null @@ -1,138 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_22`, - `N_NAME` AS `bfcol_23`, - COALESCE(COALESCE(`N_NAME` IN ('FRANCE', 'GERMANY'), FALSE), FALSE) AS `bfcol_24` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_4` - WHERE - COALESCE(COALESCE(`N_NAME` IN ('FRANCE', 'GERMANY'), FALSE), FALSE) -), `bfcte_1` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_2`, - `S_NATIONKEY` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_3` -), `bfcte_2` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_31`, - `L_SUPPKEY` AS `bfcol_32`, - `L_EXTENDEDPRICE` AS `bfcol_33`, - `L_DISCOUNT` AS `bfcol_34`, - `L_SHIPDATE` AS `bfcol_35` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_2` - WHERE - ( - `L_SHIPDATE` >= CAST('1995-01-01' AS DATE) - ) - AND ( - `L_SHIPDATE` <= CAST('1996-12-31' AS DATE) - ) -), `bfcte_3` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_9`, - `O_CUSTKEY` AS `bfcol_10` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_1` -), `bfcte_4` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_11`, - `C_NATIONKEY` AS `bfcol_12` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_0` -), `bfcte_5` AS ( - SELECT - `bfcol_22` AS `bfcol_36`, - `bfcol_23` AS `bfcol_37` - FROM `bfcte_0` -), `bfcte_6` AS ( - SELECT - `bfcol_22` AS `bfcol_38`, - `bfcol_23` AS `bfcol_39` - FROM `bfcte_0` -), `bfcte_7` AS ( - SELECT - `bfcol_11` AS `bfcol_40`, - `bfcol_39` AS `bfcol_41` - FROM `bfcte_4` - INNER JOIN `bfcte_6` - ON `bfcol_12` = `bfcol_38` -), `bfcte_8` AS ( - SELECT - `bfcol_41` AS `bfcol_42`, - `bfcol_9` AS `bfcol_43` - FROM `bfcte_7` - INNER JOIN `bfcte_3` - ON `bfcol_40` = `bfcol_10` -), `bfcte_9` AS ( - SELECT - `bfcol_42` AS `bfcol_44`, - `bfcol_32` AS `bfcol_45`, - `bfcol_33` AS `bfcol_46`, - `bfcol_34` AS `bfcol_47`, - `bfcol_35` AS `bfcol_48` - FROM `bfcte_8` - INNER JOIN `bfcte_2` - ON `bfcol_43` = `bfcol_31` -), `bfcte_10` AS ( - SELECT - `bfcol_44` AS `bfcol_49`, - `bfcol_46` AS `bfcol_50`, - `bfcol_47` AS `bfcol_51`, - `bfcol_48` AS `bfcol_52`, - `bfcol_3` AS `bfcol_53` - FROM `bfcte_9` - INNER JOIN `bfcte_1` - ON `bfcol_45` = `bfcol_2` -), `bfcte_11` AS ( - SELECT - `bfcol_49`, - `bfcol_50`, - `bfcol_51`, - `bfcol_52`, - `bfcol_53`, - `bfcol_36`, - `bfcol_37`, - `bfcol_49` AS `bfcol_59`, - `bfcol_50` AS `bfcol_60`, - `bfcol_51` AS `bfcol_61`, - `bfcol_52` AS `bfcol_62`, - `bfcol_37` AS `bfcol_63`, - `bfcol_49` <> `bfcol_37` AS `bfcol_64`, - `bfcol_49` AS `bfcol_76`, - `bfcol_52` AS `bfcol_77`, - `bfcol_37` AS `bfcol_78`, - `bfcol_50` * ( - 1.0 - `bfcol_51` - ) AS `bfcol_79`, - `bfcol_49` AS `bfcol_84`, - `bfcol_37` AS `bfcol_85`, - `bfcol_50` * ( - 1.0 - `bfcol_51` - ) AS `bfcol_86`, - EXTRACT(YEAR FROM `bfcol_52`) AS `bfcol_87` - FROM `bfcte_10` - INNER JOIN `bfcte_5` - ON `bfcol_53` = `bfcol_36` - WHERE - `bfcol_49` <> `bfcol_37` -), `bfcte_12` AS ( - SELECT - `bfcol_85`, - `bfcol_84`, - `bfcol_87`, - COALESCE(SUM(`bfcol_86`), 0) AS `bfcol_92` - FROM `bfcte_11` - WHERE - NOT `bfcol_87` IS NULL - GROUP BY - `bfcol_85`, - `bfcol_84`, - `bfcol_87` -) -SELECT - `bfcol_85` AS `SUPP_NATION`, - `bfcol_84` AS `CUST_NATION`, - `bfcol_87` AS `L_YEAR`, - `bfcol_92` AS `REVENUE` -FROM `bfcte_12` -ORDER BY - `bfcol_85` ASC NULLS LAST, - `bfcol_84` ASC NULLS LAST, - `bfcol_87` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/8/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/8/out.sql deleted file mode 100644 index edaf51f18b8..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/8/out.sql +++ /dev/null @@ -1,186 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_0`, - `N_NAME` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_6` -), `bfcte_1` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_4`, - `N_REGIONKEY` AS `bfcol_5` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_6` -), `bfcte_2` AS ( - SELECT - `R_REGIONKEY` AS `bfcol_2`, - `R_NAME` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`REGION` AS `bft_5` -), `bfcte_3` AS ( - SELECT - `C_CUSTKEY` AS `bfcol_6`, - `C_NATIONKEY` AS `bfcol_7` - FROM `bigframes-dev-perf`.`tpch_0001t`.`CUSTOMER` AS `bft_4` -), `bfcte_4` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_8`, - `O_CUSTKEY` AS `bfcol_9`, - `O_ORDERDATE` AS `bfcol_10` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_3` -), `bfcte_5` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_11`, - `S_NATIONKEY` AS `bfcol_12` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_2` -), `bfcte_6` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_13`, - `L_PARTKEY` AS `bfcol_14`, - `L_SUPPKEY` AS `bfcol_15`, - `L_EXTENDEDPRICE` AS `bfcol_16`, - `L_DISCOUNT` AS `bfcol_17` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_7` AS ( - SELECT - `P_PARTKEY` AS `bfcol_18`, - `P_TYPE` AS `bfcol_19` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_8` AS ( - SELECT - `bfcol_19` AS `bfcol_20`, - `bfcol_13` AS `bfcol_21`, - `bfcol_15` AS `bfcol_22`, - `bfcol_16` AS `bfcol_23`, - `bfcol_17` AS `bfcol_24` - FROM `bfcte_7` - INNER JOIN `bfcte_6` - ON `bfcol_18` = `bfcol_14` -), `bfcte_9` AS ( - SELECT - `bfcol_20` AS `bfcol_25`, - `bfcol_21` AS `bfcol_26`, - `bfcol_23` AS `bfcol_27`, - `bfcol_24` AS `bfcol_28`, - `bfcol_12` AS `bfcol_29` - FROM `bfcte_8` - INNER JOIN `bfcte_5` - ON `bfcol_22` = `bfcol_11` -), `bfcte_10` AS ( - SELECT - `bfcol_25` AS `bfcol_30`, - `bfcol_27` AS `bfcol_31`, - `bfcol_28` AS `bfcol_32`, - `bfcol_29` AS `bfcol_33`, - `bfcol_9` AS `bfcol_34`, - `bfcol_10` AS `bfcol_35` - FROM `bfcte_9` - INNER JOIN `bfcte_4` - ON `bfcol_26` = `bfcol_8` -), `bfcte_11` AS ( - SELECT - `bfcol_30` AS `bfcol_36`, - `bfcol_31` AS `bfcol_37`, - `bfcol_32` AS `bfcol_38`, - `bfcol_33` AS `bfcol_39`, - `bfcol_35` AS `bfcol_40`, - `bfcol_7` AS `bfcol_41` - FROM `bfcte_10` - INNER JOIN `bfcte_3` - ON `bfcol_34` = `bfcol_6` -), `bfcte_12` AS ( - SELECT - `bfcol_36` AS `bfcol_42`, - `bfcol_37` AS `bfcol_43`, - `bfcol_38` AS `bfcol_44`, - `bfcol_39` AS `bfcol_45`, - `bfcol_40` AS `bfcol_46`, - `bfcol_5` AS `bfcol_47` - FROM `bfcte_11` - INNER JOIN `bfcte_1` - ON `bfcol_41` = `bfcol_4` -), `bfcte_13` AS ( - SELECT - `bfcol_42` AS `bfcol_66`, - `bfcol_43` AS `bfcol_67`, - `bfcol_44` AS `bfcol_68`, - `bfcol_45` AS `bfcol_69`, - `bfcol_46` AS `bfcol_70` - FROM `bfcte_12` - INNER JOIN `bfcte_2` - ON `bfcol_47` = `bfcol_2` - WHERE - `bfcol_3` = 'AMERICA' -), `bfcte_14` AS ( - SELECT - `bfcol_66`, - `bfcol_67`, - `bfcol_68`, - `bfcol_69`, - `bfcol_70`, - `bfcol_0`, - `bfcol_1`, - `bfcol_66` AS `bfcol_76`, - `bfcol_67` AS `bfcol_77`, - `bfcol_68` AS `bfcol_78`, - `bfcol_70` AS `bfcol_79`, - `bfcol_1` AS `bfcol_80`, - ( - `bfcol_70` >= CAST('1995-01-01' AS DATE) - ) - AND ( - `bfcol_70` <= CAST('1996-12-31' AS DATE) - ) AS `bfcol_81`, - `bfcol_67` AS `bfcol_93`, - `bfcol_68` AS `bfcol_94`, - `bfcol_70` AS `bfcol_95`, - `bfcol_1` AS `bfcol_96`, - `bfcol_66` = 'ECONOMY ANODIZED STEEL' AS `bfcol_97`, - `bfcol_67` AS `bfcol_107`, - `bfcol_68` AS `bfcol_108`, - `bfcol_1` AS `bfcol_109`, - EXTRACT(YEAR FROM `bfcol_70`) AS `bfcol_110`, - `bfcol_1` AS `bfcol_115`, - EXTRACT(YEAR FROM `bfcol_70`) AS `bfcol_116`, - `bfcol_67` * ( - 1.0 - `bfcol_68` - ) AS `bfcol_117`, - EXTRACT(YEAR FROM `bfcol_70`) AS `bfcol_121`, - `bfcol_67` * ( - 1.0 - `bfcol_68` - ) AS `bfcol_122`, - IF(`bfcol_1` = 'BRAZIL', `bfcol_67` * ( - 1.0 - `bfcol_68` - ), 0) AS `bfcol_123`, - EXTRACT(YEAR FROM `bfcol_70`) AS `bfcol_127`, - IF(`bfcol_1` = 'BRAZIL', `bfcol_67` * ( - 1.0 - `bfcol_68` - ), 0) AS `bfcol_128`, - `bfcol_67` * ( - 1.0 - `bfcol_68` - ) AS `bfcol_129` - FROM `bfcte_13` - INNER JOIN `bfcte_0` - ON `bfcol_69` = `bfcol_0` - WHERE - ( - `bfcol_70` >= CAST('1995-01-01' AS DATE) - ) - AND ( - `bfcol_70` <= CAST('1996-12-31' AS DATE) - ) - AND `bfcol_66` = 'ECONOMY ANODIZED STEEL' -), `bfcte_15` AS ( - SELECT - `bfcol_127`, - COALESCE(SUM(`bfcol_128`), 0) AS `bfcol_133`, - COALESCE(SUM(`bfcol_129`), 0) AS `bfcol_134` - FROM `bfcte_14` - WHERE - NOT `bfcol_127` IS NULL - GROUP BY - `bfcol_127` -) -SELECT - `bfcol_127` AS `O_YEAR`, - ROUND(IEEE_DIVIDE(`bfcol_133`, `bfcol_134`), 2) AS `MKT_SHARE` -FROM `bfcte_15` -ORDER BY - `bfcol_127` ASC NULLS LAST, - `bfcol_127` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/9/out.sql b/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/9/out.sql deleted file mode 100644 index 949d45e8dae..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/snapshots/test_tpch/test_tpch_query/9/out.sql +++ /dev/null @@ -1,143 +0,0 @@ -WITH `bfcte_0` AS ( - SELECT - `N_NATIONKEY` AS `bfcol_0`, - `N_NAME` AS `bfcol_1` - FROM `bigframes-dev-perf`.`tpch_0001t`.`NATION` AS `bft_5` -), `bfcte_1` AS ( - SELECT - `O_ORDERKEY` AS `bfcol_2`, - `O_ORDERDATE` AS `bfcol_3` - FROM `bigframes-dev-perf`.`tpch_0001t`.`ORDERS` AS `bft_4` -), `bfcte_2` AS ( - SELECT - `S_SUPPKEY` AS `bfcol_4`, - `S_NATIONKEY` AS `bfcol_5` - FROM `bigframes-dev-perf`.`tpch_0001t`.`SUPPLIER` AS `bft_3` -), `bfcte_3` AS ( - SELECT - `PS_PARTKEY` AS `bfcol_6`, - `PS_SUPPKEY` AS `bfcol_7`, - `PS_SUPPLYCOST` AS `bfcol_8` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PARTSUPP` AS `bft_2` -), `bfcte_4` AS ( - SELECT - `L_ORDERKEY` AS `bfcol_9`, - `L_PARTKEY` AS `bfcol_10`, - `L_SUPPKEY` AS `bfcol_11`, - `L_QUANTITY` AS `bfcol_12`, - `L_EXTENDEDPRICE` AS `bfcol_13`, - `L_DISCOUNT` AS `bfcol_14` - FROM `bigframes-dev-perf`.`tpch_0001t`.`LINEITEM` AS `bft_1` -), `bfcte_5` AS ( - SELECT - `P_PARTKEY` AS `bfcol_15`, - `P_NAME` AS `bfcol_16` - FROM `bigframes-dev-perf`.`tpch_0001t`.`PART` AS `bft_0` -), `bfcte_6` AS ( - SELECT - `bfcol_16` AS `bfcol_17`, - `bfcol_9` AS `bfcol_18`, - `bfcol_10` AS `bfcol_19`, - `bfcol_11` AS `bfcol_20`, - `bfcol_12` AS `bfcol_21`, - `bfcol_13` AS `bfcol_22`, - `bfcol_14` AS `bfcol_23` - FROM `bfcte_5` - INNER JOIN `bfcte_4` - ON `bfcol_15` = `bfcol_10` -), `bfcte_7` AS ( - SELECT - `bfcol_17` AS `bfcol_24`, - `bfcol_18` AS `bfcol_25`, - `bfcol_20` AS `bfcol_26`, - `bfcol_21` AS `bfcol_27`, - `bfcol_22` AS `bfcol_28`, - `bfcol_23` AS `bfcol_29`, - `bfcol_8` AS `bfcol_30` - FROM `bfcte_6` - INNER JOIN `bfcte_3` - ON `bfcol_20` = `bfcol_7` AND `bfcol_19` = `bfcol_6` -), `bfcte_8` AS ( - SELECT - `bfcol_24` AS `bfcol_31`, - `bfcol_25` AS `bfcol_32`, - `bfcol_27` AS `bfcol_33`, - `bfcol_28` AS `bfcol_34`, - `bfcol_29` AS `bfcol_35`, - `bfcol_30` AS `bfcol_36`, - `bfcol_5` AS `bfcol_37` - FROM `bfcte_7` - INNER JOIN `bfcte_2` - ON `bfcol_26` = `bfcol_4` -), `bfcte_9` AS ( - SELECT - `bfcol_31` AS `bfcol_38`, - `bfcol_33` AS `bfcol_39`, - `bfcol_34` AS `bfcol_40`, - `bfcol_35` AS `bfcol_41`, - `bfcol_36` AS `bfcol_42`, - `bfcol_37` AS `bfcol_43`, - `bfcol_3` AS `bfcol_44` - FROM `bfcte_8` - INNER JOIN `bfcte_1` - ON `bfcol_32` = `bfcol_2` -), `bfcte_10` AS ( - SELECT - `bfcol_38`, - `bfcol_39`, - `bfcol_40`, - `bfcol_41`, - `bfcol_42`, - `bfcol_43`, - `bfcol_44`, - `bfcol_0`, - `bfcol_1`, - `bfcol_39` AS `bfcol_52`, - `bfcol_40` AS `bfcol_53`, - `bfcol_41` AS `bfcol_54`, - `bfcol_42` AS `bfcol_55`, - `bfcol_44` AS `bfcol_56`, - `bfcol_1` AS `bfcol_57`, - REGEXP_CONTAINS(`bfcol_38`, 'green') AS `bfcol_58`, - `bfcol_39` AS `bfcol_72`, - `bfcol_40` AS `bfcol_73`, - `bfcol_41` AS `bfcol_74`, - `bfcol_42` AS `bfcol_75`, - `bfcol_1` AS `bfcol_76`, - EXTRACT(YEAR FROM `bfcol_44`) AS `bfcol_77`, - `bfcol_1` AS `bfcol_84`, - EXTRACT(YEAR FROM `bfcol_44`) AS `bfcol_85`, - ( - `bfcol_40` * ( - 1 - `bfcol_41` - ) - ) - ( - `bfcol_42` * `bfcol_39` - ) AS `bfcol_86` - FROM `bfcte_9` - INNER JOIN `bfcte_0` - ON `bfcol_43` = `bfcol_0` - WHERE - REGEXP_CONTAINS(`bfcol_38`, 'green') -), `bfcte_11` AS ( - SELECT - `bfcol_84`, - `bfcol_85`, - COALESCE(SUM(`bfcol_86`), 0) AS `bfcol_90` - FROM `bfcte_10` - WHERE - NOT `bfcol_85` IS NULL - GROUP BY - `bfcol_84`, - `bfcol_85` -) -SELECT - `bfcol_84` AS `NATION`, - `bfcol_85` AS `O_YEAR`, - ROUND(`bfcol_90`, 2) AS `SUM_PROFIT` -FROM `bfcte_11` -ORDER BY - `bfcol_84` ASC NULLS LAST, - `bfcol_85` DESC, - `bfcol_84` ASC NULLS LAST, - `bfcol_85` ASC NULLS LAST \ No newline at end of file diff --git a/tests/unit/core/compile/sqlglot/tpch/test_tpch.py b/tests/unit/core/compile/sqlglot/tpch/test_tpch.py deleted file mode 100644 index 8988a5512f2..00000000000 --- a/tests/unit/core/compile/sqlglot/tpch/test_tpch.py +++ /dev/null @@ -1,47 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 - -freezegun = pytest.importorskip("freezegun") -pytest.importorskip("pytest_snapshot") - - -@pytest.mark.parametrize("query_num", range(1, 23)) -def test_tpch_query(tpch_session, query_num, snapshot): - project_id = "bigframes-dev-perf" - dataset_id = "tpch_0001t" - - query_file_path = f"third_party/bigframes_vendored/tpch/queries/q{query_num}.py" - - with open(query_file_path, "r") as f: - query_code = f.read() - - # We want to capture the result dataframe instead of running next(result.to_pandas_batches(...)) - modified_code = re.sub( - r"next\((\w+)\.to_pandas_batches\((.*?)\)\)", - r"return \1", - query_code, - ) - - exec_globals = {} # type: ignore[var-annotated] - exec(modified_code, exec_globals) - q_func = exec_globals["q"] - - result = q_func(project_id, dataset_id, tpch_session) - - # result should be a DataFrame - snapshot.assert_match(result.sql, "out.sql") diff --git a/tests/unit/core/logging/__init__.py b/tests/unit/core/logging/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/core/logging/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/logging/test_data_types.py b/tests/unit/core/logging/test_data_types.py deleted file mode 100644 index 09b3429f00d..00000000000 --- a/tests/unit/core/logging/test_data_types.py +++ /dev/null @@ -1,54 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 import dtypes -from bigframes.core.logging import data_types - -UNKNOWN_TYPE = pd.ArrowDtype(pa.time64("ns")) - -PA_STRUCT_TYPE = pa.struct([("city", pa.string()), ("pop", pa.int64())]) - -PA_LIST_TYPE = pa.list_(pa.int64()) - - -@pytest.mark.parametrize( - ("dtype", "expected_mask"), - [ - (None, 0), - (UNKNOWN_TYPE, 1 << 0), - (dtypes.INT_DTYPE, 1 << 1), - (dtypes.FLOAT_DTYPE, 1 << 2), - (dtypes.BOOL_DTYPE, 1 << 3), - (dtypes.STRING_DTYPE, 1 << 4), - (dtypes.BYTES_DTYPE, 1 << 5), - (dtypes.DATE_DTYPE, 1 << 6), - (dtypes.TIME_DTYPE, 1 << 7), - (dtypes.DATETIME_DTYPE, 1 << 8), - (dtypes.TIMESTAMP_DTYPE, 1 << 9), - (dtypes.TIMEDELTA_DTYPE, 1 << 10), - (dtypes.NUMERIC_DTYPE, 1 << 11), - (dtypes.BIGNUMERIC_DTYPE, 1 << 12), - (dtypes.GEO_DTYPE, 1 << 13), - (dtypes.JSON_DTYPE, 1 << 14), - (pd.ArrowDtype(PA_STRUCT_TYPE), 1 << 15), - (pd.ArrowDtype(PA_LIST_TYPE), 1 << 16), - (dtypes.OBJ_REF_DTYPE, (1 << 15) | (1 << 17)), - ], -) -def test_get_dtype_mask(dtype, expected_mask): - assert data_types._get_dtype_mask(dtype) == expected_mask diff --git a/tests/unit/core/rewrite/conftest.py b/tests/unit/core/rewrite/conftest.py index ab168427f29..22b897f3bf9 100644 --- a/tests/unit/core/rewrite/conftest.py +++ b/tests/unit/core/rewrite/conftest.py @@ -16,9 +16,8 @@ import google.cloud.bigquery import pytest -import bigframes import bigframes.core as core -from bigframes.core import bq_data +import bigframes.core.schema TABLE_REF = google.cloud.bigquery.TableReference.from_string("project.dataset.table") SCHEMA = ( @@ -35,32 +34,7 @@ @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, - ) + return TABLE @pytest.fixture @@ -72,13 +46,6 @@ def fake_session(): def leaf(fake_session, table): return core.ArrayValue.from_table( session=fake_session, - table=bq_data.GbqNativeTable.from_table(table), - ).node - - -@pytest.fixture -def leaf_too(fake_session, table_too): - return core.ArrayValue.from_table( - session=fake_session, - table=bq_data.GbqNativeTable.from_table(table_too), + table=table, + schema=bigframes.core.schema.ArraySchema.from_bq_table(table), ).node diff --git a/tests/unit/core/rewrite/test_identifiers.py b/tests/unit/core/rewrite/test_identifiers.py index 4d4609bb0fa..fd12df60a85 100644 --- a/tests/unit/core/rewrite/test_identifiers.py +++ b/tests/unit/core/rewrite/test_identifiers.py @@ -11,16 +11,11 @@ # WITHOUT 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.agg_expressions as agg_ex -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 -import bigframes.operations.aggregations as agg_ops -from bigframes.core import bq_data def test_remap_variables_single_node(leaf): @@ -54,56 +49,12 @@ def test_remap_variables_projection(leaf): assert set(mapping.values()) == {identifiers.ColumnId(f"id_{i}") for i in range(3)} -def test_remap_variables_aggregate(leaf): - # Aggregation: sum(col_a) AS sum_a - # Group by nothing - agg_op = agg_ex.UnaryAggregation( - op=agg_ops.sum_op, - arg=ex.DerefOp(leaf.fields[0].id), - ) - node = nodes.AggregateNode( - child=leaf, - aggregations=((agg_op, identifiers.ColumnId("sum_a")),), - by_column_ids=(), - ) - - id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) - _, mapping = id_rewrite.remap_variables(node, id_generator) - - # leaf has 2 columns: col_a, col_b - # AggregateNode defines 1 column: sum_a - # Output of AggregateNode should only be sum_a - assert len(mapping) == 1 - assert identifiers.ColumnId("sum_a") in mapping - - -def test_remap_variables_aggregate_with_grouping(leaf): - # Aggregation: sum(col_b) AS sum_b - # Group by col_a - agg_op = agg_ex.UnaryAggregation( - op=agg_ops.sum_op, - arg=ex.DerefOp(leaf.fields[1].id), - ) - node = nodes.AggregateNode( - child=leaf, - aggregations=((agg_op, identifiers.ColumnId("sum_b")),), - by_column_ids=(ex.DerefOp(leaf.fields[0].id),), - ) - - id_generator = (identifiers.ColumnId(f"id_{i}") for i in range(100)) - _, mapping = id_rewrite.remap_variables(node, id_generator) - - # Output should have 2 columns: col_a (grouping) and sum_b (agg) - assert len(mapping) == 2 - assert leaf.fields[0].id in mapping - assert identifiers.ColumnId("sum_b") in mapping - - 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=bq_data.GbqNativeTable.from_table(table), + table=table, + schema=leaf.schema, ).node leaf2 = leaf2_uncached.remap_vars( { @@ -113,7 +64,8 @@ def test_remap_variables_nested_join_stability(leaf, fake_session, table): ) leaf3_uncached = core.ArrayValue.from_table( session=fake_session, - table=bq_data.GbqNativeTable.from_table(table), + table=table, + schema=leaf.schema, ).node leaf3 = leaf3_uncached.remap_vars( { @@ -134,7 +86,6 @@ def test_remap_variables_nested_join_stability(leaf, fake_session, table): ), type="inner", propogate_order=False, - nulls_equal=True, ) outer_join = nodes.JoinNode( left_child=inner_join, @@ -147,7 +98,6 @@ def test_remap_variables_nested_join_stability(leaf, fake_session, table): ), type="inner", propogate_order=False, - nulls_equal=True, ) # Run remap_variables twice and assert stability @@ -180,24 +130,3 @@ def test_remap_variables_concat_self_stability(leaf): 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/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 deleted file mode 100644 index 9affd870e3e..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_basic/create_model_basic.sql +++ /dev/null @@ -1,3 +0,0 @@ -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_hparam_tuning/create_model_hparam_tuning.sql b/tests/unit/core/sql/snapshots/test_ml/test_create_model_hparam_tuning/create_model_hparam_tuning.sql deleted file mode 100644 index c7ed32e54fc..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_hparam_tuning/create_model_hparam_tuning.sql +++ /dev/null @@ -1,3 +0,0 @@ -CREATE MODEL `my_model` -OPTIONS(model_type = 'LINEAR_REG', learn_rate = HPARAM_RANGE(0.0001, 1.0), optimizer = HPARAM_CANDIDATES(['ADAGRAD', 'SGD'])) -AS SELECT * FROM t 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 deleted file mode 100644 index b67ea139673..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_if_not_exists/create_model_if_not_exists.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 723a4b037d8..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_list_option/create_model_list_option.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 878afe0823b..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote/create_model_remote.sql +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index 9bbea44259b..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_remote_default/create_model_remote_default.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index 7fe9d492da3..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_replace/create_model_replace.sql +++ /dev/null @@ -1,3 +0,0 @@ -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 deleted file mode 100644 index da7b6ba6724..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_training_data_and_holiday/create_model_training_data_and_holiday.sql +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index e460400be23..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_create_model_transform/create_model_transform.sql +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index 5889e342e4d..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_basic/evaluate_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index cdb66bbf0e1..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_options/evaluate_model_with_options.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e1d4fdecd62..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_evaluate_model_with_table/evaluate_model_with_table.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 1d755b34ddd..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_basic/explain_predict_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 7569463ea2d..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_explain_predict_model_with_options/explain_predict_model_with_options.sql +++ /dev/null @@ -1 +0,0 @@ -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_generate_embedding_model_basic/generate_embedding_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_generate_embedding_model_basic/generate_embedding_model_basic.sql deleted file mode 100644 index 7294f1655f7..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_generate_embedding_model_basic/generate_embedding_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM ML.GENERATE_EMBEDDING(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_generate_embedding_model_with_options/generate_embedding_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_generate_embedding_model_with_options/generate_embedding_model_with_options.sql deleted file mode 100644 index 3be957079cf..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_generate_embedding_model_with_options/generate_embedding_model_with_options.sql +++ /dev/null @@ -1,5 +0,0 @@ -SELECT * FROM ML.GENERATE_EMBEDDING(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data), STRUCT( - TRUE AS `flatten_json_output`, - 'RETRIEVAL_DOCUMENT' AS `task_type`, - 256 AS `output_dimensionality` -)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_basic/generate_text_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_basic/generate_text_model_basic.sql deleted file mode 100644 index 9d986876448..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_basic/generate_text_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM ML.GENERATE_TEXT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_with_options/generate_text_model_with_options.sql b/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_with_options/generate_text_model_with_options.sql deleted file mode 100644 index 0ea26747287..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_generate_text_model_with_options/generate_text_model_with_options.sql +++ /dev/null @@ -1,10 +0,0 @@ -SELECT * FROM ML.GENERATE_TEXT(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data), STRUCT( - 0.5 AS `temperature`, - 128 AS `max_output_tokens`, - 20 AS `top_k`, - 0.9 AS `top_p`, - TRUE AS `flatten_json_output`, - ['a', 'b'] AS `stop_sequences`, - TRUE AS `ground_with_google_search`, - 'TYPE' AS `request_type` -)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_get_insights_model_basic/get_insights_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_get_insights_model_basic/get_insights_model_basic.sql deleted file mode 100644 index a3f2680c179..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_get_insights_model_basic/get_insights_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM ML.GET_INSIGHTS(MODEL `my_project.my_dataset.my_model`) 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 deleted file mode 100644 index 4fc8250dab2..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_basic/global_explain_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index 396648aa1db..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_global_explain_model_with_options/global_explain_model_with_options.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index a1ac0b2b459..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_basic/predict_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -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 deleted file mode 100644 index e19f39eebba..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_predict_model_with_options/predict_model_with_options.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM ML.PREDICT(MODEL `my_model`, (SELECT * FROM new_data), STRUCT(TRUE AS `keep_original_columns`)) diff --git a/tests/unit/core/sql/snapshots/test_ml/test_transform_model_basic/transform_model_basic.sql b/tests/unit/core/sql/snapshots/test_ml/test_transform_model_basic/transform_model_basic.sql deleted file mode 100644 index e6cedc16477..00000000000 --- a/tests/unit/core/sql/snapshots/test_ml/test_transform_model_basic/transform_model_basic.sql +++ /dev/null @@ -1 +0,0 @@ -SELECT * FROM ML.TRANSFORM(MODEL `my_project.my_dataset.my_model`, (SELECT * FROM new_data)) diff --git a/tests/unit/core/sql/test_ml.py b/tests/unit/core/sql/test_ml.py deleted file mode 100644 index a03d8cd805a..00000000000 --- a/tests/unit/core/sql/test_ml.py +++ /dev/null @@ -1,243 +0,0 @@ -# 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.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_create_model_hparam_tuning(snapshot): - sql = bigframes.core.sql.ml.create_model_ddl( - model_name="my_model", - options={ - "model_type": "LINEAR_REG", - "learn_rate": bbq.hparam_range(0.0001, 1.0), - "optimizer": bbq.hparam_candidates(["ADAGRAD", "SGD"]), - }, - training_data="SELECT * FROM t", - ) - snapshot.assert_match(sql, "create_model_hparam_tuning.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") - - -def test_transform_model_basic(snapshot): - sql = bigframes.core.sql.ml.transform( - model_name="my_project.my_dataset.my_model", - table="SELECT * FROM new_data", - ) - snapshot.assert_match(sql, "transform_model_basic.sql") - - -def test_generate_text_model_basic(snapshot): - sql = bigframes.core.sql.ml.generate_text( - model_name="my_project.my_dataset.my_model", - table="SELECT * FROM new_data", - ) - snapshot.assert_match(sql, "generate_text_model_basic.sql") - - -def test_generate_text_model_with_options(snapshot): - sql = bigframes.core.sql.ml.generate_text( - model_name="my_project.my_dataset.my_model", - table="SELECT * FROM new_data", - temperature=0.5, - max_output_tokens=128, - top_k=20, - top_p=0.9, - flatten_json_output=True, - stop_sequences=["a", "b"], - ground_with_google_search=True, - request_type="TYPE", - ) - snapshot.assert_match(sql, "generate_text_model_with_options.sql") - - -def test_get_insights_model_basic(snapshot): - sql = bigframes.core.sql.ml.get_insights( - model_name="my_project.my_dataset.my_model", - ) - snapshot.assert_match(sql, "get_insights_model_basic.sql") - - -def test_generate_embedding_model_basic(snapshot): - sql = bigframes.core.sql.ml.generate_embedding( - model_name="my_project.my_dataset.my_model", - table="SELECT * FROM new_data", - ) - snapshot.assert_match(sql, "generate_embedding_model_basic.sql") - - -def test_generate_embedding_model_with_options(snapshot): - sql = bigframes.core.sql.ml.generate_embedding( - model_name="my_project.my_dataset.my_model", - table="SELECT * FROM new_data", - flatten_json_output=True, - task_type="RETRIEVAL_DOCUMENT", - output_dimensionality=256, - ) - snapshot.assert_match(sql, "generate_embedding_model_with_options.sql") diff --git a/tests/unit/core/test_bytecode.py b/tests/unit/core/test_bytecode.py deleted file mode 100644 index 036e3f00e8f..00000000000 --- a/tests/unit/core/test_bytecode.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.core.expression as ex -import bigframes.operations as ops -from bigframes.core.bytecode import py_to_expression - - -def test_py_to_expression_simple_arithmetic(): - func = lambda x: x + 1 - expr = py_to_expression(func) - assert expr is not None - - expected = ops.add_op.as_expr(ex.free_var("x"), ex.const(1)) - assert expr == expected - - -def test_py_to_expression_math_function(): - func = lambda x: math.sin(x) - expr = py_to_expression(func) - assert expr is not None - - expected = ops.numeric_ops.sin_op.as_expr(ex.free_var("x")) - assert expr == expected - - -def test_py_to_expression_negation(): - func = lambda x: -x - expr = py_to_expression(func) - assert expr is not None - - expected = ops.numeric_ops.neg_op.as_expr(ex.free_var("x")) - assert expr == expected - - -def test_py_to_expression_comparison(): - func = lambda x, y: x == y - expr = py_to_expression(func) - assert expr is not None - - expected = ops.comparison_ops.eq_op.as_expr(ex.free_var("x"), ex.free_var("y")) - assert expr == expected - - -def test_py_to_expression_unsupported(): - # Control flow or unsupported structures should return None - def func_with_loop(x): - res = 0 - for val in range(int(x)): - res += val - return res - - with pytest.raises(ValueError): - py_to_expression(func_with_loop) - - -global_none_val = None - - -def test_py_to_expression_global_none(): - # Test resolving a global variable explicitly set to None - func = lambda x: x == global_none_val - expr = py_to_expression(func) - assert expr is not None - - expected = ops.comparison_ops.eq_op.as_expr(ex.free_var("x"), ex.const(None)) - assert expr == expected diff --git a/tests/unit/core/test_dtypes.py b/tests/unit/core/test_dtypes.py new file mode 100644 index 00000000000..cd23614bbf5 --- /dev/null +++ b/tests/unit/core/test_dtypes.py @@ -0,0 +1,290 @@ +# 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 bigframes_vendored.ibis.backends.bigquery.datatypes as ibis_bq_types +import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes +import bigframes_vendored.ibis.expr.types as ibis_types +import geopandas as gpd # type: ignore +import numpy as np +import pandas as pd +import pyarrow as pa # type: ignore +import pytest +import shapely.geometry # type: ignore + +import bigframes.core.compile.ibis_types +import bigframes.dtypes + + +@pytest.mark.parametrize( + ["ibis_dtype", "bigframes_dtype"], + [ + # TODO(bmil): Add ARRAY, INTERVAL, STRUCT to cover all the standard + # BigQuery data types as they appear in Ibis: + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + pytest.param( + ibis_dtypes.Decimal(precision=76, scale=38, nullable=True), + pd.ArrowDtype(pa.decimal256(76, 38)), + id="bignumeric", + ), + pytest.param(ibis_dtypes.boolean, pd.BooleanDtype(), id="bool"), + pytest.param(ibis_dtypes.binary, pd.ArrowDtype(pa.binary()), id="bytes"), + pytest.param(ibis_dtypes.date, pd.ArrowDtype(pa.date32()), id="date"), + pytest.param( + ibis_dtypes.Timestamp(), pd.ArrowDtype(pa.timestamp("us")), id="datetime" + ), + pytest.param(ibis_dtypes.float64, pd.Float64Dtype(), id="float"), + pytest.param( + ibis_dtypes.GeoSpatial(geotype="geography", srid=4326, nullable=True), + gpd.array.GeometryDtype(), + id="geography", + ), + pytest.param(ibis_dtypes.int8, pd.Int64Dtype(), id="int8-as-int64"), + pytest.param(ibis_dtypes.int64, pd.Int64Dtype(), id="int64"), + # TODO(tswast): custom dtype (or at least string dtype) for JSON objects + pytest.param( + ibis_dtypes.Decimal(precision=38, scale=9, nullable=True), + pd.ArrowDtype(pa.decimal128(38, 9)), + id="numeric", + ), + pytest.param( + ibis_dtypes.string, pd.StringDtype(storage="pyarrow"), id="string" + ), + pytest.param(ibis_dtypes.time, pd.ArrowDtype(pa.time64("us")), id="time"), + pytest.param( + ibis_dtypes.Timestamp(timezone="UTC"), + pd.ArrowDtype(pa.timestamp("us", tz="UTC")), # type: ignore + id="timestamp", + ), + ], +) +def test_ibis_dtype_converts(ibis_dtype, bigframes_dtype): + """Test all the Ibis data types needed to read BigQuery tables""" + result = bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype(ibis_dtype) + assert result == bigframes_dtype + + +def test_ibis_timestamp_pst_raises_unexpected_datatype(): + """BigQuery timestamp only supports UTC time""" + with pytest.raises(ValueError, match="'PST'"): + bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( + ibis_dtypes.Timestamp(timezone="PST") + ) + + +def test_ibis_float32_raises_unexpected_datatype(): + """Other Ibis types not read from BigQuery are not expected""" + with pytest.raises(ValueError, match="Unexpected Ibis data type"): + bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( + ibis_dtypes.float32 + ) + + +IBIS_ARROW_DTYPES = ( + (ibis_dtypes.boolean, pa.bool_()), + (ibis_dtypes.date, pa.date32()), + (ibis_dtypes.Timestamp(), pa.timestamp("us")), + (ibis_dtypes.float64, pa.float64()), + ( + ibis_dtypes.Timestamp(timezone="UTC"), + pa.timestamp("us", tz="UTC"), + ), + ( + ibis_dtypes.Struct.from_tuples( + [ + ("name", ibis_dtypes.string()), + ("version", ibis_dtypes.int64()), + ] + ), + pa.struct( + [ + ("name", pa.string()), + ("version", pa.int64()), + ] + ), + ), + ( + ibis_dtypes.Struct.from_tuples( + [ + ( + "nested", + ibis_dtypes.Struct.from_tuples( + [ + ("field", ibis_dtypes.string()), + ] + ), + ), + ] + ), + pa.struct( + [ + ( + "nested", + pa.struct( + [ + ("field", pa.string()), + ] + ), + ), + ] + ), + ), +) + + +@pytest.mark.parametrize(("ibis_dtype", "arrow_dtype"), IBIS_ARROW_DTYPES) +def test_arrow_dtype_to_ibis_dtype(ibis_dtype, arrow_dtype): + result = bigframes.core.compile.ibis_types._arrow_dtype_to_ibis_dtype(arrow_dtype) + assert result == ibis_dtype + + +@pytest.mark.parametrize(("ibis_dtype", "arrow_dtype"), IBIS_ARROW_DTYPES) +def test_ibis_dtype_to_arrow_dtype(ibis_dtype, arrow_dtype): + result = bigframes.core.compile.ibis_types._ibis_dtype_to_arrow_dtype(ibis_dtype) + assert result == arrow_dtype + + +@pytest.mark.parametrize( + ("ibis_dtype", "bigquery_type"), + [(ibis_dtypes.String(), "STRING"), (ibis_dtypes.String(nullable=False), "STRING")], +) +def test_ibis_dtype_to_bigquery_type(ibis_dtype, bigquery_type): + result = ibis_bq_types.BigQueryType.from_ibis(ibis_dtype) + assert result == bigquery_type + + +@pytest.mark.parametrize( + ["bigframes_dtype", "ibis_dtype"], + [ + # This test covers all dtypes that BigQuery DataFrames can exactly map to Ibis + (pd.BooleanDtype(), ibis_dtypes.boolean), + (pd.ArrowDtype(pa.date32()), ibis_dtypes.date), + (pd.ArrowDtype(pa.timestamp("us")), ibis_dtypes.Timestamp()), + (pd.Float64Dtype(), ibis_dtypes.float64), + (pd.Int64Dtype(), ibis_dtypes.int64), + (pd.StringDtype(storage="pyarrow"), ibis_dtypes.string), + (pd.ArrowDtype(pa.time64("us")), ibis_dtypes.time), + ( + pd.ArrowDtype(pa.timestamp("us", tz="UTC")), # type: ignore + ibis_dtypes.Timestamp(timezone="UTC"), + ), + ], + ids=[ + "boolean", + "date", + "datetime", + "float", + "int", + "string", + "time", + "timestamp", + ], +) +def test_bigframes_dtype_converts(ibis_dtype, bigframes_dtype): + """Test all the Ibis data types needed to read BigQuery tables""" + result = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( + bigframes_dtype + ) + assert result == ibis_dtype + + +@pytest.mark.parametrize( + ["bigframes_dtype_str", "ibis_dtype"], + [ + # This test covers all dtypes that BigQuery DataFrames can exactly map to Ibis + ("boolean", ibis_dtypes.boolean), + ("date32[day][pyarrow]", ibis_dtypes.date), + ("timestamp[us][pyarrow]", ibis_dtypes.Timestamp()), + ("Float64", ibis_dtypes.float64), + ("Int64", ibis_dtypes.int64), + ("string[pyarrow]", ibis_dtypes.string), + ("time64[us][pyarrow]", ibis_dtypes.time), + ( + "timestamp[us, tz=UTC][pyarrow]", + ibis_dtypes.Timestamp(timezone="UTC"), + ), + # Special case - "string" is acceptable for "string[pyarrow]" + ("string", ibis_dtypes.string), + ], +) +def test_bigframes_string_dtype_converts(ibis_dtype, bigframes_dtype_str): + """Test all the Ibis data types needed to read BigQuery tables""" + result = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( + bigframes.dtypes.bigframes_type(bigframes_dtype_str) + ) + assert result == ibis_dtype + + +@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 + + +def test_unsupported_dtype_raises_unexpected_datatype(): + """Incompatible dtypes should fail when passed into BigQuery DataFrames""" + with pytest.raises(ValueError, match="Datatype has no ibis type mapping"): + bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype(np.float32) + + +def test_unsupported_dtype_str_raises_unexpected_datatype(): + """Incompatible dtypes should fail when passed into BigQuery DataFrames""" + with pytest.raises(ValueError, match="Datatype has no ibis type mapping"): + bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype("int64") + + +@pytest.mark.parametrize( + ["literal", "ibis_scalar"], + [ + (True, ibis_types.literal(True, ibis_dtypes.boolean)), + (5, ibis_types.literal(5, ibis_dtypes.int64)), + (-33.2, ibis_types.literal(-33.2, ibis_dtypes.float64)), + ], +) +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"}) + + +@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 diff --git a/tests/unit/core/test_expression.py b/tests/unit/core/test_expression.py index 68fc3a2b540..4c3d233879f 100644 --- a/tests/unit/core/test_expression.py +++ b/tests/unit/core/test_expression.py @@ -16,11 +16,11 @@ 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 -from bigframes.core import field def test_simple_expression_dtype(): @@ -105,7 +105,7 @@ def test_nested_expression_dtypes_are_cached(): def _create_field_bindings( - col_dtypes: typing.Dict[str, dtypes.Dtype], + col_dtypes: typing.Dict[str, dtypes.Dtype] ) -> typing.Dict[ids.ColumnId, field.Field]: return { ids.ColumnId(col): field.Field(ids.ColumnId(col), dtype) diff --git a/tests/unit/core/test_googlesql.py b/tests/unit/core/test_googlesql.py deleted file mode 100644 index e83391e4b33..00000000000 --- a/tests/unit/core/test_googlesql.py +++ /dev/null @@ -1,268 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 pandas as pd - -import bigframes.core.col as col -import bigframes.core.expression as ex -import bigframes.core.global_session -import bigframes.core.googlesql as core_googlesql -import bigframes.series as series -from bigframes.operations import googlesql -from bigframes.testing import mocks - -# Define a test op -_TEST_OP = googlesql.GoogleSqlScalarOp( - "TEST_OP", - args=(googlesql.ArgSpec(), googlesql.ArgSpec()), - signature=lambda *args: None, -) - - -def test_apply_googlesql_scalar_op_expressions(): - # Only expressions - result = core_googlesql.apply_googlesql_scalar_op( - _TEST_OP, - col.col("a"), - col.col("b"), - ) - assert isinstance(result, col.Expression) - - -def test_apply_googlesql_scalar_op_pandas_series_global_session(monkeypatch): - # Setup mock session - session = mocks.create_bigquery_session() - monkeypatch.setattr(bigframes.core.global_session, "_global_session", session) - bigframes.options.bigquery._session_started = True - - # Create a real-ish Series to return from read_pandas - df = mocks.create_dataframe(monkeypatch, session=session, data={"col": [1, 2, 3]}) - bf_series = df["col"] - - # Mock read_pandas on the session - mock_read_pandas = mock.MagicMock(return_value=bf_series) - session.read_pandas = mock_read_pandas # type: ignore - - # Mock _apply_nary_op on Series class to avoid real compilation/execution - mock_apply_nary_op = mock.MagicMock(return_value=bf_series) - monkeypatch.setattr(series.Series, "_apply_nary_op", mock_apply_nary_op) - - pd_series = pd.Series([1, 2, 3]) - - # Call the function with a pandas Series and a literal - result = core_googlesql.apply_googlesql_scalar_op(_TEST_OP, pd_series, 42) - - # Verify read_pandas was called on the global session - mock_read_pandas.assert_called_once_with(pd_series) - - # Verify _apply_nary_op was called on the converted series - mock_apply_nary_op.assert_called_once() - # First arg to _apply_nary_op is the op, second is the processed_args - assert mock_apply_nary_op.call_args[0][0] == _TEST_OP - # processed_args should contain the converted bf_series and the literal 42 - processed_args = mock_apply_nary_op.call_args[0][1] - assert processed_args[0] is bf_series - assert processed_args[1] == 42 - - # Verify result is a Series - assert isinstance(result, series.Series) - - -def test_apply_googlesql_scalar_op_pandas_series_with_bf_series(monkeypatch): - # Setup mock session 1 (global) and session 2 (associated with bf_series) - global_session = mocks.create_bigquery_session(session_id="global") - monkeypatch.setattr( - bigframes.core.global_session, "_global_session", global_session - ) - bigframes.options.bigquery._session_started = True - - bf_session = mocks.create_bigquery_session(session_id="bf_session") - - # Create a bf_series associated with bf_session - df = mocks.create_dataframe( - monkeypatch, session=bf_session, data={"col": [1, 2, 3]} - ) - bf_series = df["col"] - - assert bf_series._session == bf_session - - # Mock read_pandas on both sessions - mock_global_read_pandas = mock.MagicMock() - global_session.read_pandas = mock_global_read_pandas # type: ignore - - mock_bf_read_pandas = mock.MagicMock(return_value=bf_series) - bf_session.read_pandas = mock_bf_read_pandas # type: ignore - - # Mock _apply_nary_op - mock_apply_nary_op = mock.MagicMock(return_value=bf_series) - monkeypatch.setattr(series.Series, "_apply_nary_op", mock_apply_nary_op) - - pd_series = pd.Series([1, 2, 3]) - - # Call with both pandas Series and BigFrames Series - result = core_googlesql.apply_googlesql_scalar_op(_TEST_OP, pd_series, bf_series) - - # Verify read_pandas was called on bf_session, NOT global_session - mock_bf_read_pandas.assert_called_once_with(pd_series) - mock_global_read_pandas.assert_not_called() - - # Verify _apply_nary_op was called - mock_apply_nary_op.assert_called_once() - processed_args = mock_apply_nary_op.call_args[0][1] - # Both arguments to the op should now be BigFrames Series - assert processed_args[0] is bf_series - assert processed_args[1] is bf_series - - assert isinstance(result, series.Series) - - -def test_apply_googlesql_scalar_op_mixed_args(monkeypatch): - session = mocks.create_bigquery_session() - monkeypatch.setattr(bigframes.core.global_session, "_global_session", session) - bigframes.options.bigquery._session_started = True - - df = mocks.create_dataframe(monkeypatch, session=session, data={"col": [1, 2, 3]}) - bf_series = df["col"] - - mock_read_pandas = mock.MagicMock(return_value=bf_series) - session.read_pandas = mock_read_pandas # type: ignore - - mock_apply_nary_op = mock.MagicMock(return_value=bf_series) - monkeypatch.setattr(series.Series, "_apply_nary_op", mock_apply_nary_op) - - pd_series = pd.Series([1, 2, 3]) - expr = col.Expression(ex.const(10)) - - # Call with pandas Series, Expression, and Literal - result = core_googlesql.apply_googlesql_scalar_op(_TEST_OP, pd_series, expr, 42) - - # Verify pandas Series was converted - mock_read_pandas.assert_called_once_with(pd_series) - - # Verify _apply_nary_op was called - mock_apply_nary_op.assert_called_once() - processed_args = mock_apply_nary_op.call_args[0][1] - - # Processed args should be: - # 1. bf_series (converted from pd_series) - # 2. A new Series (projected from the expression onto bf_series' block) - # 3. Literal 42 - assert isinstance(processed_args[0], series.Series) - assert processed_args[0] is bf_series - - assert isinstance(processed_args[1], series.Series) - assert processed_args[1] is not bf_series - - assert processed_args[2] == 42 - - assert isinstance(result, series.Series) - - -def test_apply_googlesql_scalar_op_pandas_series_with_bf_dataframe(monkeypatch): - # Setup mock session 2 (associated with bf_dataframe) - bf_session = mocks.create_bigquery_session(session_id="bf_session") - - # Create a bf_dataframe associated with bf_session - bf_dataframe = mocks.create_dataframe( - monkeypatch, session=bf_session, data={"col": [1, 2, 3]} - ) - bf_series = bf_dataframe["col"] - - # Setup mock session 1 (global) AFTER creating the dataframe - global_session = mocks.create_bigquery_session(session_id="global") - monkeypatch.setattr( - bigframes.core.global_session, "_global_session", global_session - ) - bigframes.options.bigquery._session_started = True - - assert bf_dataframe._session == bf_session - - # Mock read_pandas on both sessions - mock_global_read_pandas = mock.MagicMock() - global_session.read_pandas = mock_global_read_pandas # type: ignore - - mock_bf_read_pandas = mock.MagicMock(return_value=bf_series) - bf_session.read_pandas = mock_bf_read_pandas # type: ignore - - # Mock _apply_nary_op - mock_apply_nary_op = mock.MagicMock(return_value=bf_series) - monkeypatch.setattr(series.Series, "_apply_nary_op", mock_apply_nary_op) - - pd_series = pd.Series([1, 2, 3]) - - # Call with pandas Series and BigFrames DataFrame - result = core_googlesql.apply_googlesql_scalar_op(_TEST_OP, pd_series, bf_dataframe) - - # Verify read_pandas was called on bf_session, NOT global_session - mock_bf_read_pandas.assert_called_once_with(pd_series) - mock_global_read_pandas.assert_not_called() - - # Verify _apply_nary_op was called - mock_apply_nary_op.assert_called_once() - processed_args = mock_apply_nary_op.call_args[0][1] - assert processed_args[0] is bf_series - assert processed_args[1] is bf_dataframe - - assert isinstance(result, series.Series) - - -def test_apply_googlesql_scalar_op_pandas_series_with_bf_index(monkeypatch): - # Setup mock session 2 (associated with bf_index) - bf_session = mocks.create_bigquery_session(session_id="bf_session") - - # Create a bf_dataframe associated with bf_session to get an index - bf_dataframe = mocks.create_dataframe( - monkeypatch, session=bf_session, data={"col": [1, 2, 3]} - ) - bf_index = bf_dataframe.index - bf_series = bf_dataframe["col"] - - # Setup mock session 1 (global) AFTER creating the dataframe - global_session = mocks.create_bigquery_session(session_id="global") - monkeypatch.setattr( - bigframes.core.global_session, "_global_session", global_session - ) - bigframes.options.bigquery._session_started = True - - assert bf_index._session == bf_session - - # Mock read_pandas on both sessions - mock_global_read_pandas = mock.MagicMock() - global_session.read_pandas = mock_global_read_pandas # type: ignore - - mock_bf_read_pandas = mock.MagicMock(return_value=bf_series) - bf_session.read_pandas = mock_bf_read_pandas # type: ignore - - # Mock _apply_nary_op - mock_apply_nary_op = mock.MagicMock(return_value=bf_series) - monkeypatch.setattr(series.Series, "_apply_nary_op", mock_apply_nary_op) - - pd_series = pd.Series([1, 2, 3]) - - # Call with pandas Series and BigFrames Index - result = core_googlesql.apply_googlesql_scalar_op(_TEST_OP, pd_series, bf_index) - - # Verify read_pandas was called on bf_session, NOT global_session - mock_bf_read_pandas.assert_called_once_with(pd_series) - mock_global_read_pandas.assert_not_called() - - # Verify _apply_nary_op was called - mock_apply_nary_op.assert_called_once() - processed_args = mock_apply_nary_op.call_args[0][1] - assert processed_args[0] is bf_series - assert processed_args[1] is bf_index - - assert isinstance(result, series.Series) diff --git a/tests/unit/core/test_groupby.py b/tests/unit/core/test_groupby.py deleted file mode 100644 index b23199da331..00000000000 --- a/tests/unit/core/test_groupby.py +++ /dev/null @@ -1,264 +0,0 @@ -# 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 -import bigframes.testing.utils - -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 - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.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,) - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.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,) - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.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 - bigframes.testing.utils.assert_series_equal( - bf_result, pd_result, check_dtype=False, check_index_type=False - ) diff --git a/tests/unit/core/test_ibis_types.py b/tests/unit/core/test_ibis_types.py deleted file mode 100644 index 427e726179e..00000000000 --- a/tests/unit/core/test_ibis_types.py +++ /dev/null @@ -1,250 +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 bigframes_vendored.ibis.backends.bigquery.datatypes as ibis_bq_types -import bigframes_vendored.ibis.expr.datatypes as ibis_dtypes -import bigframes_vendored.ibis.expr.types as ibis_types -import geopandas as gpd # type: ignore -import numpy as np -import pandas as pd -import pyarrow as pa # type: ignore -import pytest - -import bigframes.core.compile.ibis_types -import bigframes.dtypes - - -@pytest.mark.parametrize( - ["ibis_dtype", "bigframes_dtype"], - [ - # TODO(bmil): Add ARRAY, INTERVAL, STRUCT to cover all the standard - # BigQuery data types as they appear in Ibis: - # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types - pytest.param( - ibis_dtypes.Decimal(precision=76, scale=38, nullable=True), - pd.ArrowDtype(pa.decimal256(76, 38)), - id="bignumeric", - ), - pytest.param(ibis_dtypes.boolean, pd.BooleanDtype(), id="bool"), - pytest.param(ibis_dtypes.binary, pd.ArrowDtype(pa.binary()), id="bytes"), - pytest.param(ibis_dtypes.date, pd.ArrowDtype(pa.date32()), id="date"), - pytest.param( - ibis_dtypes.Timestamp(), pd.ArrowDtype(pa.timestamp("us")), id="datetime" - ), - pytest.param(ibis_dtypes.float64, pd.Float64Dtype(), id="float"), - pytest.param( - ibis_dtypes.GeoSpatial(geotype="geography", srid=4326, nullable=True), - gpd.array.GeometryDtype(), - id="geography", - ), - pytest.param(ibis_dtypes.int8, pd.Int64Dtype(), id="int8-as-int64"), - pytest.param(ibis_dtypes.int64, pd.Int64Dtype(), id="int64"), - # TODO(tswast): custom dtype (or at least string dtype) for JSON objects - pytest.param( - ibis_dtypes.Decimal(precision=38, scale=9, nullable=True), - pd.ArrowDtype(pa.decimal128(38, 9)), - id="numeric", - ), - pytest.param( - ibis_dtypes.string, pd.StringDtype(storage="pyarrow"), id="string" - ), - pytest.param(ibis_dtypes.time, pd.ArrowDtype(pa.time64("us")), id="time"), - pytest.param( - ibis_dtypes.Timestamp(timezone="UTC"), - pd.ArrowDtype(pa.timestamp("us", tz="UTC")), # type: ignore - id="timestamp", - ), - ], -) -def test_ibis_dtype_converts(ibis_dtype, bigframes_dtype): - """Test all the Ibis data types needed to read BigQuery tables""" - result = bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype(ibis_dtype) - assert result == bigframes_dtype - - -def test_ibis_timestamp_pst_raises_unexpected_datatype(): - """BigQuery timestamp only supports UTC time""" - with pytest.raises(ValueError, match="'PST'"): - bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( - ibis_dtypes.Timestamp(timezone="PST") - ) - - -def test_ibis_float32_raises_unexpected_datatype(): - """Other Ibis types not read from BigQuery are not expected""" - with pytest.raises(ValueError, match="Unexpected Ibis data type"): - bigframes.core.compile.ibis_types.ibis_dtype_to_bigframes_dtype( - ibis_dtypes.float32 - ) - - -IBIS_ARROW_DTYPES = ( - (ibis_dtypes.boolean, pa.bool_()), - (ibis_dtypes.date, pa.date32()), - (ibis_dtypes.Timestamp(), pa.timestamp("us")), - (ibis_dtypes.float64, pa.float64()), - ( - ibis_dtypes.Timestamp(timezone="UTC"), - pa.timestamp("us", tz="UTC"), - ), - ( - ibis_dtypes.Struct.from_tuples( - [ - ("name", ibis_dtypes.string()), - ("version", ibis_dtypes.int64()), - ] - ), - pa.struct( - [ - ("name", pa.string()), - ("version", pa.int64()), - ] - ), - ), - ( - ibis_dtypes.Struct.from_tuples( - [ - ( - "nested", - ibis_dtypes.Struct.from_tuples( - [ - ("field", ibis_dtypes.string()), - ] - ), - ), - ] - ), - pa.struct( - [ - ( - "nested", - pa.struct( - [ - ("field", pa.string()), - ] - ), - ), - ] - ), - ), -) - - -@pytest.mark.parametrize(("ibis_dtype", "arrow_dtype"), IBIS_ARROW_DTYPES) -def test_arrow_dtype_to_ibis_dtype(ibis_dtype, arrow_dtype): - result = bigframes.core.compile.ibis_types._arrow_dtype_to_ibis_dtype(arrow_dtype) - assert result == ibis_dtype - - -@pytest.mark.parametrize(("ibis_dtype", "arrow_dtype"), IBIS_ARROW_DTYPES) -def test_ibis_dtype_to_arrow_dtype(ibis_dtype, arrow_dtype): - result = bigframes.core.compile.ibis_types._ibis_dtype_to_arrow_dtype(ibis_dtype) - assert result == arrow_dtype - - -@pytest.mark.parametrize( - ("ibis_dtype", "bigquery_type"), - [(ibis_dtypes.String(), "STRING"), (ibis_dtypes.String(nullable=False), "STRING")], -) -def test_ibis_dtype_to_bigquery_type(ibis_dtype, bigquery_type): - result = ibis_bq_types.BigQueryType.from_ibis(ibis_dtype) - assert result == bigquery_type - - -@pytest.mark.parametrize( - ["bigframes_dtype", "ibis_dtype"], - [ - # This test covers all dtypes that BigQuery DataFrames can exactly map to Ibis - (pd.BooleanDtype(), ibis_dtypes.boolean), - (pd.ArrowDtype(pa.date32()), ibis_dtypes.date), - (pd.ArrowDtype(pa.timestamp("us")), ibis_dtypes.Timestamp()), - (pd.Float64Dtype(), ibis_dtypes.float64), - (pd.Int64Dtype(), ibis_dtypes.int64), - (pd.StringDtype(storage="pyarrow"), ibis_dtypes.string), - (pd.ArrowDtype(pa.time64("us")), ibis_dtypes.time), - ( - pd.ArrowDtype(pa.timestamp("us", tz="UTC")), # type: ignore - ibis_dtypes.Timestamp(timezone="UTC"), - ), - ], - ids=[ - "boolean", - "date", - "datetime", - "float", - "int", - "string", - "time", - "timestamp", - ], -) -def test_bigframes_dtype_converts(ibis_dtype, bigframes_dtype): - """Test all the Ibis data types needed to read BigQuery tables""" - result = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( - bigframes_dtype - ) - assert result == ibis_dtype - - -@pytest.mark.parametrize( - ["bigframes_dtype_str", "ibis_dtype"], - [ - # This test covers all dtypes that BigQuery DataFrames can exactly map to Ibis - ("boolean", ibis_dtypes.boolean), - ("date32[day][pyarrow]", ibis_dtypes.date), - ("timestamp[us][pyarrow]", ibis_dtypes.Timestamp()), - ("Float64", ibis_dtypes.float64), - ("Int64", ibis_dtypes.int64), - ("string[pyarrow]", ibis_dtypes.string), - ("time64[us][pyarrow]", ibis_dtypes.time), - ( - "timestamp[us, tz=UTC][pyarrow]", - ibis_dtypes.Timestamp(timezone="UTC"), - ), - # Special case - "string" is acceptable for "string[pyarrow]" - ("string", ibis_dtypes.string), - ], -) -def test_bigframes_string_dtype_converts(ibis_dtype, bigframes_dtype_str): - """Test all the Ibis data types needed to read BigQuery tables""" - result = bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype( - bigframes.dtypes.bigframes_type(bigframes_dtype_str) - ) - assert result == ibis_dtype - - -def test_unsupported_dtype_raises_unexpected_datatype(): - """Incompatible dtypes should fail when passed into BigQuery DataFrames""" - with pytest.raises(ValueError, match="Datatype has no ibis type mapping"): - bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype(np.float32) - - -def test_unsupported_dtype_str_raises_unexpected_datatype(): - """Incompatible dtypes should fail when passed into BigQuery DataFrames""" - with pytest.raises(ValueError, match="Datatype has no ibis type mapping"): - bigframes.core.compile.ibis_types.bigframes_dtype_to_ibis_dtype("int64") - - -@pytest.mark.parametrize( - ["literal", "ibis_scalar"], - [ - (True, ibis_types.literal(True, ibis_dtypes.boolean)), - (5, ibis_types.literal(5, ibis_dtypes.int64)), - (-33.2, ibis_types.literal(-33.2, ibis_dtypes.float64)), - ], -) -def test_literal_to_ibis_scalar_converts(literal, ibis_scalar): - assert bigframes.core.compile.ibis_types.literal_to_ibis_scalar(literal).equals( - ibis_scalar - ) diff --git a/tests/unit/core/logging/test_log_adapter.py b/tests/unit/core/test_log_adapter.py similarity index 95% rename from tests/unit/core/logging/test_log_adapter.py rename to tests/unit/core/test_log_adapter.py index 0722ef62a29..eba015dd9da 100644 --- a/tests/unit/core/logging/test_log_adapter.py +++ b/tests/unit/core/test_log_adapter.py @@ -14,10 +14,10 @@ from unittest import mock -import pytest from google.cloud import bigquery +import pytest -from bigframes.core.logging import log_adapter +from bigframes.core import log_adapter # The limit is 64 (https://cloud.google.com/bigquery/docs/labels-intro#requirements), # but leave a few spare for internal labels to be added. @@ -101,17 +101,6 @@ def test_method_logging_with_custom_base_name(test_method_w_custom_base): 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 diff --git a/tests/unit/core/test_pyformat.py b/tests/unit/core/test_pyformat.py index 239a59237f6..447ce377661 100644 --- a/tests/unit/core/test_pyformat.py +++ b/tests/unit/core/test_pyformat.py @@ -62,72 +62,6 @@ def test_parse_fields(sql_template: str, expected: List[str]): assert fields == expected -def test_get_error_context_at_pos_invalid_pos(): - assert pyformat.get_error_context_at_pos("SELECT 1", -1) == "" - assert pyformat.get_error_context_at_pos("SELECT 1", 100) == "" - - -def test_get_error_context_at_pos_single_line(): - sql = "SELECT {foo}" - # pos of '{' is 7 - context = pyformat.get_error_context_at_pos(sql, 7) - expected = " 1: SELECT {foo}\n ^" - assert context == expected - - -def test_get_error_context_at_pos_multi_line(): - sql = "SELECT 1\nFROM my_table\nWHERE col = {foo}\nAND active = True\nLIMIT 10" - # Lines: - # 1: SELECT 1 (len 9 including \n) - # 2: FROM my_table (len 14 including \n) -> total 23 - # 3: WHERE col = {foo} -> '{' is at 23 + 12 = 35 - - context = pyformat.get_error_context_at_pos(sql, 35) - expected = ( - " 1: SELECT 1\n" - " 2: FROM my_table\n" - " 3: WHERE col = {foo}\n" - " ^\n" - " 4: AND active = True\n" - " 5: LIMIT 10" - ) - assert context == expected - - -def test_get_error_context_at_pos_multi_line_limits(): - # Test that it only shows at most 2 lines before and 2 lines after - sql = ( - "LINE 1\n" - "LINE 2\n" - "LINE 3\n" - "LINE 4\n" - "LINE 5\n" - "TARGET {foo}\n" - "LINE 7\n" - "LINE 8\n" - "LINE 9\n" - "LINE 10" - ) - # Line lengths: - # LINE 1\n (7) - # LINE 2\n (7) -> 14 - # LINE 3\n (7) -> 21 - # LINE 4\n (7) -> 28 - # LINE 5\n (7) -> 35 - # TARGET {foo}\n -> '{' is at 35 + 7 = 42 - - context = pyformat.get_error_context_at_pos(sql, 42) - expected = ( - " 4: LINE 4\n" - " 5: LINE 5\n" - " 6: TARGET {foo}\n" - " ^\n" - " 7: LINE 7\n" - " 8: LINE 8" - ) - assert context == expected - - def test_pyformat_with_unsupported_type_raises_typeerror(session): pyformat_args = {"my_object": object()} sql = "SELECT {my_object}" @@ -136,75 +70,13 @@ def test_pyformat_with_unsupported_type_raises_typeerror(session): pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) -def test_pyformat_with_missing_variable_raises_valueerror(session): +def test_pyformat_with_missing_variable_raises_keyerror(session): pyformat_args: Dict[str, Any] = {} sql = "SELECT {my_object}" - with pytest.raises(ValueError) as exc_info: - pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) - - err_msg = str(exc_info.value) - assert "Undetected variable 'my_object' in SQL template" in err_msg - assert "Did you mean to escape '{' and '}'" in err_msg - assert " 1: SELECT {my_object}" in err_msg - assert " ^" in err_msg - - -def test_pyformat_with_unescaped_braces_raises_valueerror_with_context(session): - pyformat_args = {"active": True} - sql = """SELECT * FROM my_table -WHERE json_col = { "generation_config": { "temperature": 0.9 } } -AND active = {active} -""" - - with pytest.raises(ValueError) as exc_info: + with pytest.raises(KeyError, match="my_object"): pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) - err_msg = str(exc_info.value) - assert "Undetected variable ' \"generation_config\"' in SQL template" in err_msg - assert "Did you mean to escape '{' and '}'" in err_msg - # The triple quote string starts with SELECT immediately, so lines are: - # 1: SELECT * FROM my_table - # 2: WHERE json_col = { "generation_config": { "temperature": 0.9 } } - # 3: AND active = {active} - assert " 1: SELECT * FROM my_table" in err_msg - assert ( - ' 2: WHERE json_col = { "generation_config": { "temperature": 0.9 } }' - in err_msg - ) - assert " ^" in err_msg - assert " 3: AND active = {active}" in err_msg - - -@pytest.mark.parametrize( - ("sql_template", "expected_error"), - ( - pytest.param( - "SELECT {foo", - "expected '}' before end of string", - id="missing_closing_brace", - ), - pytest.param( - "SELECT foo}", - "Single '}' encountered in format string", - id="missing_opening_brace", - ), - ), -) -def test_pyformat_with_malformed_template_raises_valueerror( - session, sql_template: str, expected_error: str -): - pyformat_args: Dict[str, Any] = {} - - # Case 1: Single '{' (unmatched) - with pytest.raises(ValueError) as exc_info: - pyformat.pyformat(sql_template, pyformat_args=pyformat_args, session=session) - - error_message = str(exc_info.value) - assert "Failed to parse SQL template" in error_message - assert "Did you mean to escape '{' and '}'" in error_message - assert expected_error in error_message - def test_pyformat_with_no_variables(session): pyformat_args: Dict[str, Any] = {} @@ -572,7 +444,7 @@ def test_pyformat_with_pandas_dataframe_not_dry_run_no_session_raises_valueerror def test_pyformat_with_query_string_replaces_variables(session): pyformat_args = { - "my_string": "`my_table`", + "my_string": "some string value", "max_value": 2.25, "year": 2025, "null_value": None, @@ -584,8 +456,9 @@ def test_pyformat_with_query_string_replaces_variables(session): SELECT {year} - year AS age, @myparam AS myparam, '{{my_string}}' AS escaped_string, - * - FROM {my_string} + {my_string} AS my_string, + {null_value} AS null_value, + FROM my_dataset.my_table WHERE height < {max_value} """.strip() @@ -593,8 +466,9 @@ def test_pyformat_with_query_string_replaces_variables(session): SELECT 2025 - year AS age, @myparam AS myparam, '{my_string}' AS escaped_string, - * - FROM `my_table` + 'some string value' AS my_string, + NULL AS null_value, + FROM my_dataset.my_table WHERE height < 2.25 """.strip() @@ -628,15 +502,6 @@ def test_pyformat_with_query_string_replaces_variables(session): ), "SELECT * FROM `ListedProject`.`ListedDataset`.`ListedTable`", ), - ( - google.cloud.bigquery.TableReference( - google.cloud.bigquery.DatasetReference( - "my-project", "my-catalog.my-namespace" - ), - "my-table", - ), - "SELECT * FROM `my-project`.`my-catalog`.`my-namespace`.`my-table`", - ), ), ) def test_pyformat_with_table_replaces_variables(table, expected_sql, session=session): @@ -648,51 +513,3 @@ def test_pyformat_with_table_replaces_variables(table, expected_sql, session=ses sql = "SELECT * FROM {table}" got_sql = pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) assert got_sql == expected_sql - - -def test_pyformat_with_bigframes_dataframe_biglake_table(session): - # Create a real BigFrames DataFrame that points to a BigLake table. - import bigframes.core.array_value as array_value - import bigframes.core.blocks as blocks - import bigframes.core.bq_data as bq_data - import bigframes.dataframe - - # Define the BigLake table - project_id = "my-project" - catalog_id = "my-catalog" - namespace_id = "my-namespace" - table_id = "my-table" - schema = (google.cloud.bigquery.SchemaField("col", "INTEGER"),) - - biglake_table = bq_data.BiglakeIcebergTable( - project_id=project_id, - catalog_id=catalog_id, - namespace_id=namespace_id, - table_id=table_id, - physical_schema=schema, - cluster_cols=(), - metadata=bq_data.TableMetadata( - location=bq_data.BigQueryRegion("us-central1"), - type="TABLE", - ), - ) - - # ArrayValue.from_table is what read_gbq uses. - av = array_value.ArrayValue.from_table(biglake_table, session) - block = blocks.Block(av, index_columns=[], column_labels=["col"]) - df = bigframes.dataframe.DataFrame(block) - - pyformat_args = {"df": df} - sql = "SELECT * FROM {df}" - - got_sql = pyformat.pyformat(sql, pyformat_args=pyformat_args, session=session) - - # For BigLake, we now expect a SUBQUERY, not a view reference. - # The subquery should have correctly quoted 4-part ID. - assert "SELECT" in got_sql - assert project_id in got_sql - assert catalog_id in got_sql - assert namespace_id in got_sql - assert table_id in got_sql - assert got_sql.startswith("SELECT * FROM (SELECT") - assert got_sql.endswith(")") diff --git a/tests/unit/core/test_sql.py b/tests/unit/core/test_sql.py index 04ebb28764d..17da3008fc4 100644 --- a/tests/unit/core/test_sql.py +++ b/tests/unit/core/test_sql.py @@ -12,9 +12,128 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime +import decimal +import re + +import pytest +import shapely.geometry # type: ignore + from bigframes.core import sql +@pytest.mark.parametrize( + ("value", "expected_pattern"), + ( + # Try to have some literals for each scalar data type: + # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-types + (None, "NULL"), + # TODO: support ARRAY type (possibly another method?) + (True, "True"), + (False, "False"), + ( + b"\x01\x02\x03ABC", + re.escape(r"b'\x01\x02\x03ABC'"), + ), + ( + datetime.date(2025, 1, 1), + re.escape("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), + 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, 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, re.escape("123.75")), + # TODO: support RANGE type + ("abc", re.escape("'abc'")), + # TODO: support STRUCT type (possibly another method?) + ( + datetime.time(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 + ), + re.escape("TIMESTAMP('2025-01-02T03:45:06.789123+00:00')"), + ), + ), +) +def test_simple_literal(value, expected_pattern): + got = sql.simple_literal(value) + 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(): result_query = sql.create_vector_search_sql( sql_string="SELECT embedding FROM my_embeddings_table WHERE id = 1", @@ -61,6 +180,6 @@ def test_create_vector_search_sql_all_named_parameters(): query_column_to_search => 'another_embedding_column', top_k=> 10, distance_type => 'cosine', -options => '{"fraction_lists_to_search": 0.1, "use_brute_force": false}') +options => '{\\"fraction_lists_to_search\\": 0.1, \\"use_brute_force\\": false}') """ ) diff --git a/tests/unit/core/tools/test_bigquery_schema.py b/tests/unit/core/tools/test_bigquery_schema.py index 2b6693c13e6..aed8ae03231 100644 --- a/tests/unit/core/tools/test_bigquery_schema.py +++ b/tests/unit/core/tools/test_bigquery_schema.py @@ -1,5 +1,5 @@ -import pytest from google.cloud import bigquery +import pytest from bigframes.core.tools import bigquery_schema diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py deleted file mode 100644 index 25f19cf495c..00000000000 --- a/tests/unit/display/test_anywidget.py +++ /dev/null @@ -1,543 +0,0 @@ -# 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 pyarrow as pa -import pytest - -import bigframes - -# Skip if anywidget/traitlets not installed, though they should be in the dev env -pytest.importorskip("anywidget") -pytest.importorskip("traitlets") - -import bigframes.dataframe -import bigframes.dtypes -import bigframes.series -from bigframes.display.anywidget import TableWidget - - -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. - """ - 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"), - bigframes.option_context( - "display.render_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) - - -def test_css_contains_dark_mode_selectors(): - """Test that the CSS for dark mode is loaded with all required selectors.""" - mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) - # mock_df.columns and mock_df.dtypes are needed for __init__ - mock_df.columns = ["col1"] - mock_df.dtypes = {"col1": "object"} - - # Mock _block to avoid AttributeError during _set_table_html - mock_block = mock.Mock() - mock_block.has_index = False - mock_df._block = mock_block - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df) - css = widget._css - assert "@media (prefers-color-scheme: dark)" in css - assert 'html[theme="dark"]' in css - assert 'body[data-theme="dark"]' in css - - -@pytest.fixture -def mock_df(): - """A mock DataFrame that can be used in multiple tests.""" - df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) - df.columns = ["col1", "col2"] - df.dtypes = {"col1": "int64", "col2": "int64"} - - mock_block = mock.Mock() - mock_block.has_index = False - df._block = mock_block - - # Mock to_pandas_batches to return empty iterator or simple data - batch_df = pd.DataFrame({"col1": [1, 2], "col2": [3, 4]}) - batches = mock.MagicMock() - batches.__iter__.return_value = iter([batch_df]) - batches.total_rows = 2 - df.to_pandas_batches.return_value = batches - - # Mock sort_values to return self (for chaining) - df.sort_values.return_value = df - - return df - - -def test_sorting_single_column(mock_df): - """Test that the widget can be sorted by a single column.""" - with bigframes.option_context("display.render_mode", "anywidget"): - widget = TableWidget(mock_df) - - # Verify initial state - assert widget.sort_context == [] - - # Apply sort - widget.sort_context = [{"column": "col1", "ascending": True}] - - # This should trigger _sort_changed -> _set_table_html - # which calls df.sort_values - - mock_df.sort_values.assert_called_with(by=["col1"], ascending=[True]) - - -def test_sorting_multi_column(mock_df): - """Test that the widget can be sorted by multiple columns.""" - with bigframes.option_context("display.render_mode", "anywidget"): - widget = TableWidget(mock_df) - - # Apply multi-column sort - widget.sort_context = [ - {"column": "col1", "ascending": True}, - {"column": "col2", "ascending": False}, - ] - - mock_df.sort_values.assert_called_with(by=["col1", "col2"], ascending=[True, False]) - - -def test_page_size_change_resets_sort(mock_df): - """Test that changing the page size resets the sorting.""" - with bigframes.option_context("display.render_mode", "anywidget"): - widget = TableWidget(mock_df) - - # Set sort state - widget.sort_context = [{"column": "col1", "ascending": True}] - - # Change page size - widget.page_size = 50 - - # Sort should be reset - assert widget.sort_context == [] - - # to_pandas_batches called again (reset) - assert mock_df.to_pandas_batches.call_count >= 2 - - -def test_cell_execution_count_propagation(mock_df): - """Test that the captured cell_execution_count is propagated to to_pandas_batches.""" - with ( - mock.patch("bigframes.core.utils.get_ipython_execution_count", return_value=42), - bigframes.option_context("display.render_mode", "anywidget"), - ): - widget = TableWidget(mock_df) - - assert widget._cell_execution_count == 42 - - mock_df.to_pandas_batches.assert_called_with( - page_size=widget.page_size, - cell_execution_count=42, - ) - - -def test_json_column_converted_to_string_for_display(polars_session): - series = bigframes.series.Series( - ['{"a": 1}', '{"b": 2}'], - dtype=bigframes.dtypes.JSON_DTYPE, - session=polars_session, - ) - df = series.to_frame("col_json") - - result = df._prepare_display_df() - - assert result["col_json"].dtype == bigframes.dtypes.STRING_DTYPE - - -def test_struct_column_with_nested_json_converted_to_string_for_display( - polars_session, -): - if not hasattr(pa, "json_"): - pytest.skip(reason=f"pyarrow=={pa.__version__} does not support json_") - - # Arrange - json_type = pa.json_(storage_type=pa.utf8()) - json_data = pa.array(['{"a": 1}'], type=json_type) - string_data = pa.array(["hello"], type=pa.string()) - struct_data = pa.StructArray.from_arrays( - [string_data, json_data], names=["field1", "field2"] - ) - nested_data = pa.table([struct_data], names=["nested"]) - df = polars_session.read_arrow(nested_data) - exploded = df["nested"].struct.explode() - # Ensure that we are actually using the JSON dtype in this test. - assert exploded["field2"].dtype == bigframes.dtypes.JSON_DTYPE - - # Act - result = df._prepare_display_df() - - # Assert - assert result["nested"].dtype == bigframes.dtypes.STRING_DTYPE - - -@pytest.fixture -def mock_df_deferred(): - with mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", True): - df = mock.Mock(spec=bigframes.dataframe.DataFrame) - df.shape = (100, 4) - df.columns = ["A", "B", "C", "D"] - df.dtypes = { - "A": bigframes.dtypes.INT_DTYPE, - "B": bigframes.dtypes.STRING_DTYPE, - "C": bigframes.dtypes.FLOAT_DTYPE, - "D": bigframes.dtypes.BOOL_DTYPE, - } - - df.to_pandas_batches.return_value = iter( - [pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]})] - ) - - df.sort_values.return_value = df - - df._block = mock.Mock() - df._block.has_index = False - df._prepare_display_df.return_value = df - - yield df - - -@pytest.fixture -def mock_deferred_df(): - from bigframes.session.deferred import DeferredBigQueryDataFrame - - with mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", True): - # We create a mock that subclasses DeferredBigQueryDataFrame so isinstance passes - class MockDeferredBigQueryDataFrame(DeferredBigQueryDataFrame): - def __init__(self): - pass - - df = mock.MagicMock(spec=MockDeferredBigQueryDataFrame) - df.__class__ = DeferredBigQueryDataFrame # type: ignore[assignment] - yield df - - -def test_init_raises_if_anywidget_not_installed(): - with ( - mock.patch("bigframes.display.anywidget._ANYWIDGET_INSTALLED", False), - pytest.raises(ImportError), - ): - from bigframes.display.anywidget import TableWidget - - TableWidget(mock.Mock()) - - -def test_init_initializes_attributes(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with ( - bigframes.option_context("display.render_mode", "anywidget"), - mock.patch.object(TableWidget, "_initial_load"), - ): - widget = TableWidget(mock_df_deferred) - - assert widget._dataframe is mock_df_deferred - assert widget.page == 0 - assert widget.page_size > 0 - assert widget.orderable_columns == [ - "A", - "B", - "C", - "D", - ] - - -def test_init_calls_initial_load(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with mock.patch.object(TableWidget, "_initial_load") as mock_load: - TableWidget(mock_df_deferred) - mock_load.assert_called_once() - - -def test_validate_page_clamping(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df_deferred) - widget.row_count = 100 - widget.page_size = 10 - - widget.page = 5 - assert widget.page == 5 - - with pytest.raises(ValueError): - widget.page = -1 - - widget.page = 100 - assert widget.page == 9 - - -def test_validate_page_size(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with ( - bigframes.option_context("display.render_mode", "anywidget"), - mock.patch.object(TableWidget, "_initial_load"), - ): - widget = TableWidget(mock_df_deferred) - - widget.page_size = 50 - assert widget.page_size == 50 - - original_size = widget.page_size - widget.page_size = -5 - assert widget.page_size == original_size - - widget.page_size = 10000 - assert widget.page_size == 1000 - - -def test_page_size_change_resets_page_and_sort(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df_deferred) - widget._initial_load_complete = True - widget.page = 5 - widget.sort_context = [{"column": "A", "ascending": True}] - - widget.page_size = 20 - - assert widget.page == 0 - assert widget.sort_context == [] - - -def test_page_size_change_resets_batches(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df_deferred) - widget._initial_load_complete = True - - widget.page_size = 50 - - mock_df_deferred.to_pandas_batches.assert_called() - - -def test_sort_change_resets_batches(mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - with ( - bigframes.option_context("display.render_mode", "anywidget"), - mock.patch.object(TableWidget, "_initial_load"), - ): - widget = TableWidget(mock_df_deferred) - widget._initial_load_complete = True - - mock_df_deferred.to_pandas_batches.reset_mock() - - widget.sort_context = [{"column": "B", "ascending": False}] - - assert mock_df_deferred.to_pandas_batches.call_count >= 1 - - -def test_deferred_mode_initialization(mock_deferred_df): - from bigframes.display.anywidget import TableWidget - - with mock.patch.object(TableWidget, "_initial_load") as mock_load: - widget = TableWidget(mock_deferred_df) - - assert widget.is_deferred_mode is True - mock_load.assert_not_called() - - -def test_deferred_mode_execution(mock_deferred_df, mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - mock_deferred_df.execute.return_value = mock_df_deferred - - widget = TableWidget(mock_deferred_df) - - assert widget.is_deferred_mode is True - - import bigframes - - with bigframes.option_context( - "display.render_mode", bigframes.options.display.render_mode - ): - widget.start_execution = True - - thread = getattr(widget, "_execution_thread", None) - if thread is not None: - thread.join(timeout=5) - - mock_deferred_df.execute.assert_called_once() - mock_df_deferred.to_pandas_batches.assert_called_once() - assert widget.is_deferred_mode is False - - -def test_deferred_mode_execution_updates_table_html(mock_deferred_df, mock_df_deferred): - from bigframes.display.anywidget import TableWidget - - mock_deferred_df.execute.return_value = mock_df_deferred - - batches = mock.MagicMock() - batch_df = pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]}) - batches.__iter__.return_value = iter([batch_df]) - batches.total_rows = 1 - mock_df_deferred.to_pandas_batches.return_value = batches - - with bigframes.option_context("display.render_mode", "anywidget"): - widget = TableWidget(mock_deferred_df) - widget.is_deferred_mode = True - widget._deferred_dataframe = mock_deferred_df - assert widget.table_html == "" - - widget.start_execution = True - thread = getattr(widget, "_execution_thread", None) - if thread is not None: - thread.join(timeout=5) - - assert widget.is_deferred_mode is False - assert widget.table_html != "" - assert "table" in widget.table_html - - -def test_deferred_mode_execution_error(mock_deferred_df): - from bigframes.display.anywidget import TableWidget - - mock_deferred_df.execute.side_effect = RuntimeError("Query Failed") - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_deferred_df) - - import bigframes - - with bigframes.option_context( - "display.render_mode", bigframes.options.display.render_mode - ): - widget.start_execution = True - - thread = getattr(widget, "_execution_thread", None) - if thread is not None: - thread.join(timeout=5) - - assert widget.is_deferred_mode is True - assert widget._error_message == "Query Failed" - - -def test_deferred_mode_execution_does_not_reset_page_on_navigation( - mock_deferred_df, mock_df_deferred -): - from bigframes.display.anywidget import TableWidget - - mock_deferred_df.execute.return_value = mock_df_deferred - - batches = mock.MagicMock() - batch_df = pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]}) - batches.__iter__.return_value = iter([batch_df]) - batches.total_rows = 50 - mock_df_deferred.to_pandas_batches.return_value = batches - - with bigframes.option_context("display.render_mode", "anywidget"): - widget = TableWidget(mock_deferred_df) - widget.page_size = 10 - widget.start_execution = True - - thread = getattr(widget, "_execution_thread", None) - if thread is not None: - thread.join(timeout=5) - - assert widget.page == 0 - widget.page = 1 - assert widget.page == 1 - - -def test_deferred_mode_execution_in_colab(mock_deferred_df, mock_df_deferred): - import sys - - from bigframes.display.anywidget import TableWidget - - mock_deferred_df.execute.return_value = mock_df_deferred - - batches = mock.MagicMock() - batch_df = pd.DataFrame({"A": [1], "B": ["a"], "C": [1.0], "D": [True]}) - batches.__iter__.return_value = iter([batch_df]) - batches.total_rows = 1 - mock_df_deferred.to_pandas_batches.return_value = batches - - with ( - mock.patch.dict(sys.modules, {"google.colab": mock.MagicMock()}), - bigframes.option_context("display.render_mode", "anywidget"), - ): - widget = TableWidget(mock_deferred_df) - widget.is_deferred_mode = True - - widget.start_execution = True - - thread = getattr(widget, "_execution_thread", None) - if thread is not None: - thread.join(timeout=5) - - assert widget.is_deferred_mode is True - assert widget.table_html == "" - - # Simulate frontend ping callback - widget.ping = 1 - - assert widget.is_deferred_mode is False - assert widget.table_html != "" diff --git a/tests/unit/display/test_html.py b/tests/unit/display/test_html.py index 239033861a4..fcf14553620 100644 --- a/tests/unit/display/test_html.py +++ b/tests/unit/display/test_html.py @@ -13,7 +13,6 @@ # limitations under the License. import datetime -from unittest.mock import Mock, patch import pandas as pd import pyarrow as pa @@ -131,8 +130,9 @@ def test_render_html_alignment_and_precision( 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 _, align in expected_alignments.items(): + assert 'th style="text-align: left;"' in html + assert f' 2 left, 2 right. col_0, col_1 ... col_8, col_9 - html = bf_html.render_html(dataframe=df, table_id="test", max_columns=4) - - assert "col_0" in html - assert "col_1" in html - assert "col_2" not in html - assert "col_7" not in html - assert "col_8" in html - assert "col_9" in html - assert "..." in html - - # Test max_columns=3 - # 3 // 2 = 1. Left: col_0. Right: 3 - 1 = 2. col_8, col_9. - # Total displayed: col_0, ..., col_8, col_9. (3 data cols + 1 ellipsis) - html = bf_html.render_html(dataframe=df, table_id="test", max_columns=3) - assert "col_0" in html - assert "col_1" not in html - assert "col_7" not in html - assert "col_8" in html - assert "col_9" in html - - # Test max_columns=1 - # 1 // 2 = 0. Left: []. Right: 1. col_9. - # Total: ..., col_9. - html = bf_html.render_html(dataframe=df, table_id="test", max_columns=1) - assert "col_0" not in html - assert "col_8" not in html - assert "col_9" in html - assert "..." in html - - -def test_repr_mimebundle_head(): - mock_df = Mock() - mock_df.columns = ["col1"] - - mock_df._prepare_display_df.return_value = mock_df - - # Mock the call to retrieve_repr_request_results - pandas_df = pd.DataFrame({"col1": [1, 2, 3]}) - mock_df._block.retrieve_repr_request_results.return_value = ( - pandas_df, - 3, - Mock(), # query_job - ) - - # Mock _get_obj_metadata - with ( - patch("bigframes.display.html._get_obj_metadata", return_value=(False, False)), - patch( - "bigframes.display.html.create_html_representation", return_value="" - ) as mock_create_html, - patch( - "bigframes.display.plaintext.create_text_representation", - return_value="text", - ) as mock_create_text, - ): - bundle = bf_html.repr_mimebundle_head(mock_df) - - assert bundle == {"text/html": "", "text/plain": "text"} - mock_df._prepare_display_df.assert_called_once() - mock_df._block.retrieve_repr_request_results.assert_called_once() - mock_create_html.assert_called_once() - mock_create_text.assert_called_once() diff --git a/tests/unit/display/test_render_mode.py b/tests/unit/display/test_render_mode.py deleted file mode 100644 index 478bfd30eaf..00000000000 --- a/tests/unit/display/test_render_mode.py +++ /dev/null @@ -1,120 +0,0 @@ -# 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.display.html as bf_html -import bigframes.pandas as bpd - - -def test_render_mode_options(): - assert bpd.options.display.render_mode == "html" - - with bpd.option_context("display.render_mode", "plaintext"): - assert bpd.options.display.render_mode == "plaintext" - - with bpd.option_context("display.render_mode", "html"): - assert bpd.options.display.render_mode == "html" - - with bpd.option_context("display.render_mode", "anywidget"): - assert bpd.options.display.render_mode == "anywidget" - - -def test_repr_mimebundle_selection_logic(): - mock_obj = mock.Mock() - - # Mocking dependencies - with ( - mock.patch("bigframes.display.html.repr_mimebundle_head") as mock_head, - mock.patch("bigframes.display.html.get_anywidget_bundle") as mock_anywidget, - mock.patch("bigframes.display.html.repr_mimebundle_deferred") as mock_deferred, - ): - mock_head.side_effect = lambda obj: {"text/plain": "plain", "text/html": "html"} - mock_anywidget.return_value = ( - { - "application/vnd.jupyter.widget-view+json": {}, - "text/plain": "plain", - "text/html": "html", - }, - {}, - ) - mock_deferred.return_value = {"text/plain": "deferred"} - - # Test deferred repr_mode when anywidget is available - with bpd.option_context("display.repr_mode", "deferred"): - bundle = bf_html.repr_mimebundle(mock_obj) - assert "application/vnd.jupyter.widget-view+json" in bundle[0] - mock_anywidget.assert_called_once() - mock_deferred.assert_not_called() - - mock_anywidget.reset_mock() - - # Test fallback to static deferred repr when anywidget fails - mock_anywidget.side_effect = Exception("Anywidget failed") - with ( - bpd.option_context("display.repr_mode", "deferred"), - pytest.warns(UserWarning, match="Anywidget mode is not available"), - ): - bundle = bf_html.repr_mimebundle(mock_obj) - assert bundle == {"text/plain": "deferred"} - mock_deferred.assert_called_once() - - mock_anywidget.side_effect = None - mock_deferred.reset_mock() - mock_anywidget.reset_mock() - - # Test plaintext render_mode - with bpd.option_context("display.render_mode", "plaintext"): - bundle = bf_html.repr_mimebundle(mock_obj) - assert "text/plain" in bundle - assert "text/html" not in bundle - mock_head.assert_called_once() - - mock_head.reset_mock() - - # Test html render_mode - with bpd.option_context("display.render_mode", "html"): - bundle = bf_html.repr_mimebundle(mock_obj) - assert "text/plain" in bundle - assert "text/html" in bundle - mock_head.assert_called_once() - - mock_head.reset_mock() - - # Test anywidget render_mode - with bpd.option_context("display.render_mode", "anywidget"): - bundle = bf_html.repr_mimebundle(mock_obj) - assert "application/vnd.jupyter.widget-view+json" in bundle[0] - mock_anywidget.assert_called_once() - mock_head.assert_not_called() - - mock_anywidget.reset_mock() - - # Test anywidget repr_mode (backward compatibility) - with bpd.option_context("display.repr_mode", "anywidget"): - bundle = bf_html.repr_mimebundle(mock_obj) - assert "application/vnd.jupyter.widget-view+json" in bundle[0] - mock_anywidget.assert_called_once() - mock_head.assert_not_called() - - mock_anywidget.reset_mock() - - # Test default render_mode (should be "html") - bundle = bf_html.repr_mimebundle(mock_obj) - assert "text/plain" in bundle - assert "text/html" in bundle - mock_head.assert_called_once() - mock_anywidget.assert_not_called() diff --git a/tests/unit/extensions/__init__.py b/tests/unit/extensions/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/extensions/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/extensions/bigframes/__init__.py b/tests/unit/extensions/bigframes/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/extensions/bigframes/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/extensions/bigframes/test_series_accessor.py b/tests/unit/extensions/bigframes/test_series_accessor.py deleted file mode 100644 index 4c74b60a1a0..00000000000 --- a/tests/unit/extensions/bigframes/test_series_accessor.py +++ /dev/null @@ -1,78 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.mock import MagicMock, patch - -import pytest - -import bigframes.series as series -from bigframes.testing import mocks - - -def test_bigframes_series_has_accessor(monkeypatch: pytest.MonkeyPatch): - # Arrange - from bigframes.extensions.bigframes.series_accessor import ( - BigframesBigQuerySeriesAccessor, - ) - - bf_df = mocks.create_dataframe(monkeypatch, data={"col": [1, 2]}) - bf_series = cast(series.Series, bf_df["col"]) - - # Act - has_bq = hasattr(bf_series, "bigquery") - bq_obj = bf_series.bigquery - - # Assert - assert has_bq - assert isinstance(bq_obj, BigframesBigQuerySeriesAccessor) - - -@patch("bigframes.operations.googlesql.global_namespace.array.array_length") -def test_bigframes_series_accessor_global_routing( - mock_array_length, monkeypatch: pytest.MonkeyPatch -): - # Arrange - bf_df = mocks.create_dataframe(monkeypatch, data={"col": [[1, 2], [3, 4, 5]]}) - bf_series = cast(series.Series, bf_df["col"]) - mock_result_series = MagicMock() - mock_array_length.return_value = mock_result_series - - # Act - result = bf_series.bigquery.array_length() - - # Assert - mock_array_length.assert_called_once_with(bf_series) - assert result is mock_result_series - - -@patch("bigframes.operations.googlesql.aead.encrypt") -def test_bigframes_series_accessor_namespaced_routing( - mock_encrypt, monkeypatch: pytest.MonkeyPatch -): - # Arrange - bf_df = mocks.create_dataframe(monkeypatch, data={"keyset": [b"key1", b"key2"]}) - keyset_series = cast(series.Series, bf_df["keyset"]) - mock_result_series = MagicMock() - mock_encrypt.return_value = mock_result_series - - plaintext = "my secret" - additional_data = "context" - - # Act - result = keyset_series.bigquery.aead.encrypt(plaintext, additional_data) - - # Assert - mock_encrypt.assert_called_once_with(keyset_series, plaintext, additional_data) - assert result is mock_result_series diff --git a/tests/unit/extensions/core/__init__.py b/tests/unit/extensions/core/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/extensions/core/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/extensions/core/test_dataframe_accessor.py b/tests/unit/extensions/core/test_dataframe_accessor.py deleted file mode 100644 index c207070bb15..00000000000 --- a/tests/unit/extensions/core/test_dataframe_accessor.py +++ /dev/null @@ -1,525 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 pandas as pd - -import bigframes.bigquery.ai -import bigframes.pandas as bpd -import bigframes.session - - -def test_ai_forecast(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_df = mock.create_autospec(bpd.DataFrame) - session.read_pandas.return_value = bf_df - - mock_forecast = mock.MagicMock() - forecast_result_df = mock.create_autospec(bpd.DataFrame) - mock_forecast.return_value = forecast_result_df - expected_result = mock.create_autospec(pd.DataFrame) - forecast_result_df.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "forecast", mock_forecast) - - df = pd.DataFrame({"date": ["2020-01-01"], "value": [1.0]}) - actual_result = df.bigquery.ai.forecast( - timestamp_col="date", - data_col="value", - horizon=5, - session=session, - ) - - session.read_pandas.assert_called_once() - - mock_forecast.assert_called_once_with( - bf_df, - timestamp_col="date", - data_col="value", - model="TimesFM 2.0", - id_cols=None, - horizon=5, - confidence_level=0.95, - context_window=None, - output_historical_time_series=False, - ) - forecast_result_df.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_forecast(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - forecast_result = mock.create_autospec(bpd.DataFrame) - mock_forecast = mock.MagicMock() - mock_forecast.return_value = forecast_result - - monkeypatch.setattr(bigframes.bigquery.ai, "forecast", mock_forecast) - - actual_result = scalar_types_df.bigquery.ai.forecast( - timestamp_col="date", - data_col="value", - horizon=5, - session=session, - ) - - session.read_pandas.assert_not_called() - mock_forecast.assert_called_once() - args, kwargs = mock_forecast.call_args - assert args[0] is scalar_types_df - assert kwargs == { - "timestamp_col": "date", - "data_col": "value", - "model": "TimesFM 2.0", - "id_cols": None, - "horizon": 5, - "confidence_level": 0.95, - "context_window": None, - "output_historical_time_series": False, - } - # BigFrames accessor returns the bf_df directly without calling to_pandas - forecast_result.to_pandas.assert_not_called() - assert actual_result is forecast_result - - -def test_ai_generate(monkeypatch): - mock_generate = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_generate.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate", mock_generate) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["Is this a positive review?"]}) - actual_result = df.bigquery.ai.generate( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - output_schema={"res": "STRING"}, - ) - - mock_generate.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - output_schema={"res": "STRING"}, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_generate = mock.MagicMock() - mock_generate.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "generate", mock_generate) - - actual_result = scalar_types_df.bigquery.ai.generate( - bf_series, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - output_schema={"res": "STRING"}, - ) - - mock_generate.assert_called_once() - args, kwargs = mock_generate.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "request_type": "dedicated", - "model_params": {"temp": 0.5}, - "output_schema": {"res": "STRING"}, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_generate_bool(monkeypatch): - mock_generate_bool = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_generate_bool.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_bool", mock_generate_bool) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["Is this a positive review?"]}) - actual_result = df.bigquery.ai.generate_bool( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_bool.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_bool(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_generate_bool = mock.MagicMock() - mock_generate_bool.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_bool", mock_generate_bool) - - actual_result = scalar_types_df.bigquery.ai.generate_bool( - bf_series, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_bool.assert_called_once() - args, kwargs = mock_generate_bool.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "request_type": "dedicated", - "model_params": {"temp": 0.5}, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_generate_int(monkeypatch): - mock_generate_int = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_generate_int.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_int", mock_generate_int) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["How many legs?"]}) - actual_result = df.bigquery.ai.generate_int( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_int.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_int(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_generate_int = mock.MagicMock() - mock_generate_int.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_int", mock_generate_int) - - actual_result = scalar_types_df.bigquery.ai.generate_int( - bf_series, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_int.assert_called_once() - args, kwargs = mock_generate_int.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "request_type": "dedicated", - "model_params": {"temp": 0.5}, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_generate_double(monkeypatch): - mock_generate_double = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_generate_double.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_double", mock_generate_double) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["How tall?"]}) - actual_result = df.bigquery.ai.generate_double( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_double.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_double(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_generate_double = mock.MagicMock() - mock_generate_double.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_double", mock_generate_double) - - actual_result = scalar_types_df.bigquery.ai.generate_double( - bf_series, - connection_id="conn", - endpoint="endpoint", - request_type="dedicated", - model_params={"temp": 0.5}, - ) - - mock_generate_double.assert_called_once() - args, kwargs = mock_generate_double.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "request_type": "dedicated", - "model_params": {"temp": 0.5}, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_classify(monkeypatch): - mock_classify = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_classify.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "classify", mock_classify) - - input_prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["Is this a positive review?"]}) - actual_result = df.bigquery.ai.classify( - input_prompt, - categories=["Mammal", "Fish"], - examples=[("Cat", "Mammal")], - connection_id="conn", - endpoint="endpoint", - output_mode="single", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - - mock_classify.assert_called_once_with( - input_prompt, - ["Mammal", "Fish"], - examples=[("Cat", "Mammal")], - connection_id="conn", - endpoint="endpoint", - output_mode="single", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_classify(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_classify = mock.MagicMock() - mock_classify.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "classify", mock_classify) - - actual_result = scalar_types_df.bigquery.ai.classify( - bf_series, - categories=["Mammal", "Fish"], - examples=[("Cat", "Mammal")], - connection_id="conn", - endpoint="endpoint", - output_mode="single", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - - mock_classify.assert_called_once() - args, kwargs = mock_classify.call_args - assert args[0] is bf_series - assert args[1] == ["Mammal", "Fish"] - assert kwargs == { - "examples": [("Cat", "Mammal")], - "connection_id": "conn", - "endpoint": "endpoint", - "output_mode": "single", - "optimization_mode": "minimize_cost", - "max_error_ratio": 0.1, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_if(monkeypatch): - mock_if = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_if.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "if_", mock_if) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["Is this a positive review?"]}) - actual_result = df.bigquery.ai.if_( - prompt, - connection_id="conn", - endpoint="endpoint", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - - mock_if.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_if(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_if = mock.MagicMock() - mock_if.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "if_", mock_if) - - actual_result = scalar_types_df.bigquery.ai.if_( - bf_series, - connection_id="conn", - endpoint="endpoint", - optimization_mode="minimize_cost", - max_error_ratio=0.1, - ) - - mock_if.assert_called_once() - args, kwargs = mock_if.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "optimization_mode": "minimize_cost", - "max_error_ratio": 0.1, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_score(monkeypatch): - mock_score = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_score.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "score", mock_score) - - prompt = mock.create_autospec(pd.Series) - df = pd.DataFrame({"text_input": ["Is this a positive review?"]}) - actual_result = df.bigquery.ai.score( - prompt, - connection_id="conn", - endpoint="endpoint", - max_error_ratio=0.1, - ) - - mock_score.assert_called_once_with( - prompt, - connection_id="conn", - endpoint="endpoint", - max_error_ratio=0.1, - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_score(scalar_types_df: bpd.DataFrame, monkeypatch): - bf_series = mock.create_autospec(bpd.Series) - result_series = mock.create_autospec(bpd.Series) - - mock_score = mock.MagicMock() - mock_score.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "score", mock_score) - - actual_result = scalar_types_df.bigquery.ai.score( - bf_series, - connection_id="conn", - endpoint="endpoint", - max_error_ratio=0.1, - ) - - mock_score.assert_called_once() - args, kwargs = mock_score.call_args - assert args[0] is bf_series - assert kwargs == { - "connection_id": "conn", - "endpoint": "endpoint", - "max_error_ratio": 0.1, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series diff --git a/tests/unit/extensions/core/test_series_mixins.py b/tests/unit/extensions/core/test_series_mixins.py deleted file mode 100644 index c6e7e4078b1..00000000000 --- a/tests/unit/extensions/core/test_series_mixins.py +++ /dev/null @@ -1,384 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 pandas as pd - -import bigframes.bigquery.ai -import bigframes.pandas as bpd -import bigframes.session - - -def test_ai_generate_embedding(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_series = mock.create_autospec(bpd.Series) - session.read_pandas.return_value = bf_series - - mock_generate_embedding = mock.MagicMock() - result_df = mock.create_autospec(bpd.DataFrame) - mock_generate_embedding.return_value = result_df - expected_result = mock.create_autospec(pd.DataFrame) - result_df.to_pandas.return_value = expected_result - - monkeypatch.setattr( - bigframes.bigquery.ai, "generate_embedding", mock_generate_embedding - ) - - series = pd.Series(["apple"], name="content") - actual_result = series.bigquery.ai.generate_embedding( # type: ignore - model="my_model", - output_dimensionality=256, - task_type="retrieval_document", - start_second=1.0, - end_second=2.0, - interval_seconds=3.0, - trial_id=4, - session=session, - ) - - session.read_pandas.assert_called_once() - mock_generate_embedding.assert_called_once_with( - "my_model", - bf_series, - output_dimensionality=256, - task_type="retrieval_document", - start_second=1.0, - end_second=2.0, - interval_seconds=3.0, - trial_id=4, - ) - result_df.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_embedding(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - result_df = mock.create_autospec(bpd.DataFrame) - - mock_generate_embedding = mock.MagicMock() - mock_generate_embedding.return_value = result_df - - monkeypatch.setattr( - bigframes.bigquery.ai, "generate_embedding", mock_generate_embedding - ) - - scalar_types_series = scalar_types_df["string_col"] - actual_result = scalar_types_series.bigquery.ai.generate_embedding( - model="my_model", - output_dimensionality=256, - session=session, - ) - - session.read_pandas.assert_not_called() - mock_generate_embedding.assert_called_once() - args, kwargs = mock_generate_embedding.call_args - assert args[0] == "my_model" - assert args[1] is scalar_types_series - assert kwargs == { - "output_dimensionality": 256, - "task_type": None, - "start_second": None, - "end_second": None, - "interval_seconds": None, - "trial_id": None, - } - result_df.to_pandas.assert_not_called() - assert actual_result is result_df - - -def test_ai_generate_text(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_series = mock.create_autospec(bpd.Series) - session.read_pandas.return_value = bf_series - - mock_generate_text = mock.MagicMock() - result_df = mock.create_autospec(bpd.DataFrame) - mock_generate_text.return_value = result_df - expected_result = mock.create_autospec(pd.DataFrame) - result_df.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_text", mock_generate_text) - - series = pd.Series(["write a poem"], name="prompt") - actual_result = series.bigquery.ai.generate_text( # type: ignore - model="my_model", - temperature=0.7, - max_output_tokens=100, - top_k=50, - top_p=0.9, - stop_sequences=["\n"], - ground_with_google_search=True, - request_type="dedicated", - session=session, - ) - - session.read_pandas.assert_called_once() - mock_generate_text.assert_called_once_with( - "my_model", - bf_series, - temperature=0.7, - max_output_tokens=100, - top_k=50, - top_p=0.9, - stop_sequences=["\n"], - ground_with_google_search=True, - request_type="dedicated", - ) - result_df.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_text(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - result_df = mock.create_autospec(bpd.DataFrame) - - mock_generate_text = mock.MagicMock() - mock_generate_text.return_value = result_df - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_text", mock_generate_text) - - scalar_types_series = scalar_types_df["string_col"] - actual_result = scalar_types_series.bigquery.ai.generate_text( - model="my_model", - temperature=0.7, - session=session, - ) - - session.read_pandas.assert_not_called() - mock_generate_text.assert_called_once() - args, kwargs = mock_generate_text.call_args - assert args[0] == "my_model" - assert args[1] is scalar_types_series - assert kwargs == { - "temperature": 0.7, - "max_output_tokens": None, - "top_k": None, - "top_p": None, - "stop_sequences": None, - "ground_with_google_search": None, - "request_type": None, - } - result_df.to_pandas.assert_not_called() - assert actual_result is result_df - - -def test_ai_generate_table(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_series = mock.create_autospec(bpd.Series) - session.read_pandas.return_value = bf_series - - mock_generate_table = mock.MagicMock() - result_df = mock.create_autospec(bpd.DataFrame) - mock_generate_table.return_value = result_df - expected_result = mock.create_autospec(pd.DataFrame) - result_df.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_table", mock_generate_table) - - series = pd.Series(["generate something"], name="prompt") - actual_result = series.bigquery.ai.generate_table( # type: ignore - model="my_model", - output_schema="category STRING", - temperature=0.7, - top_p=0.9, - max_output_tokens=100, - stop_sequences=["\n"], - request_type="dedicated", - session=session, - ) - - session.read_pandas.assert_called_once() - mock_generate_table.assert_called_once_with( - "my_model", - bf_series, - output_schema="category STRING", - temperature=0.7, - top_p=0.9, - max_output_tokens=100, - stop_sequences=["\n"], - request_type="dedicated", - ) - result_df.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_generate_table(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - result_df = mock.create_autospec(bpd.DataFrame) - - mock_generate_table = mock.MagicMock() - mock_generate_table.return_value = result_df - - monkeypatch.setattr(bigframes.bigquery.ai, "generate_table", mock_generate_table) - - scalar_types_series = scalar_types_df["string_col"] - actual_result = scalar_types_series.bigquery.ai.generate_table( - model="my_model", - output_schema="category STRING", - temperature=0.7, - session=session, - ) - - session.read_pandas.assert_not_called() - mock_generate_table.assert_called_once() - args, kwargs = mock_generate_table.call_args - assert args[0] == "my_model" - assert args[1] is scalar_types_series - assert kwargs == { - "output_schema": "category STRING", - "temperature": 0.7, - "top_p": None, - "max_output_tokens": None, - "stop_sequences": None, - "request_type": None, - } - result_df.to_pandas.assert_not_called() - assert actual_result is result_df - - -def test_ai_embed(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_series = mock.create_autospec(bpd.Series) - session.read_pandas.return_value = bf_series - - mock_embed = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_embed.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "embed", mock_embed) - - series = pd.Series(["hello world"], name="content") - actual_result = series.bigquery.ai.embed( # type: ignore - endpoint="my_endpoint", - model="my_model", - task_type="retrieval_query", - title="my_title", - model_params={"key": "val"}, - connection_id="my_connection", - session=session, - ) - - session.read_pandas.assert_called_once() - mock_embed.assert_called_once_with( - bf_series, - endpoint="my_endpoint", - model="my_model", - task_type="retrieval_query", - title="my_title", - model_params={"key": "val"}, - connection_id="my_connection", - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_embed(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - result_series = mock.create_autospec(bpd.Series) - - mock_embed = mock.MagicMock() - mock_embed.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "embed", mock_embed) - - scalar_types_series = scalar_types_df["string_col"] - actual_result = scalar_types_series.bigquery.ai.embed( - endpoint="my_endpoint", - session=session, - ) - - session.read_pandas.assert_not_called() - mock_embed.assert_called_once() - args, kwargs = mock_embed.call_args - assert args[0] is scalar_types_series - assert kwargs == { - "endpoint": "my_endpoint", - "model": None, - "task_type": None, - "title": None, - "model_params": None, - "connection_id": None, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series - - -def test_ai_similarity(monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - bf_series = mock.create_autospec(bpd.Series) - session.read_pandas.return_value = bf_series - - mock_similarity = mock.MagicMock() - result_series = mock.create_autospec(bpd.Series) - mock_similarity.return_value = result_series - expected_result = mock.create_autospec(pd.Series) - result_series.to_pandas.return_value = expected_result - - monkeypatch.setattr(bigframes.bigquery.ai, "similarity", mock_similarity) - - series = pd.Series(["apple"], name="content") - actual_result = series.bigquery.ai.similarity( # type: ignore - "banana", - endpoint="my_endpoint", - model="my_model", - model_params={"key": "val"}, - connection_id="my_connection", - session=session, - ) - - session.read_pandas.assert_called_once() - mock_similarity.assert_called_once_with( - bf_series, - "banana", - endpoint="my_endpoint", - model="my_model", - model_params={"key": "val"}, - connection_id="my_connection", - ) - result_series.to_pandas.assert_called_once() - assert actual_result is expected_result - - -def test_bigframes_ai_similarity(scalar_types_df: bpd.DataFrame, monkeypatch): - session = mock.create_autospec(bigframes.session.Session) - result_series = mock.create_autospec(bpd.Series) - - mock_similarity = mock.MagicMock() - mock_similarity.return_value = result_series - - monkeypatch.setattr(bigframes.bigquery.ai, "similarity", mock_similarity) - - scalar_types_series = scalar_types_df["string_col"] - actual_result = scalar_types_series.bigquery.ai.similarity( - "other_text", - endpoint="my_endpoint", - session=session, - ) - - session.read_pandas.assert_not_called() - mock_similarity.assert_called_once() - args, kwargs = mock_similarity.call_args - assert args[0] is scalar_types_series - assert args[1] == "other_text" - assert kwargs == { - "endpoint": "my_endpoint", - "model": None, - "model_params": None, - "connection_id": None, - } - result_series.to_pandas.assert_not_called() - assert actual_result is result_series diff --git a/tests/unit/extensions/pandas/__init__.py b/tests/unit/extensions/pandas/__init__.py deleted file mode 100644 index 58d482ea386..00000000000 --- a/tests/unit/extensions/pandas/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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/extensions/pandas/test_registration.py b/tests/unit/extensions/pandas/test_registration.py deleted file mode 100644 index 7007d6f9f2f..00000000000 --- a/tests/unit/extensions/pandas/test_registration.py +++ /dev/null @@ -1,29 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 - -# Importing bigframes registers the accessor. -import bigframes # noqa: F401 - - -def test_bigframes_import_registers_accessor(): - df = pd.DataFrame({"a": [1]}) - # If bigframes was imported, df.bigquery should exist - assert hasattr(df, "bigquery") - from bigframes.extensions.pandas.dataframe_accessor import ( - PandasBigQueryDataFrameAccessor, - ) - - assert isinstance(df.bigquery, PandasBigQueryDataFrameAccessor) diff --git a/tests/unit/extensions/pandas/test_series_accessor.py b/tests/unit/extensions/pandas/test_series_accessor.py deleted file mode 100644 index bfb68323f6d..00000000000 --- a/tests/unit/extensions/pandas/test_series_accessor.py +++ /dev/null @@ -1,136 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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.mock import MagicMock, patch - -import pandas as pd - -import bigframes # noqa: F401 registers pandas extensions -import bigframes.series as series - - -def test_pandas_series_registers_accessor(): - # Arrange - from bigframes.extensions.pandas.series_accessor import ( - PandasBigQuerySeriesAccessor, - ) - - s = pd.Series([1, 2]) - - # Act - has_bq = hasattr(s, "bigquery") - bq_obj = s.bigquery - - # Assert - assert has_bq - assert isinstance(bq_obj, PandasBigQuerySeriesAccessor) - - -@patch("bigframes.operations.googlesql.global_namespace.array.array_length") -def test_pandas_series_accessor_global_routing(mock_array_length): - # Arrange - mock_bf_series = MagicMock() - mock_bf_series.to_pandas.return_value = pd.Series([2, 3]) - mock_array_length.return_value = mock_bf_series - mock_session = MagicMock() - mock_bf_self = MagicMock() - mock_session.read_pandas.return_value = mock_bf_self - - s = pd.Series([[1, 2], [3, 4, 5]]) - - # Act - result = s.bigquery.array_length(session=mock_session) - - # Assert - mock_session.read_pandas.assert_called_once_with(s) - mock_array_length.assert_called_once_with(mock_bf_self) - mock_bf_series.to_pandas.assert_called_once_with(ordered=True) - pd.testing.assert_series_equal(result, pd.Series([2, 3])) - - -@patch("bigframes.operations.googlesql.aead.encrypt") -def test_pandas_series_accessor_namespaced_routing(mock_encrypt): - # Arrange - mock_bf_series = MagicMock() - mock_bf_series.to_pandas.return_value = pd.Series([b"encrypted1", b"encrypted2"]) - mock_encrypt.return_value = mock_bf_series - mock_session = MagicMock() - mock_bf_self = MagicMock() - mock_session.read_pandas.return_value = mock_bf_self - - keyset_series = pd.Series([b"key1", b"key2"]) - plaintext = "my secret" - additional_data = "context" - - # Act - result = keyset_series.bigquery.aead.encrypt( # type: ignore - plaintext, additional_data, session=mock_session - ) - - # Assert - mock_session.read_pandas.assert_called_once_with(keyset_series) - mock_encrypt.assert_called_once_with(mock_bf_self, plaintext, additional_data) - mock_bf_series.to_pandas.assert_called_once_with(ordered=True) - pd.testing.assert_series_equal(result, pd.Series([b"encrypted1", b"encrypted2"])) - - -@patch("bigframes.operations.googlesql.global_namespace.array.array_concat") -def test_pandas_series_accessor_global_routing_uses_series_session(mock_array_concat): - # Arrange - mock_bf_series = MagicMock() - mock_bf_series.to_pandas.return_value = pd.Series([[1, 2, 3, 4]]) - mock_array_concat.return_value = mock_bf_series - mock_session = MagicMock() - mock_bf_other = MagicMock(spec=series.Series) - mock_bf_other._session = mock_session - mock_bf_self = MagicMock() - mock_session.read_pandas.return_value = mock_bf_self - s = pd.Series([[1, 2]]) - - # Act - result = s.bigquery.array_concat(mock_bf_other) - - # Assert - assert result is not None - mock_session.read_pandas.assert_called_once_with(s) - mock_array_concat.assert_called_once_with(mock_bf_self, mock_bf_other) - - -@patch("bigframes.operations.googlesql.aead.encrypt") -def test_pandas_series_accessor_namespaced_routing_uses_series_session( - mock_encrypt, -): - # Arrange - mock_bf_series = MagicMock() - mock_bf_series.to_pandas.return_value = pd.Series([b"encrypted1", b"encrypted2"]) - mock_encrypt.return_value = mock_bf_series - mock_session = MagicMock() - mock_bf_plaintext = MagicMock(spec=series.Series) - mock_bf_plaintext._session = mock_session - mock_bf_self = MagicMock() - mock_session.read_pandas.return_value = mock_bf_self - keyset_series = pd.Series([b"key1", b"key2"]) - additional_data = "context" - - # Act - result = keyset_series.bigquery.aead.encrypt( # type: ignore - mock_bf_plaintext, additional_data - ) - - # Assert - assert result is not None - mock_session.read_pandas.assert_called_once_with(keyset_series) - mock_encrypt.assert_called_once_with( - mock_bf_self, mock_bf_plaintext, additional_data - ) diff --git a/tests/unit/functions/test_function_typing.py b/tests/unit/functions/test_function_typing.py deleted file mode 100644 index 46ae19555aa..00000000000 --- a/tests/unit/functions/test_function_typing.py +++ /dev/null @@ -1,50 +0,0 @@ -# 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 19de301790d..ea09ac59d38 100644 --- a/tests/unit/functions/test_remote_function.py +++ b/tests/unit/functions/test_remote_function.py @@ -12,15 +12,53 @@ # See the License for the specific language governing permissions and # limitations under the License. +import re + +import pandas import pytest +import bigframes.functions.function as bff +import bigframes.series from bigframes.testing import mocks +@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): + """Check that is_row_processor=True uses str as the input type to serialize a row.""" + session = mocks.create_bigquery_session() + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" + ) + + with pytest.warns( + bigframes.exceptions.PreviewWarning, + match=re.escape("input_types=Series is in preview."), + ): + + @remote_function_decorator + def axis_1_function(myparam: series_type) -> 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!" + + def test_missing_input_types(): session = mocks.create_bigquery_session() - remote_function_decorator = session._function_session.remote_function( - cloud_function_service_account="default" + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" ) def function_without_parameter_annotations(myparam) -> str: @@ -37,8 +75,8 @@ def function_without_parameter_annotations(myparam) -> str: def test_missing_output_type(): session = mocks.create_bigquery_session() - remote_function_decorator = session._function_session.remote_function( - cloud_function_service_account="default" + remote_function_decorator = bff.remote_function( + session=session, cloud_function_service_account="default" ) def function_without_return_annotation(myparam: int): @@ -53,6 +91,36 @@ def function_without_return_annotation(myparam: int): 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() @@ -61,7 +129,8 @@ def my_remote_func(x: int) -> int: deployed = session.deploy_udf(my_remote_func) - assert deployed.udf_def is not None + # Test that the function would have been deployed somewhere. + assert deployed.bigframes_bigquery_function def test_deploy_udf_with_name(): diff --git a/tests/unit/functions/test_remote_function_utils.py b/tests/unit/functions/test_remote_function_utils.py index cbdb289e265..8ddd39d8578 100644 --- a/tests/unit/functions/test_remote_function_utils.py +++ b/tests/unit/functions/test_remote_function_utils.py @@ -13,7 +13,6 @@ # limitations under the License. import inspect -import sys from unittest.mock import patch import bigframes_vendored.constants as constants @@ -23,18 +22,98 @@ @pytest.mark.parametrize( - ("input_location", "expected_cf_region"), + ("input_location", "expected_bq_location", "expected_cf_region"), [ - ("us", "us-central1"), - ("eu", "europe-west1"), - ("US-east4", "us-east4"), + (None, "us", "us-central1"), + ("us", "us", "us-central1"), + ("eu", "eu", "europe-west1"), + ("US-east4", "us-east4", "us-east4"), ], ) -def test_gcf_location_from_bq_location(input_location, expected_cf_region): - """Tests getting cloud function locations for various BigQuery locations.""" - gcf_location = _utils.gcf_location_from_bq_location(input_location) +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 gcf_location == expected_cf_region + 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") @@ -82,7 +161,7 @@ def test_get_updated_package_requirements_capture_references_false(): # Case 1: Only capture_references=False. result_1 = _utils.get_updated_package_requirements(capture_references=False) - assert len(result_1) == 0 + 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"] @@ -148,26 +227,6 @@ def test_get_updated_package_requirements_with_existing_cloudpickle(): 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"] @@ -184,6 +243,78 @@ def test_package_existed_helper(): 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.""" @@ -289,6 +420,7 @@ def test_has_conflict_output_type_no_annotation(): ), ) def test_get_bigframes_metadata(metadata_options, metadata_string): + assert _utils.get_bigframes_metadata(**metadata_options) == metadata_string @@ -361,6 +493,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 diff --git a/tests/unit/ml/test_compose.py b/tests/unit/ml/test_compose.py index 7779bafadfa..86cbb111f4d 100644 --- a/tests/unit/ml/test_compose.py +++ b/tests/unit/ml/test_compose.py @@ -13,13 +13,13 @@ # limitations under the License. from unittest import mock -import pytest from google.cloud import bigquery +import pytest -import bigframes.pandas as bpd from bigframes.ml import compose, preprocessing from bigframes.ml.compose import ColumnTransformer, SQLScalarColumnTransformer from bigframes.ml.core import BqmlModel +import bigframes.pandas as bpd def test_columntransformer_init_expectedtransforms(): diff --git a/tests/unit/ml/test_golden_sql.py b/tests/unit/ml/test_golden_sql.py index 7babf476117..10fefcc457e 100644 --- a/tests/unit/ml/test_golden_sql.py +++ b/tests/unit/ml/test_golden_sql.py @@ -14,14 +14,14 @@ from unittest import mock +from google.cloud import bigquery import pandas as pd import pytest -from google.cloud import bigquery import bigframes +from bigframes.ml import core, decomposition, linear_model import bigframes.ml.core import bigframes.pandas as bpd -from bigframes.ml import core, decomposition, linear_model TEMP_MODEL_ID = bigquery.ModelReference.from_string( "test-project._anon123.temp_model_id" @@ -124,7 +124,7 @@ def test_linear_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='LINEAR_REG',\n data_split_method='NO_SPLIT',\n optimize_strategy='auto_strategy',\n fit_intercept=TRUE,\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='LINEAR_REG',\n data_split_method='NO_SPLIT',\n optimize_strategy='auto_strategy',\n fit_intercept=True,\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" ) @@ -134,7 +134,7 @@ def test_linear_regression_params_fit(bqml_model_factory, mock_session, mock_X, 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='LINEAR_REG',\n data_split_method='NO_SPLIT',\n optimize_strategy='auto_strategy',\n fit_intercept=FALSE,\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='LINEAR_REG',\n data_split_method='NO_SPLIT',\n optimize_strategy='auto_strategy',\n fit_intercept=False,\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" ) @@ -143,10 +143,9 @@ def test_linear_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq_query.assert_called_once_with( + mock_session.read_gbq.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, ) @@ -155,9 +154,8 @@ 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_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, + mock_session.read_gbq.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" ) @@ -169,7 +167,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" ) @@ -191,7 +189,7 @@ def test_logistic_regression_params_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=FALSE,\n auto_class_weights=TRUE,\n optimize_strategy='batch_gradient_descent',\n l2_reg=0.2,\n max_iterations=30,\n learn_rate_strategy='constant',\n min_rel_progress=0.02,\n calculate_p_values=FALSE,\n enable_global_explain=FALSE,\n l1_reg=0.2,\n learn_rate=0.2,\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=False,\n auto_class_weights=True,\n optimize_strategy='batch_gradient_descent',\n l2_reg=0.2,\n max_iterations=30,\n learn_rate_strategy='constant',\n min_rel_progress=0.02,\n calculate_p_values=False,\n enable_global_explain=False,\n l1_reg=0.2,\n learn_rate=0.2,\n INPUT_LABEL_COLS=['input_column_label'])\nAS input_X_y_no_index_sql" ) @@ -200,10 +198,9 @@ def test_logistic_regression_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq_query.assert_called_once_with( + mock_session.read_gbq.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, ) @@ -212,9 +209,8 @@ 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_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, + mock_session.read_gbq.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_y_sql))" ) @@ -247,10 +243,9 @@ def test_decomposition_mf_predict(mock_session, bqml_model, mock_X): model._bqml_model = bqml_model model.predict(mock_X) - mock_session.read_gbq_query.assert_called_once_with( + mock_session.read_gbq.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, ) @@ -265,9 +260,8 @@ def test_decomposition_mf_score(mock_session, bqml_model): ) 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, + mock_session.read_gbq.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`)" ) @@ -282,7 +276,6 @@ def test_decomposition_mf_score_with_x(mock_session, bqml_model, mock_X): ) 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, + mock_session.read_gbq.assert_called_once_with( + "SELECT * FROM ML.EVALUATE(MODEL `model_project`.`model_dataset`.`model_id`,\n (input_X_sql_property))" ) diff --git a/tests/unit/ml/test_llm.py b/tests/unit/ml/test_llm.py deleted file mode 100644 index df17bbc6e7b..00000000000 --- a/tests/unit/ml/test_llm.py +++ /dev/null @@ -1,50 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 google.cloud import bigquery - -import bigframes.session -from bigframes.ml import llm - - -def test_gemini_text_generator_default_model(): - mock_session = mock.create_autospec(spec=bigframes.session.Session) - mock_session._create_bq_connection.return_value = ( - "projects/test-project/locations/us-central1/connections/test-conn" - ) - mock_session._anonymous_dataset = bigquery.DatasetReference( - "test-project", "test_dataset" - ) - mock_job = mock.MagicMock() - mock_job.destination.project = "test-project" - mock_job.destination.dataset_id = "test_dataset" - mock_job.destination.table_id = "test_model" - mock_session._start_query_ml_ddl.return_value = (None, mock_job) - mock_session.bqclient.get_model.return_value = mock.MagicMock(spec=bigquery.Model) - - with pytest.warns( - FutureWarning, match="default model will be removed in BigFrames 3.0" - ): - model = llm.GeminiTextGenerator( - session=mock_session, - connection_name="test-conn", - ) - - assert model.model_name == "gemini-2.5-flash" - mock_session._start_query_ml_ddl.assert_called_once() - generated_sql = mock_session._start_query_ml_ddl.call_args[0][0] - assert "gemini-2.5-flash" in generated_sql diff --git a/tests/unit/operations/test_output_schemas.py b/tests/unit/operations/test_output_schemas.py deleted file mode 100644 index 204078a5ceb..00000000000 --- a/tests/unit/operations/test_output_schemas.py +++ /dev/null @@ -1,99 +0,0 @@ -# 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<>", - "ARRAYARRAYSTRUCT<>", - "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 index dbdf427d91b..1e69fa9df31 100644 --- a/tests/unit/pandas/io/test_api.py +++ b/tests/unit/pandas/io/test_api.py @@ -14,15 +14,11 @@ 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") @@ -51,51 +47,6 @@ def test_read_gbq_colab_dry_run_doesnt_call_set_location( 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" ) @@ -108,7 +59,7 @@ def test_read_gbq_colab_calls_set_location( mock_with_default_session.return_value = mock_df query_or_table = "SELECT {param1} AS param1" - sample_pyformat_args = {"param1": "'value1'"} + sample_pyformat_args = {"param1": "value1"} result = bf_io_api._read_gbq_colab( query_or_table, pyformat_args=sample_pyformat_args, dry_run=False ) diff --git a/tests/unit/session/test_clients.py b/tests/unit/session/test_clients.py index 0de6c75e01b..5304c99466b 100644 --- a/tests/unit/session/test_clients.py +++ b/tests/unit/session/test_clients.py @@ -15,8 +15,8 @@ import os import pathlib import tempfile +from typing import cast, Optional import unittest.mock as mock -from typing import Optional, cast import google.auth.credentials import google.cloud.bigquery @@ -182,18 +182,12 @@ def test_user_agent_not_in_vscode(monkeypatch): @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") - with tempfile.TemporaryDirectory() as tmpdir: - user_home = pathlib.Path(tmpdir) - with mock.patch("pathlib.Path.home", return_value=user_home): - 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__}" - ) + # 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) diff --git a/tests/unit/session/test_io_bigquery.py b/tests/unit/session/test_io_bigquery.py index e6fa7a901ec..c451d74d0fe 100644 --- a/tests/unit/session/test_io_bigquery.py +++ b/tests/unit/session/test_io_bigquery.py @@ -18,16 +18,12 @@ from unittest import mock import google.cloud.bigquery as bigquery -import google.cloud.bigquery.job -import google.cloud.bigquery.table import pytest import bigframes -import bigframes.core.events +from bigframes.core import log_adapter import bigframes.pandas as bpd -import bigframes.session._io.bigquery import bigframes.session._io.bigquery as io_bq -from bigframes.core.logging import log_adapter from bigframes.testing import mocks @@ -35,7 +31,7 @@ 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_row_iterator = mock.create_autospec(bigquery.table.RowIterator) mock_query_job.result.return_value = mock_row_iterator @@ -66,6 +62,18 @@ def test_create_job_configs_labels_always_includes_bigframes_api(): } +def test_create_job_configs_labels_includes_extra_query_labels(): + user_labels = {"my-label-1": "my-value-1", "my-label-2": "my-value-2"} + + with bigframes.option_context("compute.extra_query_labels", user_labels): + labels = io_bq.create_job_configs_labels(None, []) + assert labels == { + "my-label-1": "my-value-1", + "my-label-2": "my-value-2", + "bigframes-api": "unknown", + } + + def test_create_job_configs_labels_length_limit_not_met(): cur_labels = { "source": "bigquery-dataframes-temp", @@ -89,12 +97,14 @@ def test_create_job_configs_labels_log_adaptor_call_method_under_length_limit(): cur_labels = { "source": "bigquery-dataframes-temp", } - api_methods = [ - "dataframe-columns", - "dataframe-max", - "dataframe-head", - "dataframe-__init__", - ] + df = bpd.DataFrame( + {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() + ) + # Test running two methods + df.head() + df.max() + df.columns + api_methods = log_adapter._api_methods labels = io_bq.create_job_configs_labels( job_configs_labels=cur_labels, api_methods=api_methods @@ -112,13 +122,17 @@ def test_create_job_configs_labels_log_adaptor_call_method_under_length_limit(): 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=mocks.create_bigquery_session() + ) # Test running methods more than the labels' length limit - api_methods = list(["dataframe-head"] * 100) + for i in range(100): + df.head() + api_methods = log_adapter._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 - ) + 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() @@ -135,15 +149,19 @@ 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=mocks.create_bigquery_session() + ) # Test running two 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 - ) + df.head() + df.max() + api_methods = log_adapter._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() @@ -165,14 +183,14 @@ def test_add_and_trim_labels_length_limit_met(): {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = google.cloud.bigquery.job.QueryJobConfig() + job_config = bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() for _ in range(52): df.head() - io_bq.add_and_trim_labels(job_config=job_config, session=df._session) + io_bq.add_and_trim_labels(job_config=job_config) assert job_config.labels is not None assert len(job_config.labels) == 56 assert "dataframe-max" not in job_config.labels.values() @@ -185,7 +203,7 @@ def test_add_and_trim_labels_length_limit_met(): ("timeout", "api_name"), [(None, None), (30.0, "test_api")], ) -def test_start_query_with_job_labels_length_limit_met( +def test_start_query_with_client_labels_length_limit_met( mock_bq_client: bigquery.Client, timeout: Optional[float], api_name ): sql = "select * from abc" @@ -202,14 +220,14 @@ def test_start_query_with_job_labels_length_limit_met( {"col1": [1, 2], "col2": [3, 4]}, session=mocks.create_bigquery_session() ) - job_config = google.cloud.bigquery.job.QueryJobConfig() + job_config = bigquery.job.QueryJobConfig() job_config.labels = cur_labels df.max() for _ in range(52): df.head() - io_bq.start_query_with_job( + io_bq.start_query_with_client( mock_bq_client, sql, job_config=job_config, @@ -217,8 +235,7 @@ def test_start_query_with_job_labels_length_limit_met( project=None, timeout=timeout, metrics=None, - publisher=bigframes.core.events.Publisher(), - session=df._session, + query_with_job=True, ) assert job_config.labels is not None @@ -332,8 +349,8 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) 2024, 5, 14, 12, 42, 36, 125125, tzinfo=datetime.timezone.utc ), ( - "SELECT `_bf_source`.`row_index`, `_bf_source`.`string_col` FROM `test_table` AS _bf_source " - "FOR SYSTEM_TIME AS OF CAST('2024-05-14T12:42:36.125125+00:00' AS TIMESTAMP) " + "SELECT `row_index`, `string_col` FROM `test_table` " + "FOR SYSTEM_TIME AS OF TIMESTAMP('2024-05-14T12:42:36.125125+00:00') " "WHERE `rowindex` NOT IN (0, 6) OR `string_col` IN ('Hello, World!', " "'こんにちは') LIMIT 123" ), @@ -357,12 +374,12 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) 2024, 5, 14, 12, 42, 36, 125125, tzinfo=datetime.timezone.utc ), ( - """SELECT `_bf_source`.`rowindex`, `_bf_source`.`string_col` FROM (SELECT + """SELECT `rowindex`, `string_col` FROM (SELECT rowindex, string_col, FROM `test_table` AS t - ) AS _bf_source """ - "FOR SYSTEM_TIME AS OF CAST('2024-05-14T12:42:36.125125+00:00' AS TIMESTAMP) " + ) """ + "FOR SYSTEM_TIME AS OF TIMESTAMP('2024-05-14T12:42:36.125125+00:00') " "WHERE `rowindex` < 4 AND `string_col` = 'Hello, World!' " "LIMIT 123" ), @@ -374,7 +391,7 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) [], None, # max_results None, # time_travel_timestampe - "SELECT `_bf_source`.`col_a`, `_bf_source`.`col_b` FROM `test_table` AS _bf_source", + "SELECT `col_a`, `col_b` FROM `test_table`", id="table-columns", ), pytest.param( @@ -383,7 +400,7 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) [("date_col", ">", "2022-10-20")], None, # max_results None, # time_travel_timestampe - "SELECT * FROM `test_table` AS _bf_source WHERE `date_col` > '2022-10-20'", + "SELECT * FROM `test_table` WHERE `date_col` > '2022-10-20'", id="table-filter", ), pytest.param( @@ -392,7 +409,7 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) [], None, # max_results None, # time_travel_timestampe - "SELECT * FROM `test_table*` AS _bf_source", + "SELECT * FROM `test_table*`", id="wildcard-no_params", ), pytest.param( @@ -401,7 +418,7 @@ def test_bq_schema_to_sql(schema: Iterable[bigquery.SchemaField], expected: str) [("_TABLE_SUFFIX", ">", "2022-10-20")], None, # max_results None, # time_travel_timestampe - "SELECT * FROM `test_table*` AS _bf_source WHERE `_TABLE_SUFFIX` > '2022-10-20'", + "SELECT * FROM `test_table*` WHERE `_TABLE_SUFFIX` > '2022-10-20'", id="wildcard-filter", ), ], diff --git a/tests/unit/session/test_io_pandas.py b/tests/unit/session/test_io_pandas.py index f4141ec8a23..224f343c7e1 100644 --- a/tests/unit/session/test_io_pandas.py +++ b/tests/unit/session/test_io_pandas.py @@ -14,8 +14,8 @@ import datetime import re -import unittest.mock as mock from typing import Dict, Union +import unittest.mock as mock import geopandas # type: ignore import numpy diff --git a/tests/unit/session/test_local_scan_executor.py b/tests/unit/session/test_local_scan_executor.py index 66dcdf590ce..30b1b5f78d7 100644 --- a/tests/unit/session/test_local_scan_executor.py +++ b/tests/unit/session/test_local_scan_executor.py @@ -13,19 +13,14 @@ # limitations under the License. from __future__ import annotations -import asyncio - import pyarrow import pytest +from bigframes import dtypes from bigframes.core import identifiers, local_data, nodes -from bigframes.session import execution_spec, local_scan_executor +from bigframes.session import local_scan_executor from bigframes.testing import mocks -SPEC = execution_spec.ExecutionSpec( - ordered=True, -) - @pytest.fixture def object_under_test(): @@ -42,6 +37,9 @@ def create_read_local_node(arrow_table: pyarrow.Table): items=tuple( nodes.ScanItem( id=identifiers.ColumnId(column_name), + dtype=dtypes.arrow_dtype_to_bigframes_dtype( + arrow_table.field(column_name).type + ), source_id=column_name, ) for column_name in arrow_table.column_names @@ -78,8 +76,8 @@ def test_local_scan_executor_with_slice(start, stop, expected_rows, object_under stop=stop, ) - result = asyncio.run(object_under_test.execute(plan, SPEC)) - result_table = pyarrow.Table.from_batches(result.batches().arrow_batches) + result = object_under_test.execute(plan, ordered=True) + result_table = pyarrow.Table.from_batches(result.arrow_batches) assert result_table.num_rows == expected_rows @@ -104,4 +102,4 @@ def test_local_scan_executor_with_slice_unsupported_inputs( stop=stop, step=step, ) - assert asyncio.run(object_under_test.execute(plan, SPEC)) is None + 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 index 4e550b1c77a..7c2f01c5b98 100644 --- a/tests/unit/session/test_metrics.py +++ b/tests/unit/session/test_metrics.py @@ -245,58 +245,3 @@ def test_write_stats_to_disk_no_env_var(tmp_path, monkeypatch): exec_seconds=1.23, ) assert len(list(tmp_path.iterdir())) == 0 - - -def test_on_event_with_local_execute_result(): - import bigframes.core.events - from bigframes.session.executor import LocalExecuteResult - - # fmt: off - local_result = unittest.mock.create_autospec( - LocalExecuteResult, instance=True - ) - # fmt: on - local_result.total_bytes_processed = 1024 - - event = bigframes.core.events.ExecutionFinished(result=local_result) - envelope = bigframes.core.events.EventEnvelope(event) - execution_metrics = metrics.ExecutionMetrics() - execution_metrics.on_event(envelope) - - assert execution_metrics.execution_count == 1 - assert len(execution_metrics.jobs) == 1 - assert execution_metrics.jobs[0].job_type == "polars" - assert execution_metrics.jobs[0].status == "DONE" - assert execution_metrics.jobs[0].total_bytes_processed == 1024 - - -def test_count_job_stats_with_explicit_cell_execution_count(): - 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, cell_execution_count=42 - ) - - assert len(execution_metrics.jobs) == 1 - assert execution_metrics.jobs[0].cell_execution_count == 42 - - -def test_on_event_with_explicit_cell_execution_count(): - import bigframes.core.events - from bigframes.session.executor import LocalExecuteResult - - local_result = unittest.mock.create_autospec(LocalExecuteResult, instance=True) - local_result.total_bytes_processed = 1024 - - event = bigframes.core.events.ExecutionFinished(result=local_result) - envelope = bigframes.core.events.EventEnvelope(event=event, cell_execution_count=42) - execution_metrics = metrics.ExecutionMetrics() - execution_metrics.on_event(envelope) - - assert len(execution_metrics.jobs) == 1 - assert execution_metrics.jobs[0].cell_execution_count == 42 diff --git a/tests/unit/session/test_proxy_executor.py b/tests/unit/session/test_proxy_executor.py deleted file mode 100644 index c20fd57236b..00000000000 --- a/tests/unit/session/test_proxy_executor.py +++ /dev/null @@ -1,198 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 as bigquery -import google.cloud.exceptions -import pytest - -import bigframes -from bigframes.session.proxy_executor import DualCompilerProxyExecutor - - -@pytest.fixture -def mock_executor(): - bqclient = mock.create_autospec(bigquery.Client) - bqclient.project = "test-project" - storage_manager = mock.Mock() - bqstoragereadclient = mock.Mock() - loader = mock.Mock() - publisher = mock.Mock() - function_manager = mock.Mock() - return DualCompilerProxyExecutor( - bqclient, - storage_manager, - bqstoragereadclient, - loader, - publisher=publisher, - function_manager=function_manager, - ) - - -def test_execute_legacy_routes_to_ibis(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - execution_spec = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - execution_spec.with_bq_labels.return_value = execution_spec - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "legacy") - mock_executor.execute(array_value, execution_spec) - - execution_spec.with_bq_labels.assert_called_once_with( - {"bigframes-compiler": "ibis"} - ) - mock_executor._ibis_executor.execute.assert_called_once_with( - array_value, execution_spec - ) - mock_executor._sqlglot_executor.execute.assert_not_called() - - -def test_execute_experimental_routes_to_sqlglot(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - execution_spec = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - execution_spec.with_bq_labels.return_value = execution_spec - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "experimental") - mock_executor.execute(array_value, execution_spec) - - execution_spec.with_bq_labels.assert_called_once_with( - {"bigframes-compiler": "sqlglot"} - ) - mock_executor._sqlglot_executor.execute.assert_called_once_with( - array_value, execution_spec - ) - mock_executor._ibis_executor.execute.assert_not_called() - - -def test_execute_stable_routes_to_sqlglot_success(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - execution_spec = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - execution_spec.with_bq_labels.return_value = execution_spec - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "stable") - with mock.patch("uuid.uuid1") as mock_uuid: - mock_uuid.return_value.hex = "1234567890123456" - mock_executor.execute(array_value, execution_spec) - - execution_spec.with_bq_labels.assert_called_once_with( - {"bigframes-compiler": "sqlglot-123456789012"} - ) - mock_executor._sqlglot_executor.execute.assert_called_once_with( - array_value, execution_spec - ) - mock_executor._ibis_executor.execute.assert_not_called() - - -def test_execute_stable_routes_to_sqlglot_fallback_to_ibis(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - execution_spec = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - - spec_sqlglot = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - spec_ibis = mock.Mock(spec=bigframes.session.execution_spec.ExecutionSpec) - execution_spec.with_bq_labels.side_effect = [spec_sqlglot, spec_ibis] - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - mock_executor._sqlglot_executor.execute.side_effect = ( - google.cloud.exceptions.BadRequest("test error") - ) - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "stable") - with mock.patch("uuid.uuid1") as mock_uuid: - mock_uuid.return_value.hex = "1234567890123456" - with pytest.warns( - UserWarning, match="Compiler ID 123456789012: Exception on sqlglot" - ): - mock_executor.execute(array_value, execution_spec) - - execution_spec.with_bq_labels.assert_has_calls( - [ - mock.call({"bigframes-compiler": "sqlglot-123456789012"}), - mock.call({"bigframes-compiler": "ibis-123456789012"}), - ] - ) - - mock_executor._sqlglot_executor.execute.assert_called_once_with( - array_value, spec_sqlglot - ) - mock_executor._ibis_executor.execute.assert_called_once_with(array_value, spec_ibis) - - -def test_cached_legacy_routes_to_ibis(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - config = mock.Mock() - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "legacy") - mock_executor.cached(array_value, config=config) - - mock_executor._ibis_executor.cached.assert_called_once_with( - array_value, config=config - ) - mock_executor._sqlglot_executor.cached.assert_not_called() - - -def test_cached_experimental_routes_to_sqlglot(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - config = mock.Mock() - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "experimental") - mock_executor.cached(array_value, config=config) - - mock_executor._sqlglot_executor.cached.assert_called_once_with( - array_value, config=config - ) - mock_executor._ibis_executor.cached.assert_not_called() - - -def test_cached_stable_routes_to_sqlglot_fallback_to_ibis(mock_executor, monkeypatch): - array_value = mock.Mock(spec=bigframes.core.ArrayValue) - config = mock.Mock() - - mock_executor._ibis_executor = mock.Mock() - mock_executor._sqlglot_executor = mock.Mock() - - mock_executor._sqlglot_executor.cached.side_effect = ( - google.cloud.exceptions.BadRequest("test error") - ) - - monkeypatch.setattr(bigframes.options.experiments, "sql_compiler", "stable") - with mock.patch("uuid.uuid1") as mock_uuid: - mock_uuid.return_value.hex = "1234567890123456" - with pytest.warns( - UserWarning, match="Compiler ID 123456789012: Exception on sqlglot" - ): - mock_executor.cached(array_value, config=config) - - mock_executor._sqlglot_executor.cached.assert_called_once_with( - array_value, config=config - ) - mock_executor._ibis_executor.cached.assert_called_once_with( - array_value, config=config - ) diff --git a/tests/unit/session/test_read_gbq_colab.py b/tests/unit/session/test_read_gbq_colab.py index fc4181b6a2b..52b091c0456 100644 --- a/tests/unit/session/test_read_gbq_colab.py +++ b/tests/unit/session/test_read_gbq_colab.py @@ -14,65 +14,28 @@ """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 google.cloud import bigquery 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 = mocks.create_bigquery_session() _ = session._read_gbq_colab("SELECT 'read-gbq-colab-test'") + configs = session._job_configs # type: ignore 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 - - -def test_read_gbq_colab_includes_label_in_anywidget_mode(): - """Make sure read_gbq_colab label is preserved in recent-bigframes-api labels in anywidget mode.""" - pytest.importorskip("anywidget") - pytest.importorskip("traitlets") - - import bigframes - import bigframes.display.html as bf_html - - bqclient = mock.create_autospec(bigquery.Client, instance=True) - bqclient.project = "proj" - session = mocks.create_bigquery_session(bqclient=bqclient) - df = session._read_gbq_colab("SELECT 'read-gbq-colab-test'") - - with bigframes.option_context("display.render_mode", "anywidget"): - _ = bf_html.get_anywidget_bundle(df) - - 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: + for config in configs: + if config is None: continue - label_values.extend(job_config.labels.values()) + label_values.extend(config.labels.values()) assert "session-read_gbq_colab" in label_values @@ -97,7 +60,7 @@ def test_read_gbq_colab_includes_formatted_values_in_dry_run(monkeypatch, dry_ru pyformat_args = { "some_integer": 123, - "some_string": "some_column", + "some_string": "This could be dangerous, but we escape it", "bf_df": bf_df, "pd_df": pd_df, # This is not a supported type, but ignored if not referenced. @@ -121,7 +84,7 @@ def test_read_gbq_colab_includes_formatted_values_in_dry_run(monkeypatch, dry_ru expected = textwrap.dedent( f""" SELECT 123 as some_integer, - some_column as some_string, + 'This could be dangerous, but we escape it' 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 @@ -156,91 +119,3 @@ def test_read_gbq_colab_doesnt_set_destination_table(): assert query == "SELECT 'my-test-query';" assert config.destination is None - - -def test_read_gbq_colab_with_callback(): - """Make sure callback receives events during execution.""" - session = mocks.create_bigquery_session() - callback = mock.Mock() - - _ = session._read_gbq_colab("SELECT 'my-test-query';", callback=callback) - - assert callback.call_count > 0 - - -def test_read_gbq_colab_filters_by_cell(): - """Verify that callbacks are scoped to individual executions.""" - session = mocks.create_bigquery_session() - callback1 = mock.Mock() - callback2 = mock.Mock() - - _ = session._read_gbq_colab("SELECT 'cell_1_query';", callback=callback1) - callback1_initial_count = callback1.call_count - - _ = session._read_gbq_colab("SELECT 'cell_2_query';", callback=callback2) - - # Verify callback1 was automatically unsubscribed upon completion - # of the first query. - assert callback1.call_count == callback1_initial_count - assert callback2.call_count > 0 - - -def test_execution_history_filtering(): - """Verify that execution_history can be filtered by job_ids or events.""" - from bigframes.session import metrics - - session = mocks.create_bigquery_session() - - job1 = metrics.JobMetadata(job_id="job_1", job_type="query", query="SELECT 1") - job2 = metrics.JobMetadata(job_id="job_2", job_type="query", query="SELECT 2") - session._metrics.jobs.extend([job1, job2]) - - history_job1 = session.execution_history(job_ids=["job_1"]).to_dataframe() - assert len(history_job1) == 1 - assert history_job1.iloc[0]["job_id"] == "job_1" - - event2 = mock.Mock() - event2.job_id = "job_2" - history_job2 = session.execution_history(events=[event2]).to_dataframe() - assert len(history_job2) == 1 - assert history_job2.iloc[0]["job_id"] == "job_2" - - -def test_execution_history_returns_all_executions_by_default(): - """Verify that execution_history returns all executions by default.""" - from bigframes.session import metrics - - session = mocks.create_bigquery_session() - job1 = metrics.JobMetadata( - job_id="job_1", job_type="query", query="SELECT 1", cell_execution_count=10 - ) - job2 = metrics.JobMetadata( - job_id="job_2", job_type="query", query="SELECT 2", cell_execution_count=20 - ) - session._metrics.jobs.extend([job1, job2]) - - history = session.execution_history().to_dataframe() - - assert len(history) == 2 - - -def test_execution_history_filters_by_notebook_cell_when_all_cells_is_false(): - """Verify that execution_history filters to the current cell when all_cells is False.""" - from bigframes.session import metrics - - session = mocks.create_bigquery_session() - job1 = metrics.JobMetadata( - job_id="job_1", job_type="query", query="SELECT 1", cell_execution_count=10 - ) - job2 = metrics.JobMetadata( - job_id="job_2", job_type="query", query="SELECT 2", cell_execution_count=20 - ) - session._metrics.jobs.extend([job1, job2]) - - with mock.patch( - "bigframes.core.utils.get_ipython_execution_count", return_value=20 - ): - history = session.execution_history(all_cells=False).to_dataframe() - - assert len(history) == 1 - assert history.iloc[0]["job_id"] == "job_2" diff --git a/tests/unit/session/test_read_gbq_query.py b/tests/unit/session/test_read_gbq_query.py index d078c64af72..afd9922426c 100644 --- a/tests/unit/session/test_read_gbq_query.py +++ b/tests/unit/session/test_read_gbq_query.py @@ -25,7 +25,7 @@ def test_read_gbq_query_sets_destination_table(): # 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) + _ = session.read_gbq_query("SELECT 'my-test-query';") queries = session._queries # type: ignore configs = session._job_configs # type: ignore @@ -35,4 +35,3 @@ def test_read_gbq_query_sets_destination_table(): 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 97ac0efb753..0c67e058131 100644 --- a/tests/unit/session/test_read_gbq_table.py +++ b/tests/unit/session/test_read_gbq_table.py @@ -15,25 +15,22 @@ """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 bigframes.core import bq_data from bigframes.testing import mocks @pytest.mark.parametrize( - ("index_cols", "primary_keys", "expected"), + ("index_cols", "primary_keys", "values_distinct", "expected"), ( - (["col1", "col2"], ["col1", "col2", "col3"], ("col1", "col2", "col3")), + (["col1", "col2"], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), ( ["col1", "col2", "col3"], ["col1", "col2", "col3"], + True, ("col1", "col2", "col3"), ), ( @@ -42,14 +39,15 @@ "col3", "col2", ], + True, ("col2", "col3"), ), - (["col1", "col2"], [], ()), - ([], ["col1", "col2", "col3"], ("col1", "col2", "col3")), - ([], [], ()), + (["col1", "col2"], [], False, ()), + ([], ["col1", "col2", "col3"], False, ("col1", "col2", "col3")), + ([], [], False, ()), ), ) -def test_infer_unique_columns(index_cols, primary_keys, expected): +def test_infer_unique_columns(index_cols, primary_keys, values_distinct, 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. @@ -81,51 +79,6 @@ def test_infer_unique_columns(index_cols, primary_keys, expected): "columns": primary_keys, }, } - - result = bf_read_gbq_table.infer_unique_columns( - bq_data.GbqNativeTable.from_table(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" session = mocks.create_bigquery_session( @@ -134,58 +87,13 @@ def test_check_if_index_columns_are_unique(index_cols, values_distinct, expected # 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 = ( + bqclient.query_and_wait.side_effect = None + bqclient.query_and_wait.return_value = ( {"total_count": 3, "distinct_count": 3 if values_distinct else 2}, ) table._properties["location"] = session._location - result = bf_read_gbq_table.check_if_index_columns_are_unique( - bqclient=bqclient, - table=bq_data.GbqNativeTable.from_table(table), - index_cols=index_cols, - publisher=session._publisher, - ) + result = bf_read_gbq_table.infer_unique_columns(bqclient, table, index_cols) 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( - bq_data.GbqNativeTable.from_table(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( - bq_data.GbqNativeTable.from_table(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 a6c8446967e..63c82eb30fc 100644 --- a/tests/unit/session/test_session.py +++ b/tests/unit/session/test_session.py @@ -16,8 +16,8 @@ import datetime import os import re -import warnings from unittest import mock +import warnings import google.api_core.exceptions import google.cloud.bigquery @@ -25,10 +25,9 @@ import pytest import bigframes +from bigframes import version import bigframes.enums import bigframes.exceptions -from bigframes import version -from bigframes.core import bq_data from bigframes.testing import mocks TABLE_REFERENCE = { @@ -241,13 +240,14 @@ 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[str(table_ref)] = ( + session._loader._df_snapshot[table_ref] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), - bq_data.GbqNativeTable.from_table(table), + table, ) - session.bqclient._query_and_wait_bigframes = mock.MagicMock( + session.bqclient.query_and_wait = mock.MagicMock( return_value=({"total_count": 3, "distinct_count": 2},) ) session.bqclient.get_table.return_value = table @@ -273,12 +273,12 @@ def test_read_gbq_cached_table_doesnt_warn_for_anonymous_tables_and_doesnt_inclu table._properties["numRows"] = "1000000000" table._properties["location"] = session._location table._properties["type"] = "TABLE" - session._loader._df_snapshot[str(table_ref)] = ( + session._loader._df_snapshot[table_ref] = ( datetime.datetime(1999, 1, 2, 3, 4, 5, 678901, tzinfo=datetime.timezone.utc), - bq_data.GbqNativeTable.from_table(table), + table, ) - session.bqclient._query_and_wait_bigframes = mock.MagicMock( + session.bqclient.query_and_wait = mock.MagicMock( return_value=({"total_count": 3, "distinct_count": 2},) ) session.bqclient.get_table.return_value = table @@ -306,9 +306,7 @@ 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_bigframes.return_value = ( - {"total_count": 3, "distinct_count": 2}, - ) + bqclient.query_and_wait.return_value = ({"total_count": 3, "distinct_count": 2},) session = mocks.create_bigquery_session( bqclient=bqclient, # DefaultIndexWarning is only relevant for strict mode. @@ -335,9 +333,7 @@ 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_bigframes.return_value = ( - {"total_count": 4, "distinct_count": 3}, - ) + bqclient.query_and_wait.return_value = ({"total_count": 4, "distinct_count": 3},) session = mocks.create_bigquery_session( bqclient=bqclient, # DefaultIndexWarning is only relevant for strict mode. @@ -386,7 +382,7 @@ 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_bigframes.return_value = ( + bqclient.query_and_wait.return_value = ( {"total_count": total_count, "distinct_count": distinct_count}, ) session = mocks.create_bigquery_session( @@ -496,7 +492,6 @@ 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( diff --git a/tests/unit/test_clients.py b/tests/unit/test_clients.py index 08d111b8662..9daa7598382 100644 --- a/tests/unit/test_clients.py +++ b/tests/unit/test_clients.py @@ -14,9 +14,9 @@ from unittest import mock -import pytest from google.cloud import bigquery_connection_v1, resourcemanager_v3 from google.iam.v1 import policy_pb2 +import pytest from bigframes import clients diff --git a/tests/unit/test_col.py b/tests/unit/test_col.py deleted file mode 100644 index c8caf9136c0..00000000000 --- a/tests/unit/test_col.py +++ /dev/null @@ -1,269 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 operator -import pathlib -from typing import Generator - -import numpy as np -import pandas as pd -import pytest - -import bigframes -import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal, convert_pandas_dtypes - -pytest.importorskip("polars") -pytest.importorskip("pandas", minversion="3.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 - - -@pytest.mark.parametrize( - ("op",), - [ - (operator.invert,), - ], -) -def test_pd_col_unary_operators(scalars_dfs, op): - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "result": op(bpd.col("bool_col")), - } - pd_kwargs = { - "result": op(pd.col("bool_col")), # type: ignore - } - df = scalars_df.assign(**bf_kwargs) - - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) - - -@pytest.mark.parametrize( - ("op"), - [ - (lambda x: x.sum()), - (lambda x: x.mean()), - (lambda x: x.min()), - (lambda x: x.max()), - (lambda x: x.std()), - (lambda x: x.var()), - ], - ids=[ - "sum", - "mean", - "min", - "max", - "std", - "var", - ], -) -def test_pd_col_aggregate_op(scalars_dfs, op): - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "result": op(bpd.col("float64_col")), - } - pd_kwargs = { - "result": op(pd.col("float64_col")), # type: ignore - } - df = scalars_df.assign(**bf_kwargs) - - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) - - -def test_pd_col_aggregate_of_aggregate(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "result": (bpd.col("int64_col") - bpd.col("int64_col").mean()).mean(), - } - pd_kwargs = { - "result": (pd.col("int64_col") - pd.col("int64_col").mean()).mean(), # type: ignore - } - df = scalars_df.assign(**bf_kwargs) - - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) - - -@pytest.mark.parametrize( - ("op",), - [ - (operator.add,), - (operator.sub,), - (operator.mul,), - (operator.truediv,), - (operator.floordiv,), - (operator.gt,), - (operator.lt,), - (operator.ge,), - (operator.le,), - (operator.eq,), - (operator.mod,), - ], -) -def test_pd_col_binary_operators(scalars_dfs, op): - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "result": op(bpd.col("float64_col"), 2.4), - "reverse_result": op(2.4, bpd.col("float64_col")), - } - pd_kwargs = { - "result": op(pd.col("float64_col"), 2.4), # type: ignore - "reverse_result": op(2.4, pd.col("float64_col")), # type: ignore - } - df = scalars_df.assign(**bf_kwargs) - - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) - - -@pytest.mark.parametrize( - ("op",), - [ - (operator.and_,), - (operator.or_,), - (operator.xor,), - ], -) -def test_pd_col_binary_bool_operators(scalars_dfs, op): - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "result": op(bpd.col("bool_col"), True), - "reverse_result": op(False, bpd.col("bool_col")), - } - pd_kwargs = { - "result": op(pd.col("bool_col"), True), # type: ignore - "reverse_result": op(False, pd.col("bool_col")), # type: ignore - } - df = scalars_df.assign(**bf_kwargs) - - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) - - -def test_loc_with_pd_col(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - bf_result = scalars_df.loc[bpd.col("float64_col") > 4].to_pandas() - pd_result = scalars_pandas_df.loc[pd.col("float64_col") > 4] # type: ignore - - assert_frame_equal(bf_result, pd_result) - - -def test_getitem_with_pd_col(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - bf_result = scalars_df[bpd.col("float64_col") > 4].to_pandas() - pd_result = scalars_pandas_df[pd.col("float64_col") > 4] # type: ignore - - assert_frame_equal(bf_result, pd_result) - - -def test_col_str_accessor(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - bf_result = scalars_df.assign(result=bpd.col("string_col").str.lower()).to_pandas() - pd_result = scalars_pandas_df.assign(result=pd.col("string_col").str.lower()) # type: ignore - - assert_frame_equal(bf_result, pd_result) - - -def test_col_dt_accessor(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - bf_result = scalars_df.assign(result=bpd.col("date_col").dt.year).to_pandas() - pd_result = scalars_pandas_df.assign(result=pd.col("date_col").dt.year) # type: ignore - - # int64[pyarrow] vs Int64 - assert_frame_equal(bf_result, pd_result, check_dtype=False) - - -def test_col_numpy_ufunc(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - - bf_result = scalars_df.assign( - sqrt=np.sqrt(bpd.col("float64_col")), # type: ignore - add_const=np.add(bpd.col("float64_col"), 2.4), # type: ignore - radd_const=np.add(2.4, bpd.col("float64_col")), # type: ignore - add_cols=np.add(bpd.col("float64_col"), bpd.col("int64_col")), # type: ignore - ).to_pandas() - pd_result = scalars_pandas_df.assign( - sqrt=np.sqrt(pd.col("float64_col")), # type: ignore - add_const=np.add(pd.col("float64_col"), 2.4), # type: ignore - radd_const=np.add(2.4, pd.col("float64_col")), # type: ignore - add_cols=np.add(pd.col("float64_col"), pd.col("int64_col")), # type: ignore - ) - - # int64[pyarrow] vs Int64 - assert_frame_equal(bf_result, pd_result, check_dtype=False) diff --git a/tests/unit/test_dataframe.py b/tests/unit/test_dataframe.py index d045bf7c3fc..d630380e7a0 100644 --- a/tests/unit/test_dataframe.py +++ b/tests/unit/test_dataframe.py @@ -13,11 +13,9 @@ # limitations under the License. import google.cloud.bigquery -import pandas as pd import pytest import bigframes.dataframe -import bigframes.session from bigframes.testing import mocks @@ -42,68 +40,6 @@ 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. @@ -193,33 +129,12 @@ def test_dataframe_rename_axis_inplace_returns_none(monkeypatch: pytest.MonkeyPa 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, +def test_dataframe_semantics_property_future_warning( + monkeypatch: pytest.MonkeyPatch, ): - 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] - + dataframe = mocks.create_dataframe(monkeypatch) -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"] + with bigframes.option_context("experiments.semantic_operators", True), pytest.warns( + FutureWarning + ): + dataframe.semantics diff --git a/tests/unit/test_dataframe_polars.py b/tests/unit/test_dataframe_polars.py index c2dc979b71e..a6f5c3d1ef9 100644 --- a/tests/unit/test_dataframe_polars.py +++ b/tests/unit/test_dataframe_polars.py @@ -32,7 +32,7 @@ import bigframes.series as series from bigframes.testing.utils import ( assert_dfs_equivalent, - assert_frame_equal, + assert_pandas_df_equal, assert_series_equal, assert_series_equivalent, convert_pandas_dtypes, @@ -226,7 +226,7 @@ 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) + assert_pandas_df_equal(bf_result, pd_result) def test_hasattr(scalars_dfs): @@ -253,7 +253,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) def test_tail_with_custom_column_labels(scalars_df_index, scalars_pandas_df_index): @@ -492,7 +492,7 @@ def test_drop_with_custom_column_labels(scalars_dfs): pd_result = scalars_pandas_df.rename(columns=rename_mapping).drop( columns=dropped_columns ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_memory_usage(scalars_dfs): @@ -593,8 +593,8 @@ def test_drop_bigframes_index_with_na(scalars_dfs): 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 + drop_index = scalars_df.iloc[[3, 5]].index + drop_pandas_index = scalars_pandas_df.iloc[[3, 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() @@ -737,7 +737,7 @@ def test_join_repr(scalars_dfs): assert actual == expected -def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): +def test_repr_html_w_all_rows(scalars_dfs, session): scalars_df, _ = scalars_dfs # get a pandas df of the expected format df, _ = scalars_df._block.to_pandas() @@ -745,8 +745,7 @@ def test_mimebundle_html_repr_w_all_rows(scalars_dfs, session): 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"] + actual = scalars_df.head(10)._repr_html_() with display_options.pandas_repr(bigframes.options.display): pandas_repr = pandas_df.head(10)._repr_html_() @@ -800,7 +799,7 @@ def test_take_df(scalars_dfs, indices, axis): 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) + assert_pandas_df_equal(bf_result, pd_result) def test_filter_df(scalars_dfs): @@ -812,7 +811,7 @@ def test_filter_df(scalars_dfs): pd_bool_series = scalars_pandas_df["bool_col"] pd_result = scalars_pandas_df[pd_bool_series] - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_new_column(scalars_dfs): @@ -825,27 +824,7 @@ def test_assign_new_column(scalars_dfs): # 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_using_pd_col(scalars_dfs): - if pd.__version__.startswith("1.") or pd.__version__.startswith("2."): - pytest.skip("col expression interface only supported for pandas 3+") - scalars_df, scalars_pandas_df = scalars_dfs - bf_kwargs = { - "new_col_1": 4 - bpd.col("int64_col"), - "new_col_2": bpd.col("int64_col") / (bpd.col("float64_col") * 0.5), - } - pd_kwargs = { - "new_col_1": 4 - pd.col("int64_col"), # type: ignore - "new_col_2": pd.col("int64_col") / (pd.col("float64_col") * 0.5), # type: ignore - } - - df = scalars_df.assign(**bf_kwargs) - bf_result = df.to_pandas() - pd_result = scalars_pandas_df.assign(**pd_kwargs) - - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_new_column_w_loc(scalars_dfs): @@ -983,7 +962,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_listlike_to_empty_df(session): @@ -995,7 +974,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_frame_equal(bf_result.to_pandas(), pd_result) + assert_pandas_df_equal(bf_result.to_pandas(), pd_result) def test_assign_to_empty_df_multiindex_error(session): @@ -1029,7 +1008,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) def test_assign_series_overwrite(scalars_dfs): @@ -1041,7 +1020,7 @@ def test_assign_series_overwrite(scalars_dfs): **{column_name: scalars_pandas_df[column_name] + 3} ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_sequential(scalars_dfs): @@ -1056,7 +1035,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) # Require an index so that the self-join is consistent each time. @@ -1090,7 +1069,7 @@ def test_assign_different_df( new_col=scalars_pandas_df_index[column_name] ) - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_assign_different_df_w_loc( @@ -1141,7 +1120,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -1287,49 +1266,6 @@ def test_apply_series_scalar_callable( pandas.testing.assert_series_equal(bf_result, pd_result) -def test_df_map_with_udf(session): - df = bpd.DataFrame({"x": [1, 2, None, 4], "y": [5, None, 7, 8]}, dtype="Int64") - - @session.udf() - def foo(row: pd.Series) -> int: - if pd.isna(row["x"]) or pd.isna(row["y"]): - return -1 - return int(row["x"] * row["y"]) - - bf_result = df.apply(foo, axis=1).to_pandas() - pd_result = pd.Series([5, -1, -1, 32]) - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_df_apply_complex_udf(session): - df = bpd.DataFrame( - {"x": [1, 2, 3], "y": ["a", "b", "c"]}, - index=["row0", "row1", "row2"], - ) - - @session.udf() - def foo(row: pd.Series) -> str: - idx = str(row.name) - items_str = ";".join(f"{k}={v}" for k, v in row.items()) - return f"({idx}) -> {items_str}" - - bf_result = df.apply(foo, axis=1).to_pandas() - - pd_df = pd.DataFrame( - {"x": [1, 2, 3], "y": ["a", "b", "c"]}, - index=["row0", "row1", "row2"], - ) - - def pd_foo(row): - idx = str(row.name) - items_str = ";".join(f"{k}={v}" for k, v in row.items()) - return f"({idx}) -> {items_str}" - - pd_result = pd_df.apply(pd_foo, axis=1) - - assert_series_equal(bf_result, pd_result, check_dtype=False, check_index_type=False) - - def test_df_pipe( scalars_df_index, scalars_pandas_df_index, @@ -1459,7 +1395,9 @@ def test_df_merge(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1493,7 +1431,9 @@ def test_df_merge_multi_key(scalars_dfs, left_on, right_on): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1523,7 +1463,9 @@ 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_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) @pytest.mark.parametrize( @@ -1556,7 +1498,9 @@ def test_merge_left_on_right_on(scalars_dfs, merge_how): sort=True, ) - assert_frame_equal(bf_result, pd_result, ignore_order=True, check_index_type=False) + assert_pandas_df_equal( + bf_result, pd_result, ignore_order=True, check_index_type=False + ) def test_shape(scalars_dfs): @@ -1813,19 +1757,13 @@ def test_set_index_key_error(scalars_dfs): ("na_position",), (("first",), ("last",)), ) -@pytest.mark.parametrize( - ("axis",), - ((0,), ("columns",)), -) -def test_sort_index(scalars_dfs, ascending, na_position, axis): +def test_sort_index(scalars_dfs, ascending, na_position): 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() + bf_result = df.sort_index(ascending=ascending, na_position=na_position).to_pandas() pd_result = scalars_pandas_df.set_index(index_column).sort_index( - ascending=ascending, na_position=na_position, axis=axis + ascending=ascending, na_position=na_position ) pandas.testing.assert_frame_equal(bf_result, pd_result) @@ -1856,7 +1794,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_frame_equal(pd_result, bf_result) + assert_pandas_df_equal(pd_result, bf_result) def test_df_neg(scalars_dfs): @@ -1864,7 +1802,7 @@ 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_frame_equal(pd_result, bf_result) + assert_pandas_df_equal(pd_result, bf_result) def test_df_invert(scalars_dfs): @@ -1874,7 +1812,7 @@ def test_df_invert(scalars_dfs): bf_result = (~scalars_df[columns]).to_pandas() pd_result = ~scalars_pandas_df[columns] - assert_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_isnull(scalars_dfs): @@ -1891,7 +1829,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_df_notnull(scalars_dfs): @@ -1908,7 +1846,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2042,6 +1980,7 @@ def test_df_idxmax(): ], ) 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") @@ -2245,7 +2184,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize(("other_scalar"), [1, -2]) @@ -2257,7 +2196,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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) def test_scalar_binop_str_exception(scalars_dfs): @@ -2313,7 +2252,7 @@ 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_frame_equal(bf_result, pd_result) + assert_pandas_df_equal(bf_result, pd_result) @pytest.mark.parametrize( @@ -2341,7 +2280,7 @@ 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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_df_reverse_binop_pandas(scalars_dfs): @@ -2356,7 +2295,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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_listlike_binop_axis_1_bf_index(scalars_dfs): @@ -2371,7 +2310,7 @@ 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_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) def test_binop_with_self_aggregate(session, scalars_dfs): @@ -2385,7 +2324,7 @@ def test_binop_with_self_aggregate(session, scalars_dfs): pd_df = scalars_pandas_df[df_columns] pd_result = pd_df - pd_df.mean() - assert_frame_equal(bf_result, pd_result, check_dtype=False) + assert_pandas_df_equal(bf_result, pd_result, check_dtype=False) @pytest.mark.parametrize( @@ -2453,7 +2392,7 @@ def test_series_binop_add_different_table( scalars_pandas_df_index[series_column], axis="index" ) - assert_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) # TODO(garrettwu): Test series binop with different index @@ -2488,7 +2427,7 @@ def test_join_same_table(scalars_dfs, how): pd_result = pd_df_a.join(pd_df_b, how=how) - assert_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2501,7 +2440,7 @@ 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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2558,7 +2497,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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @all_joins @@ -2579,7 +2518,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_frame_equal(bf_result, pd_result, ignore_order=True) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=True) @pytest.mark.parametrize( @@ -2742,12 +2681,10 @@ 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() - # 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 = scalars_pandas_df_index[col_names].pct_change(periods=periods) + pd.testing.assert_frame_equal( pd_result, bf_result, - nulls_are_nan=True, ) @@ -2860,7 +2797,7 @@ def test_df_transpose(): 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) + pd.testing.assert_frame_equal(pd_result, bf_result, check_dtype=False) def test_df_transpose_error(): @@ -3086,7 +3023,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_frame_equal(bf_result, pd_result, ignore_order=not ordered) + assert_pandas_df_equal(bf_result, pd_result, ignore_order=not ordered) @pytest.mark.parametrize( @@ -3443,8 +3380,9 @@ def test_dataframe_aggregates_axis_1(scalars_df_index, scalars_pandas_df_index, 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 - assert_series_equal(pd_result, bf_result, check_index_type=False, check_dtype=False) + pd.testing.assert_series_equal(pd_result, bf_result, check_index_type=False) @pytest.mark.parametrize( @@ -3854,7 +3792,7 @@ def test_df_setattr_index(): pd_df.index = pandas.Index([4, 5]) bf_df.index = [4, 5] - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -3869,7 +3807,7 @@ def test_df_setattr_columns(): bf_df.columns = pandas.Index([4, 5, 6]) - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -3882,7 +3820,7 @@ def test_df_setattr_modify_column(): pd_df.my_column = [4, 5] bf_df.my_column = [4, 5] - assert_frame_equal( + assert_pandas_df_equal( pd_df, bf_df.to_pandas(), check_index_type=False, check_dtype=False ) @@ -3968,6 +3906,7 @@ def test_iloc_list_multiindex(scalars_dfs): def test_iloc_empty_list(scalars_df_index, scalars_pandas_df_index): + index_list: List[int] = [] bf_result = scalars_df_index.iloc[index_list] @@ -4112,7 +4051,9 @@ 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) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_dict_index_orient(): @@ -4121,7 +4062,9 @@ 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_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_dict_tight_orient(): @@ -4135,7 +4078,9 @@ 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_frame_equal(pd_result, bf_result, check_dtype=False, check_index_type=False) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_from_records(): @@ -4145,7 +4090,9 @@ def test_df_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) + assert_pandas_df_equal( + pd_result, bf_result, check_dtype=False, check_index_type=False + ) def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): @@ -4157,12 +4104,9 @@ def test_df_to_dict(scalars_df_index, scalars_pandas_df_index): def test_df_to_json_local_str(scalars_df_index, scalars_pandas_df_index): - # pandas 3.0 bugged for serializing date col - bf_result = scalars_df_index.drop(columns="date_col").to_json() + bf_result = scalars_df_index.to_json() # default_handler for arrow types that have no default conversion - pd_result = scalars_pandas_df_index.drop(columns="date_col").to_json( - default_handler=str - ) + pd_result = scalars_pandas_df_index.to_json(default_handler=str) assert bf_result == pd_result @@ -4173,10 +4117,7 @@ def test_df_to_json_local_file(scalars_df_index, scalars_pandas_df_index): # 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, - ): + 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( @@ -4198,10 +4139,7 @@ def test_df_to_csv_local_str(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -4225,10 +4163,7 @@ def test_df_to_parquet_local_bytes(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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) @@ -4275,10 +4210,7 @@ def test_df_to_markdown(scalars_df_index, scalars_pandas_df_index): 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, - ): + 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() @@ -4374,13 +4306,8 @@ def test_df_value_counts(scalars_dfs, subset, normalize, ascending, dropna): 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, + pd.testing.assert_series_equal( + bf_result, pd_result, check_dtype=False, check_index_type=False ) @@ -4405,7 +4332,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_frame_equal(bf_df.to_pandas(), pd_df) + assert_pandas_df_equal(bf_df.to_pandas(), pd_df) def test_df_dot_inline(session): @@ -4526,10 +4453,3 @@ def test_dataframe_explode_reserve_order(session, ignore_index, ordered): def test_dataframe_explode_xfail(col_names): df = bpd.DataFrame({"A": [[0, 1, 2], [], [3, 4]]}) df.explode(col_names) - - -def test_recursion_limit_unit(scalars_df_index): - scalars_df_index = scalars_df_index[["int64_too", "int64_col", "float64_col"]] - for i in range(250): - scalars_df_index = scalars_df_index + 4 - scalars_df_index.to_pandas() diff --git a/tests/unit/test_dtypes.py b/tests/unit/test_dtypes.py deleted file mode 100644 index bb2b57d4090..00000000000 --- a/tests/unit/test_dtypes.py +++ /dev/null @@ -1,81 +0,0 @@ -# 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 - - -def test_convert_to_schema_field_list_description(): - bf_dtype = bigframes.dtypes.OBJ_REF_DTYPE - list_bf_dtype = bigframes.dtypes.list_type(bf_dtype) - field = bigframes.dtypes.convert_to_schema_field("my_list", list_bf_dtype) - assert field.description == "bigframes_dtype: OBJ_REF_DTYPE" - assert field.mode == "REPEATED" diff --git a/tests/unit/test_formatting_helpers.py b/tests/unit/test_formatting_helpers.py index 8917f540501..588ef6e8248 100644 --- a/tests/unit/test_formatting_helpers.py +++ b/tests/unit/test_formatting_helpers.py @@ -19,7 +19,6 @@ import google.cloud.bigquery as bigquery import pytest -import bigframes.core.events as bfevents import bigframes.formatting_helpers as formatting_helpers import bigframes.version @@ -31,7 +30,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_job(mock_query_job) + formatting_helpers.wait_for_query_job(mock_query_job) cap_exc.match("Test message 123.") cap_exc.match(constants.FEEDBACK_LINK) @@ -67,174 +66,7 @@ def test_get_formatted_bytes(test_input, expected): @pytest.mark.parametrize( - "test_input, expected", [(None, None), ("string", "string"), (66000, "a minute")] + "test_input, expected", [(None, None), ("string", "string"), (100000, "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 - - -def test_get_job_url(): - job_id = "my-job-id" - location = "us-central1" - project_id = "my-project" - expected_url = ( - f"https://console.cloud.google.com/bigquery?project={project_id}" - f"&j=bq:{location}:{job_id}&page=queryresults" - ) - - actual_url = formatting_helpers.get_job_url( - job_id=job_id, location=location, project_id=project_id - ) - assert actual_url == expected_url - - -def test_progress_callback_falls_back_to_global(): - event = bfevents.BigQuerySentEvent( - query="SELECT * FROM my_table", - ) - envelope = bfevents.EventEnvelope(event=event, progress_bar=bfevents._DEFAULT) - - with mock.patch("bigframes._config.options.display.progress_bar", "terminal"): - with mock.patch("bigframes.formatting_helpers.in_ipython", return_value=False): - with mock.patch("builtins.print") as mock_print: - formatting_helpers.create_progress_callback()(envelope) - mock_print.assert_called_once() - - -def test_progress_callback_respects_envelope_progress_bar(): - event = bfevents.BigQuerySentEvent( - query="SELECT * FROM my_table", - ) - envelope = bfevents.EventEnvelope(event=event, progress_bar=None) - - with mock.patch("bigframes._config.options.display.progress_bar", "terminal"): - with mock.patch("bigframes.formatting_helpers.in_ipython", return_value=False): - with mock.patch("builtins.print") as mock_print: - formatting_helpers.create_progress_callback()(envelope) - mock_print.assert_not_called() diff --git a/tests/unit/test_groupby_transpile.py b/tests/unit/test_groupby_transpile.py deleted file mode 100644 index 4f841bc1b2d..00000000000 --- a/tests/unit/test_groupby_transpile.py +++ /dev/null @@ -1,200 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 pathlib import Path - -import pandas as pd -import pytest -from pandas.testing import assert_frame_equal, assert_series_equal - -import bigframes -import bigframes.core.global_session -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 = Path(__file__).parent -DATA_DIR = CURRENT_DIR.parent / "data" - - -@pytest.fixture(scope="module") -def scalars_pandas_df_index(): - 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", autouse=True) -def session(): - # import inline to allow polars importorskip to happen first - from bigframes.testing import polars_session - - with bpd.option_context("experiments.enable_python_transpiler", True): - session = polars_session.TestSession() - with bigframes.core.global_session._GlobalSessionContext(session): - yield session - - -@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) - - -# Tests for groupby.agg custom lambdas - - -def test_series_groupby_agg_transpile(scalars_df_index, scalars_pandas_df_index): - def custom_agg(s): - return s.sum() - s.mean() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna(subset=["int64_col", "bool_col"]) - - bf_result = bf_df.groupby("bool_col")["int64_col"].agg(custom_agg).to_pandas() - pd_result = pd_df.groupby("bool_col")["int64_col"].agg(custom_agg) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_dataframe_groupby_agg_func_transpile( - scalars_df_index, scalars_pandas_df_index -): - def custom_agg(s): - return (s.max() - s.min()) / s.count() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "int64_too", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna( - subset=["int64_col", "int64_too", "bool_col"] - ) - - bf_result = ( - bf_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .agg(custom_agg) - .to_pandas() - ) - pd_result = ( - pd_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .agg(custom_agg) - ) - - assert_frame_equal(bf_result, pd_result, check_dtype=False) - - -def test_dataframe_groupby_agg_dict_transpile( - scalars_df_index, scalars_pandas_df_index -): - def custom_agg1(s): - return s.sum() - s.mean() - - def custom_agg2(s): - return s.max() - s.min() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "int64_too", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna( - subset=["int64_col", "int64_too", "bool_col"] - ) - - bf_result = ( - bf_df.groupby("bool_col") - .agg({"int64_col": custom_agg1, "int64_too": custom_agg2}) - .to_pandas() - ) - pd_result = pd_df.groupby("bool_col").agg( - {"int64_col": custom_agg1, "int64_too": custom_agg2} - ) - - assert_frame_equal(bf_result, pd_result, check_dtype=False) - - -def test_dataframe_groupby_agg_list_transpile( - scalars_df_index, scalars_pandas_df_index -): - def custom_agg1(s): - return s.sum() - s.mean() - - def custom_agg2(s): - return s.max() - s.min() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna(subset=["int64_col", "bool_col"]) - - bf_result = ( - bf_df[["int64_col", "bool_col"]] - .groupby("bool_col") - .agg([custom_agg1, custom_agg2]) - .to_pandas() - ) - pd_result = ( - pd_df[["int64_col", "bool_col"]] - .groupby("bool_col") - .agg([custom_agg1, custom_agg2]) - ) - - assert_frame_equal(bf_result, pd_result, check_dtype=False) - - -# Tests for groupby.transform broadcasting lambdas - - -def test_series_groupby_transform_transpile(scalars_df_index, scalars_pandas_df_index): - def custom_transform(s): - return s - s.mean() - - bf_df = scalars_df_index.dropna(subset=["int64_col", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna(subset=["int64_col", "bool_col"]) - - bf_result = ( - bf_df.groupby("bool_col")["int64_col"].transform(custom_transform).to_pandas() - ) - pd_result = pd_df.groupby("bool_col")["int64_col"].transform(custom_transform) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_dataframe_groupby_transform_transpile( - scalars_df_index, scalars_pandas_df_index -): - def custom_transform(s): - return (s - s.min()) / (s.max() - s.min()) - - bf_df = scalars_df_index.dropna(subset=["int64_col", "int64_too", "bool_col"]) - pd_df = scalars_pandas_df_index.dropna( - subset=["int64_col", "int64_too", "bool_col"] - ) - - bf_result = ( - bf_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .transform(custom_transform) - .to_pandas() - ) - pd_result = ( - pd_df[["int64_col", "int64_too", "bool_col"]] - .groupby("bool_col") - .transform(custom_transform) - ) - - assert_frame_equal(bf_result, pd_result, check_dtype=False) diff --git a/tests/unit/test_iloc_getitem.py b/tests/unit/test_iloc_getitem.py deleted file mode 100644 index 7f030a16c92..00000000000 --- a/tests/unit/test_iloc_getitem.py +++ /dev/null @@ -1,297 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Generator - -import numpy as np -import pandas as pd -import pyarrow as pa -import pytest - -import bigframes -import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal, assert_series_equal - -pytest.importorskip("polars") - - -@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 sample_df() -> bpd.DataFrame: - pd_df = pd.DataFrame( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": [7, 8, 9], - } - ) - return bpd.read_pandas(pd_df) - - -@pytest.fixture -def unordered_sample_df( - sample_df: bpd.DataFrame, -) -> Generator[bpd.DataFrame, None, None]: - session = sample_df._session - original_strictly_ordered = session._strictly_ordered - original_allow_ambiguity = session._allow_ambiguity - - try: - session._strictly_ordered = False - session._allow_ambiguity = True - - import unittest.mock as mock - - with ( - mock.patch.object( - type(sample_df._block.expr), - "order_ambiguous", - new_callable=mock.PropertyMock, - ) as mock_ambiguous, - mock.patch.object( - type(sample_df._block), - "explicitly_ordered", - new_callable=mock.PropertyMock, - ) as mock_explicit, - ): - mock_ambiguous.return_value = True - mock_explicit.return_value = False - yield sample_df - finally: - session._strictly_ordered = original_strictly_ordered - session._allow_ambiguity = original_allow_ambiguity - - -@pytest.fixture -def duplicate_columns_df() -> bpd.DataFrame: - pd_df = pd.DataFrame( - [[1, 2, 3], [4, 5, 6], [7, 8, 9]], - columns=["A", "B", "A"], - ) - return bpd.read_pandas(pd_df) - - -def test_iloc_getitem_column_single_integer(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[:, 1].to_pandas() - pd_result = pd_df.iloc[:, 1] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_column_numpy_scalar(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[:, np.int64(1)].to_pandas() - pd_result = pd_df.iloc[:, np.int64(1)] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_columns_numpy_array(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[:, np.array([0, 2], dtype=np.int64)].to_pandas() - pd_result = pd_df.iloc[:, np.array([0, 2], dtype=np.int64)] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_column_pyarrow_scalar(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[:, pa.scalar(1, type=pa.int64())].to_pandas() - pd_result = pd_df.iloc[:, 1] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_columns_pyarrow_array(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[:, pa.array([0, 2], type=pa.int64())].to_pandas() - pd_result = pd_df.iloc[:, pa.array([0, 2], type=pa.int64())] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_row_numpy_scalar(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[np.int64(1)] - pd_result = pd_df.iloc[np.int64(1)] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_rows_numpy_array(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[np.array([0, 2], dtype=np.int64)].to_pandas() - pd_result = pd_df.iloc[np.array([0, 2], dtype=np.int64)] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_row_pyarrow_scalar(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[pa.scalar(1, type=pa.int64())] - pd_result = pd_df.iloc[1] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_rows_pyarrow_array(sample_df): - bf_df = sample_df - pd_df = sample_df.to_pandas() - - bf_result = bf_df.iloc[pa.array([0, 2], type=pa.int64())].to_pandas() - pd_result = pd_df.iloc[pa.array([0, 2], type=pa.int64())] - - assert_frame_equal(bf_result, pd_result) - - -@pytest.mark.parametrize( - ["key", "value", "expected_error"], - [ - pytest.param((slice(None), 1), None, None, id="col_index"), - pytest.param((slice(0, None), 1), None, None, id="col_index_slice_0_none"), - pytest.param( - (slice(None, None, 1), 1), None, None, id="col_index_slice_none_none_1" - ), - pytest.param( - (slice(1, None), 1), - None, - bigframes.exceptions.OrderRequiredError, - id="col_index_slice_1_none", - ), - pytest.param( - (slice(None, 2), 1), - None, - bigframes.exceptions.OrderRequiredError, - id="col_index_slice_none_2", - ), - pytest.param((slice(None), 1), 99, None, id="col_setitem"), - pytest.param( - (1, slice(None)), - None, - bigframes.exceptions.OrderRequiredError, - id="row_index_slice", - ), - pytest.param( - 1, - None, - bigframes.exceptions.OrderRequiredError, - id="single_row_index", - ), - ], -) -def test_iloc_getitem_unordered(unordered_sample_df, key, value, expected_error): - if value is not None: - bf_df = unordered_sample_df.copy() - bf_df.iloc[key] = value - elif expected_error is not None: - with pytest.raises(expected_error): - unordered_sample_df.iloc[key] - else: - unordered_sample_df.iloc[key] - - -def test_iloc_getitem_duplicate_columns_single_integer(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, 2].to_pandas() - pd_result = pd_df.iloc[:, 2] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_list_integer(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, [0, 2]].to_pandas() - pd_result = pd_df.iloc[:, [0, 2]] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_slice(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, 1:3].to_pandas() - pd_result = pd_df.iloc[:, 1:3] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_numpy_scalar(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, np.int64(2)].to_pandas() - pd_result = pd_df.iloc[:, np.int64(2)] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_numpy_array(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, np.array([0, 2], dtype=np.int64)].to_pandas() - pd_result = pd_df.iloc[:, np.array([0, 2], dtype=np.int64)] - - assert_frame_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_pyarrow_scalar(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, pa.scalar(2, type=pa.int64())].to_pandas() - pd_result = pd_df.iloc[:, 2] - - assert_series_equal(bf_result, pd_result) - - -def test_iloc_getitem_duplicate_columns_pyarrow_array(duplicate_columns_df): - bf_df = duplicate_columns_df - pd_df = duplicate_columns_df.to_pandas() - - bf_result = bf_df.iloc[:, pa.array([0, 2], type=pa.int64())].to_pandas() - pd_result = pd_df.iloc[:, pa.array([0, 2], type=pa.int64())] - - assert_frame_equal(bf_result, pd_result) diff --git a/tests/unit/test_iloc_setitem.py b/tests/unit/test_iloc_setitem.py deleted file mode 100644 index 98c515fd4ee..00000000000 --- a/tests/unit/test_iloc_setitem.py +++ /dev/null @@ -1,245 +0,0 @@ -# Copyright 2026 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT 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 Generator - -import numpy as np -import pandas as pd -import pyarrow as pa -import pytest - -import bigframes -import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal - -pytest.importorskip("polars") - - -@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 sample_df() -> bpd.DataFrame: - pd_df = pd.DataFrame( - { - "A": [1, 2, 3], - "B": [4, 5, 6], - "C": [7, 8, 9], - } - ) - return bpd.read_pandas(pd_df) - - -def test_iloc_setitem_column_single_integer(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, 1] = 99 - pd_df.iloc[:, 1] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_column_single_integer_negative(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, -1] = 99 - pd_df.iloc[:, -1] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_list_integer(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, [0, 2]] = [99, 88] - pd_df.iloc[:, [0, 2]] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_slice(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, 0:2] = 99 - pd_df.iloc[:, 0:2] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_boolean_mask(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - mask = [True, False, True] - bf_df.iloc[:, mask] = 99 - pd_df.iloc[:, np.array(mask)] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_dataframe(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - value_df = bpd.DataFrame({"B": [99, 88, 77], "C": [66, 55, 44]}) - bf_df.iloc[:, 1:3] = value_df - pd_df.iloc[:, 1:3] = value_df.to_pandas() - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_column_numpy_scalar(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, np.int64(1)] = 99 - pd_df.iloc[:, np.int64(1)] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_numpy_array(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, np.array([0, 2], dtype=np.int64)] = [99, 88] - pd_df.iloc[:, np.array([0, 2], dtype=np.int64)] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_column_pyarrow_scalar(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, pa.scalar(1, type=pa.int64())] = 99 - pd_df.iloc[:, 1] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_columns_pyarrow_array(sample_df): - bf_df = sample_df.copy() - pd_df = sample_df.to_pandas() - - bf_df.iloc[:, pa.array([0, 2], type=pa.int64())] = [99, 88] - pd_df.iloc[:, pa.array([0, 2], type=pa.int64())] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -@pytest.mark.parametrize( - ["key", "expected_error"], - [ - pytest.param((slice(None), 3), IndexError, id="out_of_bounds_positive"), - pytest.param((slice(None), -4), IndexError, id="out_of_bounds_negative"), - pytest.param((0, 1), NotImplementedError, id="invalid_row_indexer"), - pytest.param((slice(None), "B"), TypeError, id="invalid_col_indexer_type"), - ], -) -def test_iloc_setitem_column_errors(sample_df, key, expected_error): - bf_df = sample_df.copy() - - with pytest.raises(expected_error): - bf_df.iloc[key] = 99 - - -@pytest.fixture -def duplicate_columns_df() -> bpd.DataFrame: - pd_df = pd.DataFrame( - [[1, 2, 3], [4, 5, 6], [7, 8, 9]], - columns=["A", "B", "A"], - ) - return bpd.read_pandas(pd_df) - - -def test_iloc_setitem_duplicate_columns_single_integer(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, 2] = 99 - pd_df.iloc[:, 2] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_list_integer(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, [0, 2]] = [99, 88] - pd_df.iloc[:, [0, 2]] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_slice(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, 1:3] = 99 - pd_df.iloc[:, 1:3] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_numpy_scalar(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, np.int64(2)] = 99 - pd_df.iloc[:, np.int64(2)] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_numpy_array(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, np.array([0, 2], dtype=np.int64)] = [99, 88] - pd_df.iloc[:, np.array([0, 2], dtype=np.int64)] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_pyarrow_scalar(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, pa.scalar(2, type=pa.int64())] = 99 - pd_df.iloc[:, 2] = 99 - - assert_frame_equal(bf_df.to_pandas(), pd_df) - - -def test_iloc_setitem_duplicate_columns_pyarrow_array(duplicate_columns_df): - bf_df = duplicate_columns_df.copy() - pd_df = duplicate_columns_df.to_pandas() - - bf_df.iloc[:, pa.array([0, 2], type=pa.int64())] = [99, 88] - pd_df.iloc[:, pa.array([0, 2], type=pa.int64())] = [99, 88] - - assert_frame_equal(bf_df.to_pandas(), pd_df) diff --git a/tests/unit/test_index.py b/tests/unit/test_index.py index b875d56e7a0..97f1e4419e9 100644 --- a/tests/unit/test_index.py +++ b/tests/unit/test_index.py @@ -12,7 +12,6 @@ # 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 @@ -39,13 +38,3 @@ def test_index_rename_inplace_returns_none(monkeypatch: pytest.MonkeyPatch): # 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 deleted file mode 100644 index 87f6c91e237..00000000000 --- a/tests/unit/test_interchange.py +++ /dev/null @@ -1,108 +0,0 @@ -# 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 index 1537c896fb2..dfd1cd622fe 100644 --- a/tests/unit/test_local_data.py +++ b/tests/unit/test_local_data.py @@ -20,21 +20,20 @@ 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}], + "ints": [10, 20, 30, 40], + "nested_ints": [[1, 2], [3, 4, 5], [], [20, 30]], + "structs": [{"a": 100}, {}, {"b": 200}, {"b": 300}], } ) pd_data_normalized = pd.DataFrame( { - "ints": pd.Series([10, 20, 30, 40, 50], dtype=dtypes.INT_DTYPE), + "ints": pd.Series([10, 20, 30, 40], dtype=dtypes.INT_DTYPE), "nested_ints": pd.Series( - [[1, 2], [], [3, 4, 5], [], [20, 30]], - dtype=pd.ArrowDtype(pa.list_(pa.int64())), + [[1, 2], [3, 4, 5], [], [20, 30]], dtype=pd.ArrowDtype(pa.list_(pa.int64())) ), "structs": pd.Series( - [{"a": 100}, None, {}, {"b": 200}, {"b": 300}], + [{"a": 100}, {}, {"b": 200}, {"b": 300}], dtype=pd.ArrowDtype(pa.struct({"a": pa.int64(), "b": pa.int64()})), ), } @@ -44,12 +43,6 @@ 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) @@ -124,28 +117,16 @@ def test_local_data_well_formed_round_trip_chunked(): 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()) + as_rechunked_pyarrow = pa.Table.from_batches(pa_table.slice(2, 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), + pd_data_normalized[2:4].reset_index(drop=True), result.reset_index(drop=True), check_dtype=False, ) @@ -162,25 +143,3 @@ def test_local_data_not_equal_other(): 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 fe5052771f2..509bc6ade21 100644 --- a/tests/unit/test_local_engine.py +++ b/tests/unit/test_local_engine.py @@ -19,12 +19,19 @@ import bigframes import bigframes.pandas as bpd -from bigframes.testing.utils import assert_frame_equal, assert_series_equal pytest.importorskip("polars") pytest.importorskip("pandas", minversion="2.0.0") +# All tests in this file require polars to be installed to pass. +@pytest.fixture(scope="module") +def polars_session(): + from bigframes.testing import polars_session + + return polars_session.TestSession() + + @pytest.fixture(scope="module") def small_inline_frame() -> pd.DataFrame: df = pd.DataFrame( @@ -43,14 +50,6 @@ def small_inline_frame() -> pd.DataFrame: return df -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 ): @@ -75,9 +74,9 @@ def test_polars_local_engine_filter(small_inline_frame: pd.DataFrame, polars_ses pd_df = small_inline_frame bf_df = bpd.DataFrame(pd_df, session=polars_session) - 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) + 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) def test_polars_local_engine_series_rename_with_mapping(polars_session): @@ -89,7 +88,7 @@ def test_polars_local_engine_series_rename_with_mapping(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) + pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) def test_polars_local_engine_series_rename_with_mapping_inplace(polars_session): @@ -104,7 +103,7 @@ def test_polars_local_engine_series_rename_with_mapping_inplace(polars_session): 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) + pandas.testing.assert_series_equal(bf_result, pd_result, check_index_type=False) def test_polars_local_engine_reset_index( @@ -130,12 +129,11 @@ 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 - assert_frame_equal( + pandas.testing.assert_frame_equal( bf_result.sort_index(), pd_result.sort_index(), check_dtype=False, check_index_type=False, - nulls_are_nan=True, ) @@ -170,12 +168,7 @@ def test_polars_local_engine_agg(polars_session): bf_result = bf_df.agg(["sum", "count"]).to_pandas() pd_result = pd_df.agg(["sum", "count"]) # local engine appears to produce uint32 - pandas.testing.assert_frame_equal( - bf_result, # type: ignore[arg-type] - pd_result, - check_dtype=False, - check_index_type=False, - ) + pandas.testing.assert_frame_equal(bf_result, pd_result, check_dtype=False, check_index_type=False) # type: ignore def test_polars_local_engine_groupby_sum(polars_session): diff --git a/tests/unit/test_notebook.py b/tests/unit/test_notebook.py index 3feacd52b29..a41854fb29a 100644 --- a/tests/unit/test_notebook.py +++ b/tests/unit/test_notebook.py @@ -12,15 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -import pathlib -REPO_ROOT = pathlib.Path(__file__).parent.parent.parent +import os.path 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 ( - REPO_ROOT / "notebooks" / "getting_started" / "bq_dataframes_template.ipynb" - ).exists() + assert os.path.exists("notebooks/getting_started/bq_dataframes_template.ipynb") diff --git a/tests/unit/test_pandas.py b/tests/unit/test_pandas.py index c85d92e024d..e8383512a6c 100644 --- a/tests/unit/test_pandas.py +++ b/tests/unit/test_pandas.py @@ -14,6 +14,7 @@ import inspect import re +import sys import unittest.mock as mock import pandas as pd @@ -36,8 +37,6 @@ def all_session_methods(): session_attributes.remove("close") # streaming isn't in pandas session_attributes.remove("read_gbq_table_streaming") - # execution_history is in base namespace, not pandas - session_attributes.remove("execution_history") for attribute in sorted(session_attributes): session_method = getattr(bigframes.session.Session, attribute) @@ -53,6 +52,11 @@ def all_session_methods(): [(method_name,) for method_name in all_session_methods()], ) def test_method_matches_session(method_name: str): + if sys.version_info < (3, 10): + pytest.skip( + "Need Python 3.10 to reconcile deferred annotations." + ) # pragma: no cover + session_method = getattr(bigframes.session.Session, method_name) session_doc = inspect.getdoc(session_method) assert session_doc is not None, "docstrings are required" @@ -60,12 +64,8 @@ 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" - - 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 + assert re.sub(leading_whitespace, "", pandas_doc) == re.sub( + leading_whitespace, "", session_doc ) # Add `eval_str = True` so that deferred annotations are turned into their @@ -75,20 +75,18 @@ def test_method_matches_session(method_name: str): eval_str=True, globals={**vars(bigframes.session), **{"dataframe": bigframes.dataframe}}, ) - 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:] pandas_signature = inspect.signature(pandas_method, eval_str=True) - pandas_args = [ + assert [ # 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 + ] == [ + # 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: ] assert pandas_signature.return_annotation == session_signature.return_annotation @@ -124,7 +122,6 @@ def test_method_matches_session(method_name: str): ) 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) @@ -163,14 +160,12 @@ def test_cut_raises_with_unsupported_labels(): ) 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 pd.NA is pd.NA + assert bpd.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 36a568a4165..c64b50395ba 100644 --- a/tests/unit/test_planner.py +++ b/tests/unit/test_planner.py @@ -19,9 +19,9 @@ import pandas as pd import bigframes.core as core -import bigframes.core.bq_data import bigframes.core.expression as ex import bigframes.core.identifiers as ids +import bigframes.core.schema import bigframes.operations as ops import bigframes.session.planner as planner @@ -38,7 +38,8 @@ type(FAKE_SESSION)._strictly_ordered = mock.PropertyMock(return_value=True) LEAF: core.ArrayValue = core.ArrayValue.from_table( session=FAKE_SESSION, - table=bigframes.core.bq_data.GbqNativeTable.from_table(TABLE), + table=TABLE, + schema=bigframes.core.schema.ArraySchema.from_bq_table(TABLE), ) diff --git a/tests/unit/test_py_udf.py b/tests/unit/test_py_udf.py deleted file mode 100644 index dcd6fa28658..00000000000 --- a/tests/unit/test_py_udf.py +++ /dev/null @@ -1,761 +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 pathlib -from typing import Generator - -import numpy as np -import pandas as pd -import pandas.testing -import pyarrow as pa -import pytest - -import bigframes -import bigframes.core.global_session -import bigframes.pandas as bpd -from bigframes.core.bytecode import py_to_expression -from bigframes.testing.utils import ( - assert_frame_equal, - assert_series_equal, - 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 inline to allow polars importorskip to happen first - from bigframes.testing import polars_session - - with bpd.option_context("experiments.enable_python_transpiler", True): - 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_dfs( - scalars_df_index, - scalars_pandas_df_index, -): - return scalars_df_index, scalars_pandas_df_index - - -def test_dataframe_map_transpile( - scalars_df_index, - scalars_pandas_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo(input): - return input * 3 + 12 - - bf_result = scalars_df_index[columns].map(foo, na_action="ignore").to_pandas() - - pd_result = ( - scalars_pandas_df_index[columns].map(foo, na_action="ignore").astype("Int64") - ) - - assert_frame_equal(bf_result, pd_result) - - -def test_dataframe_apply_axis_1_transpile( - scalars_df_index, - scalars_pandas_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo(input): - return input.int64_too + input.int64_col - - bf_result = scalars_df_index[columns].apply(foo, axis=1).to_pandas() - - pd_result = scalars_pandas_df_index[columns].apply(foo, axis=1).astype("Int64") - - assert_series_equal(bf_result, pd_result) - - -def test_series_combine_transpile( - scalars_df_index, - scalars_pandas_df_index, -): - def which_smaller(left, right): - return (left * right) + 3 - - bf_result = ( - scalars_df_index["int64_too"] - .combine(scalars_df_index["int64_col"], which_smaller) - .to_pandas() - ) - - pd_result = scalars_pandas_df_index["int64_too"].combine( - scalars_pandas_df_index["int64_col"], which_smaller - ) - - assert_series_equal(bf_result, pd_result) - - -def test_dataframe_apply_axis_1_transpile_with_defaults( - scalars_df_index, - scalars_pandas_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo(input, x=10, y=5): - return input.int64_too + input.int64_col + x + y - - bf_result = scalars_df_index[columns].apply(foo, axis=1).to_pandas() - pd_result = scalars_pandas_df_index[columns].apply(foo, axis=1).astype("Int64") - - assert_series_equal(bf_result, pd_result) - - -def test_dataframe_apply_axis_1_transpile_with_args( - scalars_df_index, - scalars_pandas_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo(input, x, y=5): - return input.int64_too + input.int64_col + x + y - - bf_result = ( - scalars_df_index[columns].apply(foo, axis=1, args=(12,), y=20).to_pandas() - ) - pd_result = ( - scalars_pandas_df_index[columns] - .apply(foo, axis=1, args=(12,), y=20) - .astype("Int64") - ) - - assert_series_equal(bf_result, pd_result) - - -def test_dataframe_apply_axis_1_transpile_invalid_bindings( - scalars_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo(input, x, y=5): - return input.int64_too + input.int64_col + x + y - - # 1. Unexpected keyword argument - with pytest.raises(TypeError, match="unexpected keyword argument 'z'"): - scalars_df_index[columns].apply(foo, axis=1, args=(10,), z=20) - - # 2. Multiple values for keyword argument 'x' - with pytest.raises(TypeError, match="multiple values for argument 'x'"): - scalars_df_index[columns].apply(foo, axis=1, args=(10,), x=20) - - # 3. Too many positional arguments - with pytest.raises(TypeError, match="too many positional arguments"): - scalars_df_index[columns].apply(foo, axis=1, args=(10, 20, 30)) - - # 4. Missing required argument 'x' - with pytest.raises(TypeError, match="missing a required argument: 'x'"): - scalars_df_index[columns].apply(foo, axis=1) - - -def test_series_apply_transpile( - scalars_df_index, - scalars_pandas_df_index, -): - def foo(x, y=10): - return x * 2 + y - - bf_result = scalars_df_index["int64_col"].apply(foo, args=(5,)).to_pandas() - pd_result = ( - scalars_pandas_df_index["int64_col"].apply(foo, args=(5,)).astype("Int64") - ) - - assert_series_equal(bf_result, pd_result) - - -def test_series_apply_transpile_invalid_bindings( - scalars_df_index, -): - def foo(x, y): - return x + y - - # Too many positional args: foo takes 2 args (x, y), we pass self and 2 more args (total 3 positional) - with pytest.raises( - TypeError, match="too many positional arguments: expected 2, got 3" - ): - scalars_df_index["int64_col"].apply(foo, args=(10, 20)) - - # Missing required argument: foo takes 2 args, we only pass self (so y is missing) - with pytest.raises(TypeError, match="missing required argument: 'y'"): - scalars_df_index["int64_col"].apply(foo) - - -def test_transpilation_unsupported_ops_raise( - scalars_df_index, -): - def foo_with_loop(x): - total = 0 - for i in range(x): - total += i - return total - - with pytest.raises(ValueError): - scalars_df_index["int64_col"].apply(foo_with_loop) - - -def my_foo(x: int): - return x + 1 - - -def test_local_series_apply_simple(scalars_df_index, scalars_pandas_df_index): - bf_result = scalars_df_index["int64_col"].apply(my_foo).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(my_foo) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def my_numpy_foo(x: int): - return np.add(x, x) * (np.cos(x) - np.sin(3)) - - -def test_local_series_apply_w_numpy(scalars_df_index, scalars_pandas_df_index): - bf_result = scalars_df_index["int64_col"].apply(my_numpy_foo).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(my_numpy_foo) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_simple_lamdba(scalars_df_index, scalars_pandas_df_index): - bf_result = scalars_df_index["int64_col"].apply(lambda x: x + 3).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(lambda x: x + 3) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_ternary_lamdba(scalars_df_index, scalars_pandas_df_index): - bf_result = ( - scalars_df_index["int64_col"] - .apply(lambda x: "positive" if x > 0 else "negative") - .to_pandas() - ) - pd_result = scalars_pandas_df_index["int64_col"].apply( - lambda x: "positive" if x > 0 else "negative" - ) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_nested_fizzbuzz(session): - # challenging: closure, multiple exits, mutating variables - foo_div = 3 - buzz_div = 5 - pd_series = pd.Series( - range(20), - dtype="Int64", - index=pd.Index(range(20), dtype="Int64"), - name="integers", - ) - bf_series = bpd.Series(pd_series, session=session) - - def fizzbuzz(x): - if (x % 3) and (x % 5): - return str(x) - val = "" - if (x % foo_div) == 0: - val += "fizz" - if (x % buzz_div) == 0: - val += "buzz" - return val - - bf_result = bf_series.apply(fizzbuzz).to_pandas() - pd_result = pd_series.apply(fizzbuzz).astype(pd.StringDtype(storage="pyarrow")) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_dataframe_apply_w_ternary_lamdba( - scalars_df_index, scalars_pandas_df_index -): - bf_result = scalars_df_index.apply( - lambda x: x.int64_col if x.rowindex_2 > 5 else x.float64_col, axis=1 - ).to_pandas() - pd_result = scalars_pandas_df_index.apply( - lambda x: x.int64_col if x.rowindex_2 > 5 else x.float64_col, axis=1 - ).astype("Float64") - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_nested_ifs(scalars_df_index, scalars_pandas_df_index): - def nested_ifs(x): - if x > 0: - if x > 100: - return x * 10 - else: - return x * 2 - else: - if x < -100: - return x * 20 - return x * -1 - - bf_result = scalars_df_index["int64_col"].apply(nested_ifs).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(nested_ifs) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_elif(scalars_df_index, scalars_pandas_df_index): - def elif_fn(x): - if x > 100: - return 1 - elif x > 50: - return 2 - elif x > 0: - return 3 - else: - return 4 - - bf_result = scalars_df_index["int64_col"].apply(elif_fn).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(elif_fn) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_logical_not(scalars_df_index, scalars_pandas_df_index): - def logical_not_fn(x): - if not (x > 0): - return -x - return x - - bf_result = scalars_df_index["int64_col"].apply(logical_not_fn).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(logical_not_fn) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_short_circuit(scalars_df_index, scalars_pandas_df_index): - def short_circuit(x): - if (x > 0 and x < 100) or x == 55555: - return 1 - return 0 - - bf_result = scalars_df_index["int64_col"].apply(short_circuit).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(short_circuit) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_var_assignments( - scalars_df_index, scalars_pandas_df_index -): - def var_assign(x): - val = x - if x > 0: - val = val + 10 - if val > 100: - val = val * 2 - else: - val = val - 10 - return val - - bf_result = scalars_df_index["int64_col"].apply(var_assign).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].apply(var_assign) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_logical_and_val( - scalars_df_index, scalars_pandas_df_index -): - def logical_and_val(x): - return (x % 3) and 100 - - bf_result = ( - scalars_df_index["int64_col"].dropna().apply(logical_and_val).to_pandas() - ) - pd_result = scalars_pandas_df_index["int64_col"].dropna().apply(logical_and_val) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_logical_or_val(scalars_df_index, scalars_pandas_df_index): - def logical_or_val(x): - return (x % 3) or 200 - - bf_result = scalars_df_index["int64_col"].dropna().apply(logical_or_val).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].dropna().apply(logical_or_val) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_logical_and_mixed( - scalars_df_index, -): - def logical_and_mixed(x): - return (x % 3) and "hello" - - with pytest.raises(TypeError, match="Cannot coerce"): - scalars_df_index["int64_col"].apply(logical_and_mixed) - - -def test_local_series_apply_w_logical_not_val( - scalars_df_index, scalars_pandas_df_index -): - def logical_not_val(x): - return not x - - bf_result = scalars_df_index["bool_col"].dropna().apply(logical_not_val).to_pandas() - pd_result = scalars_pandas_df_index["bool_col"].dropna().apply(logical_not_val) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_local_series_apply_w_compare_chain(scalars_df_index, scalars_pandas_df_index): - def compare_chain(x): - return 0 < x < 1000 - - bf_result = scalars_df_index["int64_col"].dropna().apply(compare_chain).to_pandas() - pd_result = scalars_pandas_df_index["int64_col"].dropna().apply(compare_chain) - - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_dataframe_apply_axis_1_with_integer_subscript( - scalars_df_index, scalars_pandas_df_index -): - columns = ["int64_too", "int64_col"] - bf_df = scalars_df_index[columns].rename(columns={"int64_too": 0, "int64_col": 1}) - pd_df = scalars_pandas_df_index[columns].rename( - columns={"int64_too": 0, "int64_col": 1} - ) - - def foo(input): - return input[0] + input[1] - - bf_result = bf_df.apply(foo, axis=1).to_pandas() - pd_result = pd_df.apply(foo, axis=1).astype("Int64") - - assert_series_equal(bf_result, pd_result) - - -def test_dataframe_apply_axis_1_with_invalid_subscript_raises( - scalars_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo_invalid_label(input): - return input["non_existent_column"] - - with pytest.raises(KeyError, match="non_existent_column"): - scalars_df_index[columns].apply(foo_invalid_label, axis=1) - - -def test_series_map_with_struct_subscript(session): - # Struct setup - struct_pa_type = pa.struct([("str_field", pa.string()), ("int_field", pa.int64())]) - pd_struct_series = pd.Series( - pa.array([{"str_field": "hello", "int_field": 1}], struct_pa_type), - dtype=pd.ArrowDtype(struct_pa_type), - ) - bf_struct_series = bpd.Series(pd_struct_series, session=session) - - # Struct subscripting in UDF - def get_struct_val(x): - return x["str_field"] - - bf_struct_res = bf_struct_series.map(get_struct_val).to_pandas() - pd_struct_res: pd.Series = pd_struct_series.map(get_struct_val) - assert_series_equal(bf_struct_res, pd_struct_res, check_dtype=False) - - -def test_series_map_with_array_subscript(session): - # Array setup - array_pa_type = pa.list_(pa.int64()) - pd_array_series = pd.Series( - pa.array([[10, 20]], array_pa_type), - dtype=pd.ArrowDtype(array_pa_type), - ) - bf_array_series = bpd.Series(pd_array_series, session=session) - - # Array subscripting in UDF - def get_array_val(x): - return x[1] - - bf_array_res = bf_array_series.map(get_array_val).to_pandas() - pd_array_res: pd.Series = pd_array_series.map(get_array_val) - assert_series_equal(bf_array_res, pd_array_res, check_dtype=False) - - -def test_series_map_with_string_subscript(session): - # String setup - pd_string_series = pd.Series(["hello", "world"]) - bf_string_series = bpd.Series(pd_string_series, session=session) - - # String subscripting in UDF - def get_string_val(x): - return x[1] - - bf_string_res = bf_string_series.map(get_string_val).to_pandas() - pd_string_res = pd_string_series.map(get_string_val) # type: ignore - assert_series_equal(bf_string_res, pd_string_res, check_dtype=False) - - -def test_dataframe_apply_axis_1_with_dynamic_subscript_raises( - scalars_df_index, -): - columns = ["int64_too", "int64_col"] - - def foo_dynamic(input): - return input[input[0]] - - with pytest.raises( - NotImplementedError, match="Dynamic column lookup is not supported" - ): - scalars_df_index[columns].apply(foo_dynamic, axis=1) - - -def test_dataframe_apply_axis_1_with_dynamic_array_subscript(session): - array_pa_type = pa.list_(pa.int64()) - pd_df = pd.DataFrame( - { - "array_col": pd.Series( - pa.array([[10, 20], [30, 40, 50], [60]], array_pa_type), - dtype=pd.ArrowDtype(array_pa_type), - ), - "index_col": pd.Series([1, 2, 0], dtype="Int64"), - } - ) - bf_df = bpd.DataFrame(pd_df, session=session) - - def foo(row): - return row["array_col"][row["index_col"]] - - bf_result = bf_df.apply(foo, axis=1).to_pandas() - pd_result = pd_df.apply(foo, axis=1).astype("Int64") - - assert_series_equal(bf_result, pd_result) - - -def test_series_apply_fstrings(session): - pd_series = pd.Series(["apple", "banana", None], dtype="string") - bf_series = bpd.Series(pd_series, session=session) - - def format_udf(x): - if x is None: - return "Null value" - return f"Fruit: {x}!" - - bf_result = bf_series.apply(format_udf).to_pandas() - pd_result = pd.Series( - ["Fruit: apple!", "Fruit: banana!", "Null value"], dtype="string" - ) - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_series_apply_nullity_jumps(session): - pd_series = pd.Series([10, None, 20], dtype="Int64") - bf_series = bpd.Series(pd_series, session=session) - - def nullity_udf(x): - if x is None: - return "Absent" - if x is not None: - return "Present" - return "Unknown" - - bf_result = bf_series.apply(nullity_udf).to_pandas() - pd_result = pd.Series(["Present", "Absent", "Present"], dtype="string") - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_series_apply_string_ops(session): - pd_series = pd.Series(["hello world", "BigFrames", "123a"], dtype="string") - bf_series = bpd.Series(pd_series, session=session) - - def str_udf(x): - if x is None: - return None - return x.upper() + " " + x.lower() + " " + str.upper(x) + " " + x.capitalize() - - bf_result = bf_series.apply(str_udf).to_pandas() - pd_result = pd.Series( - [ - "HELLO WORLD hello world HELLO WORLD Hello world", - "BIGFRAMES bigframes BIGFRAMES Bigframes", - "123A 123a 123A 123a", - ], - dtype="string", - ) - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_series_apply_string_predicates(session): - pd_series = pd.Series( - ["hello world", "abc123", "123", "HELLO!", None], dtype="string" - ) - bf_series = bpd.Series(pd_series, session=session) - - def predicates_udf(x): - if x is None: - return None - return f"{x.islower()}_{x.isupper()}" - - bf_result = bf_series.apply(predicates_udf).to_pandas() - pd_result = pd.Series( - ["True_False", "True_False", "False_False", "False_True", None], dtype="string" - ) - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_fstring_multiple_placeholders(session): - pd_series = pd.Series(["apple", "banana"], dtype="string") - bf_series = bpd.Series(pd_series, session=session) - - def multiple_placeholders(x): - return f"{x} and {x.upper()}!" - - bf_res = bf_series.apply(multiple_placeholders).to_pandas() - pd_res = pd.Series(["apple and APPLE!", "banana and BANANA!"], dtype="string") - assert_series_equal(bf_res, pd_res, check_dtype=False) - - -def test_fstring_empty(session): - pd_series = pd.Series(["apple", "banana"], dtype="string") - bf_series = bpd.Series(pd_series, session=session) - - def empty_fstring(x): - return "" - - bf_res = bf_series.apply(empty_fstring).to_pandas() - pd_res = pd.Series(["", ""], dtype="string") - assert_series_equal(bf_res, pd_res, check_dtype=False) - - -def test_fstring_consecutive_placeholders(session): - pd_series = pd.Series(["apple", "banana"], dtype="string") - bf_series = bpd.Series(pd_series, session=session) - - def consecutive_placeholders(x): - return f"{x}{x.upper()}" - - bf_res = bf_series.apply(consecutive_placeholders).to_pandas() - pd_res = pd.Series(["appleAPPLE", "bananaBANANA"], dtype="string") - assert_series_equal(bf_res, pd_res, check_dtype=False) - - -def test_fstring_with_specifier_raises(): - def format_with_spec(x): - return f"{x:2d}" - - with pytest.raises( - NotImplementedError, match="Formatting with specifier is not supported" - ): - py_to_expression(format_with_spec) - - -def test_fstring_with_repr_raises(): - def format_with_repr(x): - return f"{x!r}" - - with pytest.raises( - NotImplementedError, - match="repr\\(\\) and ascii\\(\\) conversions are not supported", - ): - py_to_expression(format_with_repr) - - -def test_fstring_with_ascii_raises(): - def format_with_ascii(x): - return f"{x!a}" - - with pytest.raises( - NotImplementedError, - match="repr\\(\\) and ascii\\(\\) conversions are not supported", - ): - py_to_expression(format_with_ascii) - - -def test_identity_unsupported_raises(): - def is_true_udf(x): - return x is True - - with pytest.raises( - NotImplementedError, - match="Identity comparison \\(is/is not\\) is only supported for None", - ): - py_to_expression(is_true_udf) - - -def test_fstring_int_input(session): - pd_int_series = pd.Series([10, 20, None], dtype="Int64") - bf_int_series = bpd.Series(pd_int_series, session=session) - bf_res_int = bf_int_series.apply(lambda x: f"val: {x}").to_pandas() - pd_res_int = pd.Series(["val: 10", "val: 20", None], dtype="string") - assert_series_equal(bf_res_int, pd_res_int, check_dtype=False) - - -def test_fstring_float_input(session): - pd_float_series = pd.Series([1.5, 2.75], dtype="Float64") - bf_float_series = bpd.Series(pd_float_series, session=session) - bf_res_float = bf_float_series.apply(lambda x: f"val: {x}").to_pandas() - pd_res_float = pd.Series(["val: 1.5", "val: 2.75"], dtype="string") - assert_series_equal(bf_res_float, pd_res_float, check_dtype=False) - - -def test_fstring_bool_input(session): - pd_bool_series = pd.Series([True, False], dtype="boolean") - bf_bool_series = bpd.Series(pd_bool_series, session=session) - bf_res_bool = bf_bool_series.apply(lambda x: f"val: {x}").to_pandas() - pd_res_bool = pd.Series(["val: True", "val: False"], dtype="string") - assert_series_equal(bf_res_bool, pd_res_bool, check_dtype=False) - - -def test_fstring_list_input_raises(session): - array_pa_type = pa.list_(pa.int64()) - pd_series = pd.Series( - pa.array([[10, 20]], array_pa_type), - dtype=pd.ArrowDtype(array_pa_type), - ) - bf_series = bpd.Series(pd_series, session=session) - - def udf_with_list(x): - return f"list: {x}" - - with pytest.raises((TypeError, ValueError)): - bf_series.apply(udf_with_list) diff --git a/tests/unit/test_series_polars.py b/tests/unit/test_series_polars.py deleted file mode 100644 index 8b6d97d8b4b..00000000000 --- a/tests/unit/test_series_polars.py +++ /dev/null @@ -1,5201 +0,0 @@ -# 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 -import pandas as pd -import pyarrow as pa # type: ignore -import pytest -import shapely.geometry # type: ignore -from packaging.version import Version - -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) - - -def test_series_map_with_udf(session): - series = bpd.Series([1, 2, None, 4], dtype="Int64") - - @session.udf(input_types=[int], output_type=int) - def foo(x): - if x is None: - return -1 - return x * 2 - - bf_result = series.map(foo).to_pandas() - pd_result = pd.Series([2, 4, -1, 8]) - assert_series_equal(bf_result, pd_result, check_dtype=False) - - -@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, - ) - - -def test_series_where_with_expression(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - s1 = scalars_df["float64_col"] - s2 = scalars_df["bool_col"] - - bf_result = s1.where(s2, bpd.col("bool_col")).to_pandas() - - s1_pd = scalars_pandas_df["float64_col"] - s2_pd = scalars_pandas_df["bool_col"] - - pd_result = s1_pd.where(s2_pd, s2_pd) - - pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) - - -def test_series_expression_unbound_fails(scalars_dfs): - scalars_df, _ = scalars_dfs - s1 = scalars_df["float64_col"] - s2 = scalars_df["bool_col"] - - with pytest.raises(ValueError, match="remains unbound"): - s1.where(s2, bpd.col("non_existent_column")) - - -def test_series_where_with_expression_resolving_to_self(scalars_dfs): - scalars_df, scalars_pandas_df = scalars_dfs - s1 = scalars_df["float64_col"] - s2 = scalars_df["bool_col"] - - bf_result = s1.where(s2, bpd.col("float64_col")).to_pandas() - - s1_pd = scalars_pandas_df["float64_col"] - s2_pd = scalars_pandas_df["bool_col"] - - pd_result = s1_pd.where(s2_pd, s1_pd) - - pd.testing.assert_series_equal(bf_result, pd_result, check_dtype=False) diff --git a/tests/unit/test_series_struct.py b/tests/unit/test_series_struct.py deleted file mode 100644 index f99d5859a56..00000000000 --- a/tests/unit/test_series_struct.py +++ /dev/null @@ -1,138 +0,0 @@ -# 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 TYPE_CHECKING, Generator - -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 aa331483a9c..6d55817a27d 100644 --- a/third_party/bigframes_vendored/constants.py +++ b/third_party/bigframes_vendored/constants.py @@ -23,7 +23,7 @@ import bigframes_vendored.version FEEDBACK_LINK = ( - "Share your use case with the BigQuery DataFrames team at the " + "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__}." ) @@ -55,6 +55,3 @@ "_deferred", ] VALID_WRITE_ENGINES = typing.get_args(WriteEngineType) - -DEFAULT_SORT_KIND = "stable" -STABLE_SORT_KINDS = ("stable", "mergesort") diff --git a/third_party/bigframes_vendored/cpython/_pprint.py b/third_party/bigframes_vendored/cpython/_pprint.py index 62450985816..9b586c939bd 100644 --- a/third_party/bigframes_vendored/cpython/_pprint.py +++ b/third_party/bigframes_vendored/cpython/_pprint.py @@ -70,11 +70,11 @@ # - removed global get_config, set _changed_only=True # - replace is_scalar_nan with isinstance(x, numbers.Real) and math.isnan +from collections import OrderedDict import inspect import math import numbers import pprint -from collections import OrderedDict from bigframes.ml.base import BaseEstimator diff --git a/third_party/bigframes_vendored/geopandas/geoseries.py b/third_party/bigframes_vendored/geopandas/geoseries.py index 6b56a318011..92a58b3dc6d 100644 --- a/third_party/bigframes_vendored/geopandas/geoseries.py +++ b/third_party/bigframes_vendored/geopandas/geoseries.py @@ -18,6 +18,7 @@ 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 @@ -72,6 +73,7 @@ def x(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry + >>> bpd.options.display.progress_bar = None >>> series = bpd.Series( ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], @@ -98,6 +100,7 @@ def y(self) -> bigframes.series.Series: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry + >>> bpd.options.display.progress_bar = None >>> series = bpd.Series( ... [shapely.geometry.Point(1, 2), shapely.geometry.Point(2, 3), shapely.geometry.Point(3, 4)], @@ -126,6 +129,7 @@ def boundary(self) -> bigframes.geopandas.GeoSeries: >>> import bigframes.pandas as bpd >>> import geopandas.array >>> import shapely.geometry + >>> bpd.options.display.progress_bar = None >>> from shapely.geometry import Polygon, LineString, Point >>> s = geopandas.GeoSeries( @@ -167,6 +171,7 @@ 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] @@ -205,6 +210,7 @@ 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)', @@ -240,6 +246,7 @@ 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 @@ -272,6 +279,7 @@ def difference(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignore >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point + >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row: @@ -403,6 +411,7 @@ def intersection(self: GeoSeries, other: GeoSeries) -> GeoSeries: # type: ignor >>> import bigframes as bpd >>> import bigframes.geopandas >>> from shapely.geometry import Polygon, LineString, Point + >>> bpd.options.display.progress_bar = None We can check two GeoSeries against each other, row by row. @@ -496,33 +505,3 @@ def is_closed(self: GeoSeries) -> bigframes.series.Series: ``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/retry.py b/third_party/bigframes_vendored/google_cloud_bigquery/retry.py index 9117d7aa569..15ecda4fbc4 100644 --- a/third_party/bigframes_vendored/google_cloud_bigquery/retry.py +++ b/third_party/bigframes_vendored/google_cloud_bigquery/retry.py @@ -13,10 +13,10 @@ # See the License for the specific language governing permissions and # limitations under the License. -import google.api_core.future.polling -import requests.exceptions 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"] diff --git a/third_party/bigframes_vendored/google_cloud_bigquery/tests/unit/test_pandas_helpers.py b/third_party/bigframes_vendored/google_cloud_bigquery/tests/unit/test_pandas_helpers.py index c87444f41e2..c798b0d1695 100644 --- a/third_party/bigframes_vendored/google_cloud_bigquery/tests/unit/test_pandas_helpers.py +++ b/third_party/bigframes_vendored/google_cloud_bigquery/tests/unit/test_pandas_helpers.py @@ -16,11 +16,11 @@ import functools import warnings +from google.cloud.bigquery import schema import pyarrow import pyarrow.parquet import pyarrow.types import pytest -from google.cloud.bigquery import schema @pytest.fixture diff --git a/third_party/bigframes_vendored/ibis/__init__.py b/third_party/bigframes_vendored/ibis/__init__.py index 54a896da889..236f471cd36 100644 --- a/third_party/bigframes_vendored/ibis/__init__.py +++ b/third_party/bigframes_vendored/ibis/__init__.py @@ -6,12 +6,12 @@ __version__ = "9.2.0" -import warnings from typing import Any +import warnings -import bigframes_vendored.ibis.backends.bigquery as bigquery from bigframes_vendored.ibis import util from bigframes_vendored.ibis.backends import BaseBackend +import bigframes_vendored.ibis.backends.bigquery as bigquery from bigframes_vendored.ibis.common.exceptions import IbisError from bigframes_vendored.ibis.config import options from bigframes_vendored.ibis.expr import api diff --git a/third_party/bigframes_vendored/ibis/backends/__init__.py b/third_party/bigframes_vendored/ibis/backends/__init__.py index 0d0feca9d38..86a6423d48a 100644 --- a/third_party/bigframes_vendored/ibis/backends/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/__init__.py @@ -7,27 +7,27 @@ import functools import importlib.metadata import keyword +from pathlib import Path import re +from typing import Any, ClassVar, TYPE_CHECKING import urllib.parse -from pathlib import Path -from typing import TYPE_CHECKING, Any, ClassVar import bigframes_vendored.ibis +from bigframes_vendored.ibis import util +from bigframes_vendored.ibis.common.caching import RefCountedCache import bigframes_vendored.ibis.common.exceptions as exc import bigframes_vendored.ibis.config import bigframes_vendored.ibis.expr.operations as ops import bigframes_vendored.ibis.expr.types as ir -from bigframes_vendored.ibis import util -from bigframes_vendored.ibis.common.caching import RefCountedCache if TYPE_CHECKING: from collections.abc import Iterable, Iterator, Mapping, MutableMapping from urllib.parse import ParseResult - import bigframes_vendored.sqlglot as sg import pandas as pd import polars as pl import pyarrow as pa + import sqlglot as sg import torch __all__ = ("BaseBackend", "connect") @@ -1257,7 +1257,7 @@ def _transpile_sql(self, query: str, *, dialect: str | None = None) -> str: if dialect is None: return query - import bigframes_vendored.sqlglot as sg + import sqlglot as sg # only transpile if the backend dialect doesn't match the input dialect name = self.name diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py b/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py index 5a84a6a80fd..a87cb081cbe 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/__init__.py @@ -9,21 +9,9 @@ import glob import os import re -from typing import TYPE_CHECKING, Any, Optional +from typing import Any, Optional, TYPE_CHECKING import bigframes_vendored.ibis -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 -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import google.api_core.exceptions -import google.auth.credentials -import google.cloud.bigquery as bq -import google.cloud.bigquery_storage_v1 as bqstorage -import pydata_google_auth from bigframes_vendored.ibis import util from bigframes_vendored.ibis.backends import CanCreateDatabase, CanCreateSchema from bigframes_vendored.ibis.backends.bigquery.client import ( @@ -33,10 +21,25 @@ schema_from_bigquery_table, ) from bigframes_vendored.ibis.backends.bigquery.datatypes import BigQuerySchema +from bigframes_vendored.ibis.backends.bigquery.udf.core import ( + PythonToJavaScriptTranslator, +) from bigframes_vendored.ibis.backends.sql import SQLBackend 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 +import google.api_core.exceptions +import google.auth.credentials +import google.cloud.bigquery as bq +import google.cloud.bigquery_storage_v1 as bqstorage +import pydata_google_auth from pydata_google_auth import cache +import sqlglot as sg +import sqlglot.expressions as sge if TYPE_CHECKING: from collections.abc import Iterable, Mapping @@ -728,7 +731,15 @@ def compile( ): """Compile an Ibis expression to a SQL string.""" query = self._to_sqlglot(expr, limit=limit, params=params, **kwargs) - sql = query.sql(dialect=self.name, pretty=True) + udf_sources = [] + for udf_node in expr.op().find(ops.ScalarUDF): + compile_func = getattr( + self, f"_compile_{udf_node.__input_type__.name.lower()}_udf" + ) + if sql := compile_func(udf_node): + udf_sources.append(sql.sql(self.name, pretty=True)) + + sql = ";\n".join([*udf_sources, query.sql(dialect=self.name, pretty=True)]) self._log(sql) return sql @@ -1175,6 +1186,68 @@ def _clean_up_cached_table(self, name): force=True, ) + def _get_udf_source(self, udf_node: ops.ScalarUDF): + name = type(udf_node).__name__ + type_mapper = self.compiler.udf_type_mapper + + body = PythonToJavaScriptTranslator(udf_node.__func__).compile() + config = udf_node.__config__ + libraries = config.get("libraries", []) + + signature = [ + sge.ColumnDef( + this=sg.to_identifier(name, quoted=self.compiler.quoted), + kind=type_mapper.from_ibis(param.annotation.pattern.dtype), + ) + for name, param in udf_node.__signature__.parameters.items() + ] + + lines = ['"""'] + + if config.get("strict", True): + lines.append('"use strict";') + + lines += [ + body, + "", + f"return {udf_node.__func_name__}({', '.join(udf_node.argnames)});", + '"""', + ] + + func = sge.Create( + kind="FUNCTION", + this=sge.UserDefinedFunction( + this=sg.to_identifier(name), expressions=signature, wrapped=True + ), + # not exactly what I had in mind, but it works + # + # quoting is too simplistic to handle multiline strings + expression=sge.Var(this="\n".join(lines)), + exists=False, + properties=sge.Properties( + expressions=[ + sge.TemporaryProperty(), + sge.ReturnsProperty(this=type_mapper.from_ibis(udf_node.dtype)), + sge.StabilityProperty( + this="IMMUTABLE" if config.get("determinism") else "VOLATILE" + ), + sge.LanguageProperty(this=sg.to_identifier("js")), + ] + + [ + sge.Property( + this=sg.to_identifier("library"), + value=self.compiler.f.array(*libraries), + ) + ] + * bool(libraries) + ), + ) + + return func + + def _compile_python_udf(self, udf_node: ops.ScalarUDF) -> None: + return self._get_udf_source(udf_node) + def _register_udfs(self, expr: ir.Expr) -> None: """No op because UDFs made with CREATE TEMPORARY FUNCTION must be followed by a query.""" diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/backend.py b/third_party/bigframes_vendored/ibis/backends/bigquery/backend.py index 312b284bbb8..3d214766dc6 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/backend.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/backend.py @@ -9,21 +9,9 @@ import contextlib import glob import os -from typing import TYPE_CHECKING, Any, Optional +from typing import Any, Optional, TYPE_CHECKING import bigframes_vendored.ibis -import bigframes_vendored.ibis.backends.sql.compilers as sc -import bigframes_vendored.ibis.common.exceptions as com -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.schema as sch -import bigframes_vendored.ibis.expr.types as ir -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import google.api_core.exceptions -import google.auth.credentials -import google.cloud.bigquery as bq -import google.cloud.bigquery_storage_v1 as bqstorage -import pydata_google_auth from bigframes_vendored.ibis import util from bigframes_vendored.ibis.backends import CanCreateDatabase, CanCreateSchema from bigframes_vendored.ibis.backends.bigquery.client import ( @@ -37,7 +25,19 @@ BigQueryType, ) from bigframes_vendored.ibis.backends.sql import SQLBackend +import bigframes_vendored.ibis.backends.sql.compilers as sc +import bigframes_vendored.ibis.common.exceptions as com +import bigframes_vendored.ibis.expr.operations as ops +import bigframes_vendored.ibis.expr.schema as sch +import bigframes_vendored.ibis.expr.types as ir +import google.api_core.exceptions +import google.auth.credentials +import google.cloud.bigquery as bq +import google.cloud.bigquery_storage_v1 as bqstorage +import pydata_google_auth from pydata_google_auth import cache +import sqlglot as sg +import sqlglot.expressions as sge if TYPE_CHECKING: from collections.abc import Iterable, Mapping diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/client.py b/third_party/bigframes_vendored/ibis/backends/bigquery/client.py index 0af2f924cfb..2ff8d5a5f56 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/client.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/client.py @@ -6,14 +6,14 @@ import functools -import bigframes_vendored.ibis.common.exceptions as com -import bigframes_vendored.ibis.expr.datatypes as dt -import google.cloud.bigquery as bq -import pandas as pd from bigframes_vendored.ibis.backends.bigquery.datatypes import ( BigQuerySchema, BigQueryType, ) +import bigframes_vendored.ibis.common.exceptions as com +import bigframes_vendored.ibis.expr.datatypes as dt +import google.cloud.bigquery as bq +import pandas as pd NATIVE_PARTITION_COL = "_PARTITIONTIME" diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/converter.py b/third_party/bigframes_vendored/ibis/backends/bigquery/converter.py index 2afccd454af..c2db774b2bb 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/converter.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/converter.py @@ -13,6 +13,10 @@ def convert_GeoSpatial(cls, s, dtype, pandas_type): return gpd.GeoSeries(shp.from_wkt(s)) - convert_Point = convert_LineString = convert_Polygon = convert_MultiLineString = ( - convert_MultiPoint - ) = convert_MultiPolygon = convert_GeoSpatial + convert_Point = ( + convert_LineString + ) = ( + convert_Polygon + ) = ( + convert_MultiLineString + ) = convert_MultiPoint = convert_MultiPolygon = convert_GeoSpatial diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py b/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py index aa9ac062a17..fba0339ae93 100644 --- a/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/datatypes.py @@ -5,9 +5,9 @@ import bigframes_vendored.ibis import bigframes_vendored.ibis.expr.datatypes as dt import bigframes_vendored.ibis.expr.schema as sch -import bigframes_vendored.sqlglot as sg -import google.cloud.bigquery as bq from bigframes_vendored.ibis.formats import SchemaMapper, TypeMapper +import google.cloud.bigquery as bq +import sqlglot as sg _from_bigquery_types = { "INT64": dt.Int64, diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/udf/__init__.py b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/udf/core.py b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/core.py new file mode 100644 index 00000000000..6f59a2becd7 --- /dev/null +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/core.py @@ -0,0 +1,604 @@ +# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/backends/bigquery/udf/core.py + +"""Translate a Python AST to JavaScript.""" + +from __future__ import annotations + +import ast +from collections import ChainMap +import contextlib +import functools +import inspect +import textwrap +from typing import TYPE_CHECKING + +from bigframes_vendored.ibis.backends.bigquery.udf.find import find_names +from bigframes_vendored.ibis.backends.bigquery.udf.rewrite import rewrite + +if TYPE_CHECKING: + from collections.abc import Callable + + +class SymbolTable(ChainMap): + """ChainMap subclass implementing scope for the translator. + + Notes + ----- + JavaScript requires declarations in strict mode, so to implement this we + shove a "let" at the beginning of every variable name if it doesn't already + exist in the current scope. + + """ + + def __getitem__(self, key): + if key not in self: + self[key] = key + return f"let {key}" + return key + + +def indent(lines, spaces=4): + """Indent `lines` by `spaces` spaces. + + Parameters + ---------- + lines : Union[str, List[str]] + A string or list of strings to indent + spaces : int + The number of spaces to indent `lines` + + Returns + ------- + indented_lines : str + + """ + if isinstance(lines, str): + text = [lines] + text = "\n".join(lines) + return textwrap.indent(text, " " * spaces) + + +def semicolon(f: Callable) -> Callable: + """Add a semicolon to the result of a `visit_*` call.""" + + @functools.wraps(f) + def wrapper(*args, **kwargs): + return f(*args, **kwargs) + ";" + + return wrapper + + +@rewrite.register(ast.Call(func=ast.Name(id="print"))) +def rewrite_print(node): + return ast.Call( + func=ast.Attribute( + value=ast.Name(id="console", ctx=ast.Load()), + attr="log", + ctx=ast.Load(), + ), + args=node.args, + keywords=node.keywords, + ) + + +@rewrite.register(ast.Call(func=ast.Name(id="len"))) +def rewrite_len(node): + assert len(node.args) == 1 + return ast.Attribute(value=node.args[0], attr="length", ctx=ast.Load()) + + +@rewrite.register(ast.Call(func=ast.Attribute(attr="append"))) +def rewrite_append(node): + return ast.Call( + func=ast.Attribute(value=node.func.value, attr="push", ctx=ast.Load()), + args=node.args, + keywords=node.keywords, + ) + + +@rewrite.register( + ast.Call(func=ast.Attribute(value=ast.Name(id="Array"), attr="from_")) +) +def rewrite_array_from(node): + return ast.Call( + func=ast.Attribute(value=node.func.value, attr="from"), + args=node.args, + keywords=node.keywords, + ) + + +class PythonToJavaScriptTranslator: + constructor_map = { + "list": "Array", + "Array": "Array", + "Date": "Date", + "dict": "Object", + "Map": "Map", + "WeakMap": "WeakMap", + "str": "String", + "String": "String", + "set": "Set", + "Set": "Set", + "WeakSet": "WeakSet", + } + + def __init__(self, function): + self.function = function + self.source = textwrap.dedent(inspect.getsource(function)) + self.ast = ast.parse(self.source) + self.scope = SymbolTable() + self.current_function = None + self.current_class = None + self.is_generator = False + self.is_nested_definition = False + + def compile(self): + return self.visit(self.ast) + + def visit(self, node): + node = rewrite(node) + typename = node.__class__.__name__ + method_name = f"visit_{typename}" + method = getattr(self, method_name, None) + if method is None: + raise NotImplementedError(f"{method_name!r} nodes not yet implemented") + assert callable(method) + + result = method(node) + return result + + def visit_Name(self, node): + if self.current_class is not None and node.id == "self": + return "this" + return node.id + + def visit_Yield(self, node): + self.is_generator = True + return f"yield {self.visit(node.value)}" + + def visit_YieldFrom(self, node): + self.is_generator = True + return f"yield* {self.visit(node.value)}" + + @semicolon + def visit_Assign(self, node): + try: + (target,) = node.targets + except ValueError: + raise NotImplementedError("Only single assignment supported for now") + + if not isinstance(target, (ast.Name, ast.Subscript, ast.Attribute)): + raise NotImplementedError( + "Only index, attribute, and variable name assignment " + f"supported, got {type(target).__name__}" + ) + + is_name = isinstance(target, ast.Name) + compiled_target = self.visit(target) + if not is_name or ( + self.current_class is not None and compiled_target.startswith("this.") + ): + self.scope[compiled_target] = compiled_target + return f"{self.scope[compiled_target]} = {self.visit(node.value)}" + + def translate_special_method(self, name): + return {"__init__": "constructor"}.get(name, name) + + def visit_FunctionDef(self, node): + self.current_function = node + + is_property_getter = any( + getattr(dec, "id", None) == "property" for dec in node.decorator_list + ) + + if self.current_class is None: # not a method + if is_property_getter: + raise TypeError("Functions cannot be properties, only methods can") + prefix = "function" + else: + if is_property_getter and self.is_generator: + raise TypeError("generator methods cannot be properties") + prefix = "get " * is_property_getter + + with self.local_scope(): + body = indent(map(self.visit, node.body)) + + if self.is_generator: + prefix += "* " + else: + prefix += " " * (self.current_class is None) + + lines = [ + prefix + + self.translate_special_method(node.name) + + f"({self.visit(node.args)}) {{", + body, + "}", + ] + + self.current_function = None + self.is_generator = False + return "\n".join(lines) + + @semicolon + def visit_Return(self, node): + return f"return {self.visit(node.value)}" + + def visit_Add(self, node): + return "+" + + def visit_Sub(self, node): + return "-" + + def visit_Mult(self, node): + return "*" + + def visit_Div(self, node): + return "/" + + def visit_FloorDiv(self, node): + raise AssertionError("should never reach FloorDiv") + + def visit_Pow(self, node): + raise AssertionError("should never reach Pow") + + def visit_UnaryOp(self, node): + return f"({self.visit(node.op)}{self.visit(node.operand)})" + + def visit_USub(self, node): + return "-" + + def visit_UAdd(self, node): + return "+" + + def visit_BinOp(self, node): + left, op, right = node.left, node.op, node.right + + if isinstance(op, ast.Pow): + return f"Math.pow({self.visit(left)}, {self.visit(right)})" + elif isinstance(op, ast.FloorDiv): + return f"Math.floor({self.visit(left)} / {self.visit(right)})" + return f"({self.visit(left)} {self.visit(op)} {self.visit(right)})" + + def visit_Constant(self, node): + value = node.value + if value is None: + return "null" + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, (int, float, str)): + return repr(value) + raise NotImplementedError( + f"{value.__class__.__name__!r} constants not yet implemented" + ) + + def visit_NameConstant(self, node): + value = node.value + if value is True: + return "true" + elif value is False: + return "false" + assert ( + value is None + ), f"value is not True and is not False, must be None, got {value}" + return "null" + + def visit_Str(self, node): + return repr(node.s) + + def visit_Num(self, node): + return repr(node.n) + + def visit_List(self, node): + return "[{}]".format(", ".join(map(self.visit, node.elts))) + + def visit_Tuple(self, node): + # tuples becomes lists in javascript + return "[{}]".format(", ".join(map(self.visit, node.elts))) + + def visit_Dict(self, node): + return "{{{}}}".format( + ", ".join( + f"[{self.visit(key)}]: {self.visit(value)}" + for key, value in zip(node.keys, node.values) + ) + ) + + @semicolon + def visit_Expr(self, node): + return self.visit(node.value) + + def visit_Starred(self, node): + return f"...{self.visit(node.value)}" + + def visit_Call(self, node): + thing_to_call = self.visit(node.func) + constructors = self.__class__.constructor_map + args = ", ".join(map(self.visit, node.args)) + try: + thing_to_call = constructors[thing_to_call] + except KeyError: + format_string = "{}({})" + else: + format_string = "(new {}({}))" + return format_string.format(thing_to_call, args) + + def visit_Attribute(self, node): + return f"{self.visit(node.value)}.{node.attr}" + + def visit_For(self, node): + lines = [f"for (let {self.visit(node.target)} of {self.visit(node.iter)}) {{"] + with self.local_scope(): + lines.append(indent(map(self.visit, node.body))) + lines.append("}") + return "\n".join(lines) + + def visit_While(self, node): + lines = [f"while ({self.visit(node.test)}) {{"] + with self.local_scope(): + lines.append(indent(map(self.visit, node.body))) + lines.append("}") + return "\n".join(lines) + + @semicolon + def visit_Break(self, node): + return "break" + + @semicolon + def visit_Continue(self, node): + return "continue" + + def visit_Eq(self, node): + return "===" + + def visit_NotEq(self, node): + return "!==" + + def visit_Or(self, node): + return "||" + + def visit_And(self, node): + return "&&" + + def visit_BoolOp(self, node): + return "({})".format( + f" {self.visit(node.op)} ".join(map(self.visit, node.values)) + ) + + def visit_Lt(self, node): + return "<" + + def visit_LtE(self, node): + return "<=" + + def visit_Gt(self, node): + return ">" + + def visit_GtE(self, node): + return ">=" + + def visit_Compare(self, node): + rights = node.comparators + ops = node.ops + + left = node.left + comparisons = [] + for op, right in zip(ops, rights): + comparisons.append( + f"({self.visit(left)} {self.visit(op)} {self.visit(right)})" + ) + left = right + return " && ".join(comparisons) + + @semicolon + def visit_AugAssign(self, node): + target = self.visit(node.target) + op = self.visit(node.op) + value = self.visit(node.value) + return f"{target} {op}= {value}" + + def visit_Module(self, node): + return "\n\n".join(map(self.visit, node.body)) + + def visit_arg(self, node): + if self.current_class is not None and node.arg == "self": + return "" + return node.arg + + def visit_arguments(self, node): + args = list(filter(None, map(self.visit, node.args[:]))) + vararg = node.vararg + if vararg is not None: + args.append(f"...{vararg.arg}") + return ", ".join(args) + + def visit_Lambda(self, node): + args = node.args + generated_args = self.visit(args) + return f"(({generated_args}) => {self.visit(node.body)})" + + @contextlib.contextmanager + def local_scope(self): + """Assign symbols to local variables.""" + self.scope = self.scope.new_child() + try: + yield self.scope + finally: + self.scope = self.scope.parents + + def visit_If(self, node): + lines = [f"if ({self.visit(node.test)}) {{"] + + with self.local_scope(): + lines.append(indent(map(self.visit, node.body))) + lines.append("}") + + if node.orelse: + lines[-1] += " else {" + with self.local_scope(): + lines.append(indent(map(self.visit, node.orelse))) + lines.append("}") + return "\n".join(lines) + + def visit_IfExp(self, node): + test = self.visit(node.test) + body = self.visit(node.body) + orelse = self.visit(node.orelse) + return f"({test} ? {body} : {orelse})" + + def visit_Index(self, node): + return self.visit(node.value) + + def visit_Subscript(self, node): + return f"{self.visit(node.value)}[{self.visit(node.slice)}]" + + def visit_ClassDef(self, node): + self.current_class = node + bases = node.bases + + lines = [f"class {node.name}"] + if bases: + lines[-1] += " extends {}".format(", ".join(map(self.visit, bases))) + lines[-1] += " {" + lines.append(indent(map(self.visit, node.body))) + lines.append("}") + self.current_class = None + self.__class__.constructor_map[node.name] = node.name + return "\n".join(lines) + + def visit_Not(self, node): + return "!" + + def visit_ListComp(self, node): + """Generate a curried lambda function. + + [x + y for x, y in [[1, 4], [2, 5], [3, 6]]] + + becomes + + [[1, 4], [2, 5], [3, 6]]].map(([x, y]) => x + y) + """ + try: + (generator,) = node.generators + except ValueError: + raise NotImplementedError("Only single loop comprehensions are allowed") + + names = find_names(generator.target) + argslist = [ast.arg(arg=name.id, annotation=None) for name in names] + if len(names) <= 1: + signature = ast.arguments( + args=argslist, + vararg=None, + kwonlyargs=[], + kw_defaults=[], + kwarg=None, + defaults=[], + ) + else: + signature = ast.List(elts=argslist, ctx=ast.Load()) + + array = generator.iter + lam_sig = functools.partial(ast.Lambda, args=signature) + + filters = generator.ifs + if filters: + filt = ast.BoolOp(op=ast.And(), values=filters) + # array.filter + method = ast.Attribute(value=array, attr="filter", ctx=ast.Load()) + # array.filter(func) + array = ast.Call(func=method, args=[lam_sig(body=filt)], keywords=[]) + + method = ast.Attribute(value=array, attr="map", ctx=ast.Load()) + mapped = ast.Call(func=method, args=[lam_sig(body=node.elt)], keywords=[]) + result = self.visit(mapped) + return result + + def visit_Delete(self, node): + return "\n".join(f"delete {self.visit(target)};" for target in node.targets) + + +if __name__ == "__main__": + import bigframes_vendored.ibis + from bigframes_vendored.ibis import udf + + @udf.scalar.python(strict=False) + def my_func(a: float, b: float, n: float) -> list[float]: + class Rectangle: + def __init__(self, width, height): + self.width = width + self.height = height + + @property + def area(self): + return self.width * self.height + + @property + def perimeter(self): + return self.width * 2 + self.height * 2 + + def foobar(self, n): + yield from range(n) + + def sum(values): + result = 0 + for value in values: + result += value + console.log(result) # noqa: F821 + return values.reduce(lambda a, b: a + b, 0) + + def range(n): + i = 0 + while i < n: + yield i + i += 1 + + some_stuff = [x + y for x, y in [[1, 4], [2, 5], [3, 6]] if 2 < x < 3] + some_stuff1 = [range(x) for x in [1, 2, 3]] + some_stuff2 = [x + y for x, y in [(1, 4), (2, 5), (3, 6)]] + print(some_stuff) # noqa: T201 + print(some_stuff1) # noqa: T201 + print(some_stuff2) # noqa: T201 + + x = 1 + y = 2 + x = 3 + values = [] + for i in range(10): + values.append(i) + + i = 0 + foo = 2 + bar = lambda x: x # noqa: E731 + bazel = lambda x: y # noqa: E731 + while i < n: + foo = bar(bazel(10)) + i += 1 + console.log(i) # noqa: F821 + + foo = 2 + + if i == 10 and (y < 2 or i != 42): + y += 2 + else: + y -= 2 + + z = 42.0 + w = 3 + w = not False + yyz = None + print(yyz) # noqa: T201 + foobar = x < y < z < w # x < y and y < z and z < w + foobar = 1 + baz = foobar // 3 + console.log(baz) # noqa: F821 + + my_obj = {"a": 1, "b": 2} # noqa: F841 + + z = (x if y else b) + 2 + foobar + foo = Rectangle(1, 2) + nnn = len(values) + return [sum(values) - a + b * y**-x, z, foo.width, nnn] + + print( + bigframes_vendored.ibis.bigquery.compile(my_func(42.7, 13.2, 1)) + ) # noqa: T201 diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/udf/find.py b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/find.py new file mode 100644 index 00000000000..b1f353ae4f1 --- /dev/null +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/find.py @@ -0,0 +1,64 @@ +# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/backends/bigquery/udf/find.py + +from __future__ import annotations + +import ast + +import toolz + + +class NameFinder: + """Helper class to find the unique names in an AST.""" + + __slots__ = () + + def find(self, node): + typename = type(node).__name__ + method = getattr(self, f"find_{typename}", None) + if method is None: + fields = getattr(node, "_fields", None) + if fields is None: + return + for field in fields: + value = getattr(node, field) + yield from self.find(value) + else: + yield from method(node) + + def find_Name(self, node): + # TODO not sure if this is robust to scope changes + yield node + + def find_list(self, node): + return list(toolz.concat(map(self.find, node))) + + def find_Call(self, node): + if not isinstance(node.func, ast.Name): + fields = node._fields + else: + fields = [field for field in node._fields if field != "func"] + return toolz.concat(map(self.find, (getattr(node, field) for field in fields))) + + +def find_names(node: ast.AST) -> list[ast.Name]: + """Return the unique `ast.Name` instances in an AST. + + Examples + -------- + >>> import ast + >>> node = ast.parse("a + b") + >>> names = find_names(node) + >>> names + [<....Name object at 0x...>, <....Name object at 0x...>] + >>> names[0].id + 'a' + >>> names[1].id + 'b' + + """ + return list( + toolz.unique( + filter(None, NameFinder().find(node)), + key=lambda node: (node.id, type(node.ctx)), + ) + ) diff --git a/third_party/bigframes_vendored/ibis/backends/bigquery/udf/rewrite.py b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/rewrite.py new file mode 100644 index 00000000000..6d2b0df7cdf --- /dev/null +++ b/third_party/bigframes_vendored/ibis/backends/bigquery/udf/rewrite.py @@ -0,0 +1,54 @@ +# Contains code from https://github.com/ibis-project/ibis/blob/9.2.0/ibis/backends/bigquery/udf/rewrite.py + +from __future__ import annotations + +import ast +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +def matches(value: ast.AST, pattern: ast.AST) -> bool: + """Check whether `value` matches `pattern`.""" + # types must match exactly + if type(value) is not type(pattern): + return False + + # primitive value, such as None, True, False etc + if not isinstance(value, ast.AST) and not isinstance(pattern, ast.AST): + return value == pattern + + fields = [ + (field, getattr(pattern, field)) + for field in pattern._fields + if hasattr(pattern, field) + ] + return all( + matches(getattr(value, field_name), field_value) + for field_name, field_value in fields + ) + + +class Rewriter: + """AST pattern matcher to enable rewrite rules.""" + + def __init__(self): + self.funcs: list[tuple[ast.AST, Callable[[ast.expr], ast.expr]]] = [] + + def register(self, pattern): + def wrapper(f): + self.funcs.append((pattern, f)) + return f + + return wrapper + + def __call__(self, node): + # TODO: more efficient way of doing this? + for pattern, func in self.funcs: + if matches(node, pattern): + return func(node) + return node + + +rewrite = Rewriter() diff --git a/third_party/bigframes_vendored/ibis/backends/sql/__init__.py b/third_party/bigframes_vendored/ibis/backends/sql/__init__.py index 9035bb0755a..8598e1af721 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/__init__.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/__init__.py @@ -4,26 +4,26 @@ import abc from functools import partial -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar, TYPE_CHECKING import bigframes_vendored.ibis +from bigframes_vendored.ibis import util +from bigframes_vendored.ibis.backends import BaseBackend +from bigframes_vendored.ibis.backends.sql.compilers.base import STAR import bigframes_vendored.ibis.common.exceptions as exc import bigframes_vendored.ibis.expr.operations as ops import bigframes_vendored.ibis.expr.schema as sch import bigframes_vendored.ibis.expr.types as ir -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -from bigframes_vendored.ibis import util -from bigframes_vendored.ibis.backends import BaseBackend -from bigframes_vendored.ibis.backends.sql.compilers.base import STAR +import sqlglot as sg +import sqlglot.expressions as sge if TYPE_CHECKING: from collections.abc import Iterable, Mapping - import pandas as pd - import pyarrow as pa from bigframes_vendored.ibis.backends.sql.compilers.base import SQLGlotCompiler from bigframes_vendored.ibis.expr.schema import SchemaLike + import pandas as pd + import pyarrow as pa class _DatabaseSchemaHandler: @@ -89,8 +89,8 @@ def has_operation(cls, operation: type[ops.Value]) -> bool: ) def _fetch_from_cursor(self, cursor, schema: sch.Schema) -> pd.DataFrame: - import pandas as pd from bigframes_vendored.ibis.formats.pandas import PandasData + import pandas as pd try: df = pd.DataFrame.from_records( 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 e6ab427be5e..cbc51e59d6b 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/compilers/base.py @@ -4,40 +4,40 @@ import abc import calendar +from functools import partial, reduce import itertools import math import operator import string -from functools import partial, reduce -from typing import TYPE_CHECKING, Any, ClassVar +from typing import Any, ClassVar, TYPE_CHECKING -import bigframes_vendored.ibis.common.exceptions as ibis_exceptions -import bigframes_vendored.ibis.common.patterns as pats -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge from bigframes_vendored.ibis.backends.sql.rewrites import ( - FirstValue, - LastValue, add_one_to_nth_value_input, add_order_by_to_empty_ranking_window_functions, empty_in_values_right_side, + FirstValue, + LastValue, lower_bucket, lower_capitalize, lower_sample, one_to_zero_index, sqlize, ) +import bigframes_vendored.ibis.common.exceptions as ibis_exceptions +import bigframes_vendored.ibis.common.patterns as pats from bigframes_vendored.ibis.config import options +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.operations.udf import InputType from bigframes_vendored.ibis.expr.rewrites import lower_stringslice from public import public +import sqlglot as sg +import sqlglot.expressions as sge try: - from bigframes_vendored.sqlglot.expressions import Alter + from sqlglot.expressions import Alter except ImportError: - from bigframes_vendored.sqlglot.expressions import AlterTable + from sqlglot.expressions import AlterTable else: def AlterTable(*args, kind="TABLE", **kwargs): @@ -47,9 +47,9 @@ def AlterTable(*args, kind="TABLE", **kwargs): if TYPE_CHECKING: from collections.abc import Callable, Iterable, Mapping + from bigframes_vendored.ibis.backends.bigquery.datatypes import SqlglotType import bigframes_vendored.ibis.expr.schema as sch import bigframes_vendored.ibis.expr.types as ir - from bigframes_vendored.ibis.backends.bigquery.datatypes import SqlglotType def get_leaf_classes(op): @@ -200,9 +200,9 @@ def array(self, *args: Any) -> sge.Array: first, *rest = args if isinstance(first, sge.Select): - assert not rest, ( - "only one argument allowed when `first` is a select statement" - ) + assert ( + not rest + ), "only one argument allowed when `first` is a select statement" return sge.Array(expressions=list(map(sge.convert, (first, *rest)))) @@ -811,7 +811,7 @@ def visit_DefaultLiteral(self, op, *, value, dtype): elif dtype.is_uuid(): return self.cast(str(value), dtype) elif dtype.is_json(): - return sge.JSON(this=sge.convert(str(value))) + return sge.ParseJSON(this=sge.convert(str(value))) elif dtype.is_geospatial(): wkt = value if isinstance(value, str) else value.wkt return self.f.st_geogfromtext(wkt) @@ -1084,9 +1084,9 @@ def visit_VarianceStandardDevCovariance(self, op, *, how, where, **kw): funcname = f"{funcs[type(op)]}_{hows[how]}" return self.agg[funcname](*args, where=where) - visit_Variance = visit_StandardDev = visit_Covariance = ( - visit_VarianceStandardDevCovariance - ) + visit_Variance = ( + visit_StandardDev + ) = visit_Covariance = visit_VarianceStandardDevCovariance def visit_SimpleCase(self, op, *, base=None, cases, results, default): return sge.Case( @@ -1394,17 +1394,9 @@ def _generate_groups(groups): return map(sge.convert, range(1, len(groups) + 1)) def visit_Aggregate(self, op, *, parent, groups, metrics): - exprs = [] - if groups: - exprs.extend(self._cleanup_names(groups)) - if metrics: - exprs.extend(self._cleanup_names(metrics)) - - if not exprs: - # Empty aggregated projections are invalid in BigQuery - exprs = [sge.Literal.number(1)] - - sel = sg.select(*exprs, copy=False).from_(parent, copy=False) + sel = sg.select( + *self._cleanup_names(groups), *self._cleanup_names(metrics), copy=False + ).from_(parent, copy=False) if groups: sel = sel.group_by(*self._generate_groups(groups.values()), copy=False) @@ -1545,9 +1537,11 @@ def visit_Add(self, op, *, left, right): def visit_Subtract(self, op, *, left, right): return sge.Sub(this=left, expression=right) - visit_DateSub = visit_DateDiff = visit_TimestampSub = visit_TimestampDiff = ( - visit_IntervalSubtract - ) = visit_Subtract + visit_DateSub = ( + visit_DateDiff + ) = ( + visit_TimestampSub + ) = visit_TimestampDiff = visit_IntervalSubtract = visit_Subtract @parenthesize_inputs def visit_Multiply(self, op, *, left, right): 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 e47164f6c46..61bafeeca2b 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 @@ -7,21 +7,15 @@ import decimal import math import re -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING -import bigframes_vendored.ibis.backends.bigquery.datatypes as bq_datatypes -import bigframes_vendored.ibis.common.exceptions as ibis_exceptions -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import numpy as np from bigframes_vendored.ibis import util +import bigframes_vendored.ibis.backends.bigquery.datatypes as bq_datatypes from bigframes_vendored.ibis.backends.sql.compilers.base import ( - NULL, - STAR, AggGen, + NULL, SQLGlotCompiler, + STAR, ) from bigframes_vendored.ibis.backends.sql.datatypes import BigQueryType, BigQueryUDFType from bigframes_vendored.ibis.backends.sql.rewrites import ( @@ -29,13 +23,19 @@ exclude_unsupported_window_frame_from_rank, exclude_unsupported_window_frame_from_row_number, ) +import bigframes_vendored.ibis.common.exceptions as ibis_exceptions from bigframes_vendored.ibis.common.temporal import ( DateUnit, IntervalUnit, TimestampUnit, TimeUnit, ) -from bigframes_vendored.sqlglot.dialects import BigQuery +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops +import numpy as np +import sqlglot as sg +from sqlglot.dialects import BigQuery +import sqlglot.expressions as sge if TYPE_CHECKING: from collections.abc import Mapping @@ -261,16 +261,6 @@ 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) @@ -405,7 +395,8 @@ def visit_StringToTimestamp(self, op, *, arg, format_str): def visit_ArrayCollect(self, op, *, arg, where, order_by, include_null): if where is not None and include_null: raise ibis_exceptions.UnsupportedOperationError( - "Combining `include_null=True` and `where` is not supported by bigquery" + "Combining `include_null=True` and `where` is not supported " + "by bigquery" ) out = self.agg.array_agg(arg, where=where, order_by=order_by) if not include_null: @@ -539,15 +530,6 @@ def visit_TimestampFromUNIX(self, op, *, arg, unit): def visit_Cast(self, op, *, arg, to): from_ = op.arg.dtype - if to.is_null(): - return sge.Null() - if arg is NULL or ( - isinstance(arg, sge.Cast) - and getattr(arg, "to", None) is not None - and str(arg.to).upper() == "NULL" - ): - if to.is_struct() or to.is_array(): - return sge.Cast(this=NULL, to=self.type_mapper.from_ibis(to)) if from_.is_timestamp() and to.is_integer(): return self.f.unix_micros(arg) elif from_.is_integer() and to.is_timestamp(): @@ -1106,63 +1088,6 @@ 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_AIEmbed(self, op, **kwargs): - return sge.func("AI.EMBED", *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 visit_AISimilarity(self, op, **kwargs): - return sge.func("AI.SIMILARITY", *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/backends/sql/datatypes.py b/third_party/bigframes_vendored/ibis/backends/sql/datatypes.py index 7a71ecf5efb..fce06437837 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/datatypes.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/datatypes.py @@ -7,9 +7,9 @@ import bigframes_vendored.ibis.common.exceptions as com import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge from bigframes_vendored.ibis.formats import TypeMapper +import sqlglot as sg +import sqlglot.expressions as sge typecode = sge.DataType.Type @@ -414,7 +414,11 @@ def _from_ibis_SpecificGeometry(cls, dtype: dt.GeoSpatial): this = getattr(typecode, dtype.geotype.upper()) return sge.DataType(this=this, expressions=expressions) - _from_ibis_Point = _from_ibis_LineString = _from_ibis_Polygon = ( + _from_ibis_Point = ( + _from_ibis_LineString + ) = ( + _from_ibis_Polygon + ) = ( _from_ibis_MultiLineString ) = _from_ibis_MultiPoint = _from_ibis_MultiPolygon = _from_ibis_SpecificGeometry @@ -461,9 +465,11 @@ def _from_sqlglot_GEOGRAPHY( def _from_sqlglot_TINYINT(cls) -> dt.Int64: return dt.Int64(nullable=cls.default_nullable) - _from_sqlglot_UINT = _from_sqlglot_USMALLINT = _from_sqlglot_UTINYINT = ( - _from_sqlglot_INT - ) = _from_sqlglot_SMALLINT = _from_sqlglot_TINYINT + _from_sqlglot_UINT = ( + _from_sqlglot_USMALLINT + ) = ( + _from_sqlglot_UTINYINT + ) = _from_sqlglot_INT = _from_sqlglot_SMALLINT = _from_sqlglot_TINYINT @classmethod def _from_sqlglot_UBIGINT(cls) -> NoReturn: diff --git a/third_party/bigframes_vendored/ibis/backends/sql/rewrites.py b/third_party/bigframes_vendored/ibis/backends/sql/rewrites.py index dbdce90517c..a252f116ddb 100644 --- a/third_party/bigframes_vendored/ibis/backends/sql/rewrites.py +++ b/third_party/bigframes_vendored/ibis/backends/sql/rewrites.py @@ -4,24 +4,24 @@ from __future__ import annotations -import operator from collections.abc import Mapping from functools import reduce -from typing import TYPE_CHECKING, Any +import operator +from typing import Any, TYPE_CHECKING -import bigframes_vendored.ibis.common.exceptions as ibis_exceptions -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import toolz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.collections import FrozenDict # noqa: TCH001 from bigframes_vendored.ibis.common.deferred import var +import bigframes_vendored.ibis.common.exceptions as ibis_exceptions from bigframes_vendored.ibis.common.graph import Graph from bigframes_vendored.ibis.common.patterns import InstanceOf, Object, Pattern, replace from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.rewrites import d, p, replace_parameter from bigframes_vendored.ibis.expr.schema import Schema from public import public +import toolz if TYPE_CHECKING: from collections.abc import Sequence diff --git a/third_party/bigframes_vendored/ibis/common/annotations.py b/third_party/bigframes_vendored/ibis/common/annotations.py index 9eb0de4ee24..9365c968707 100644 --- a/third_party/bigframes_vendored/ibis/common/annotations.py +++ b/third_party/bigframes_vendored/ibis/common/annotations.py @@ -5,8 +5,8 @@ import functools import inspect import types -from typing import TYPE_CHECKING from typing import Any as AnyType +from typing import TYPE_CHECKING from bigframes_vendored.ibis.common.bases import Immutable, Slotted from bigframes_vendored.ibis.common.patterns import ( @@ -15,9 +15,9 @@ NoMatch, Option, Pattern, - TupleOf, ) from bigframes_vendored.ibis.common.patterns import pattern as ensure_pattern +from bigframes_vendored.ibis.common.patterns import TupleOf from bigframes_vendored.ibis.common.typing import format_typehint, get_type_hints if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/common/bases.py b/third_party/bigframes_vendored/ibis/common/bases.py index 2d5d798c318..c9389dececf 100644 --- a/third_party/bigframes_vendored/ibis/common/bases.py +++ b/third_party/bigframes_vendored/ibis/common/bases.py @@ -2,9 +2,9 @@ from __future__ import annotations -import collections.abc from abc import abstractmethod -from typing import TYPE_CHECKING, Any +import collections.abc +from typing import Any, TYPE_CHECKING from weakref import WeakValueDictionary if TYPE_CHECKING: @@ -128,7 +128,8 @@ def __prohibit_inheritance__(cls, **kwargs): @collections.abc.Hashable.register class Hashable(Abstract): @abstractmethod - def __hash__(self) -> int: ... + def __hash__(self) -> int: + ... class Comparable(Abstract): @@ -146,7 +147,8 @@ class Comparable(Abstract): __cache__ = {} @abstractmethod - def __equals__(self, other) -> bool: ... + def __equals__(self, other) -> bool: + ... def __eq__(self, other) -> bool: if self is other: diff --git a/third_party/bigframes_vendored/ibis/common/caching.py b/third_party/bigframes_vendored/ibis/common/caching.py index b4257410e1e..66723cec61b 100644 --- a/third_party/bigframes_vendored/ibis/common/caching.py +++ b/third_party/bigframes_vendored/ibis/common/caching.py @@ -2,10 +2,10 @@ from __future__ import annotations +from collections import namedtuple import functools import sys -from collections import namedtuple -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING from weakref import finalize, ref if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/common/collections.py b/third_party/bigframes_vendored/ibis/common/collections.py index 718b94235dd..363e51c5341 100644 --- a/third_party/bigframes_vendored/ibis/common/collections.py +++ b/third_party/bigframes_vendored/ibis/common/collections.py @@ -2,10 +2,10 @@ from __future__ import annotations -import collections.abc from abc import abstractmethod +import collections.abc from itertools import tee -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TYPE_CHECKING, TypeVar from bigframes_vendored.ibis.common.bases import Abstract, Hashable from bigframes_vendored.ibis.common.exceptions import ConflictingValuesError @@ -29,7 +29,8 @@ class Iterable(Abstract, Generic[V]): """Iterable abstract base class for quicker isinstance checks.""" @abstractmethod - def __iter__(self): ... + def __iter__(self): + ... @collections.abc.Reversible.register @@ -37,7 +38,8 @@ class Reversible(Iterable[V]): """Reverse iterable abstract base class for quicker isinstance checks.""" @abstractmethod - def __reversed__(self): ... + def __reversed__(self): + ... @collections.abc.Iterator.register @@ -45,7 +47,8 @@ class Iterator(Iterable[V]): """Iterator abstract base class for quicker isinstance checks.""" @abstractmethod - def __next__(self): ... + def __next__(self): + ... def __iter__(self): return self @@ -56,7 +59,8 @@ class Sized(Abstract): """Sized abstract base class for quicker isinstance checks.""" @abstractmethod - def __len__(self): ... + def __len__(self): + ... @collections.abc.Container.register @@ -64,7 +68,8 @@ class Container(Abstract, Generic[V]): """Container abstract base class for quicker isinstance checks.""" @abstractmethod - def __contains__(self, x): ... + def __contains__(self, x): + ... @collections.abc.Collection.register @@ -77,7 +82,8 @@ class Sequence(Reversible[V], Collection[V]): """Sequence abstract base class for quicker isinstance checks.""" @abstractmethod - def __getitem__(self, index): ... + def __getitem__(self, index): + ... def __iter__(self): i = 0 @@ -121,7 +127,8 @@ class Mapping(Collection[K], Generic[K, V]): """Mapping abstract base class for quicker isinstance checks.""" @abstractmethod - def __getitem__(self, key): ... + def __getitem__(self, key): + ... def get(self, key, default=None): try: diff --git a/third_party/bigframes_vendored/ibis/common/deferred.py b/third_party/bigframes_vendored/ibis/common/deferred.py index 70e54be1503..1c6f06e223d 100644 --- a/third_party/bigframes_vendored/ibis/common/deferred.py +++ b/third_party/bigframes_vendored/ibis/common/deferred.py @@ -2,13 +2,13 @@ from __future__ import annotations +from abc import abstractmethod import collections.abc +from collections.abc import Callable import functools import inspect import operator -from abc import abstractmethod -from collections.abc import Callable -from typing import Any, TypeVar, overload +from typing import Any, overload, TypeVar from bigframes_vendored.ibis.common.bases import ( Final, @@ -51,7 +51,8 @@ def resolve(self, context: dict): """ @abstractmethod - def __eq__(self, other: Resolver) -> bool: ... + def __eq__(self, other: Resolver) -> bool: + ... @classmethod def __coerce__(cls, value): @@ -578,11 +579,13 @@ def _contains_deferred(obj: Any) -> bool: @overload -def deferrable(*, repr: str | None = None) -> Callable[[F], F]: ... +def deferrable(*, repr: str | None = None) -> Callable[[F], F]: + ... @overload -def deferrable(func: F) -> F: ... +def deferrable(func: F) -> F: + ... def deferrable(func=None, *, repr=None): diff --git a/third_party/bigframes_vendored/ibis/common/dispatch.py b/third_party/bigframes_vendored/ibis/common/dispatch.py index 9808d1fdb2b..d2920defad8 100644 --- a/third_party/bigframes_vendored/ibis/common/dispatch.py +++ b/third_party/bigframes_vendored/ibis/common/dispatch.py @@ -3,11 +3,11 @@ from __future__ import annotations import abc +from collections import defaultdict import functools import inspect import re import sys -from collections import defaultdict from typing import Union from bigframes_vendored.ibis.common.typing import ( diff --git a/third_party/bigframes_vendored/ibis/common/egraph.py b/third_party/bigframes_vendored/ibis/common/egraph.py index 437e85eda63..5497a980a44 100644 --- a/third_party/bigframes_vendored/ibis/common/egraph.py +++ b/third_party/bigframes_vendored/ibis/common/egraph.py @@ -3,9 +3,9 @@ from __future__ import annotations import collections +from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping import itertools import math -from collections.abc import Callable, Hashable, Iterable, Iterator, Mapping from typing import Any, TypeVar from bigframes_vendored.ibis.common.bases import FrozenSlotted as Slotted diff --git a/third_party/bigframes_vendored/ibis/common/exceptions.py b/third_party/bigframes_vendored/ibis/common/exceptions.py index 4c6392cfc6a..cd46ef6bd04 100644 --- a/third_party/bigframes_vendored/ibis/common/exceptions.py +++ b/third_party/bigframes_vendored/ibis/common/exceptions.py @@ -17,7 +17,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable diff --git a/third_party/bigframes_vendored/ibis/common/graph.py b/third_party/bigframes_vendored/ibis/common/graph.py index 9bf13e93ecf..6e7995ec030 100644 --- a/third_party/bigframes_vendored/ibis/common/graph.py +++ b/third_party/bigframes_vendored/ibis/common/graph.py @@ -4,11 +4,11 @@ from __future__ import annotations -import itertools from abc import abstractmethod from collections import deque from collections.abc import Callable, Iterable, Iterator, KeysView, Mapping, Sequence -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union +import itertools +from typing import Any, Optional, TYPE_CHECKING, TypeVar, Union from bigframes_vendored.ibis.common.bases import Hashable from bigframes_vendored.ibis.common.patterns import NoMatch, Pattern diff --git a/third_party/bigframes_vendored/ibis/common/grounds.py b/third_party/bigframes_vendored/ibis/common/grounds.py index 874e18c4057..fd639ce2e82 100644 --- a/third_party/bigframes_vendored/ibis/common/grounds.py +++ b/third_party/bigframes_vendored/ibis/common/grounds.py @@ -3,7 +3,7 @@ import contextlib from copy import copy -from typing import Any, ClassVar, Union, get_origin +from typing import Any, ClassVar, get_origin, Union from bigframes_vendored.ibis.common.annotations import ( Annotation, @@ -23,7 +23,7 @@ from bigframes_vendored.ibis.common.collections import FrozenDict # noqa: TCH001 from bigframes_vendored.ibis.common.patterns import Pattern from bigframes_vendored.ibis.common.typing import evaluate_annotations -from typing_extensions import Self, dataclass_transform +from typing_extensions import dataclass_transform, Self class AnnotableMeta(AbstractMeta): diff --git a/third_party/bigframes_vendored/ibis/common/patterns.py b/third_party/bigframes_vendored/ibis/common/patterns.py index 68861aa1908..5d4b5ba35d5 100644 --- a/third_party/bigframes_vendored/ibis/common/patterns.py +++ b/third_party/bigframes_vendored/ibis/common/patterns.py @@ -2,50 +2,50 @@ from __future__ import annotations -import math -import numbers from abc import abstractmethod from collections.abc import Callable, Mapping, Sequence from enum import Enum from inspect import Parameter +import math +import numbers +from typing import Annotated +from typing import Any as AnyType from typing import ( - Annotated, ForwardRef, Generic, + get_args, + get_origin, Literal, Optional, TypeVar, Union, - get_args, - get_origin, ) -from typing import Any as AnyType -import toolz from bigframes_vendored.ibis.common.bases import FrozenSlotted as Slotted from bigframes_vendored.ibis.common.bases import Hashable, Singleton from bigframes_vendored.ibis.common.collections import ( FrozenDict, - RewindableIterator, frozendict, + RewindableIterator, ) +from bigframes_vendored.ibis.common.deferred import _ # noqa: F401 from bigframes_vendored.ibis.common.deferred import ( Deferred, Factory, Resolver, - Variable, - _, # noqa: F401 resolver, + Variable, ) from bigframes_vendored.ibis.common.typing import ( Coercible, CoercionError, - Sentinel, format_typehint, get_bound_typevars, get_type_params, + Sentinel, ) from bigframes_vendored.ibis.util import import_object, is_iterable, unalias_package +import toolz from typing_extensions import GenericMeta T_co = TypeVar("T_co", covariant=True) @@ -225,7 +225,8 @@ def describe(self, plural=False): return f"matching {self!r}" @abstractmethod - def __eq__(self, other: Pattern) -> bool: ... + def __eq__(self, other: Pattern) -> bool: + ... def __invert__(self) -> Not: """Syntax sugar for matching the inverse of the pattern.""" @@ -1379,7 +1380,7 @@ def __init__(self, args, return_=_any): super().__init__(args=tuple(args), return_=return_) def match(self, value, context): - from bigframes_vendored.ibis.common.annotations import EMPTY, annotated + from bigframes_vendored.ibis.common.annotations import annotated, EMPTY if not callable(value): return NoMatch diff --git a/third_party/bigframes_vendored/ibis/common/temporal.py b/third_party/bigframes_vendored/ibis/common/temporal.py index 68042ad51a9..8d84caf5a1e 100644 --- a/third_party/bigframes_vendored/ibis/common/temporal.py +++ b/third_party/bigframes_vendored/ibis/common/temporal.py @@ -3,18 +3,18 @@ from __future__ import annotations import datetime -import numbers from decimal import Decimal from enum import Enum, EnumMeta +import numbers -import dateutil.parser -import dateutil.tz -import pytz from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.bases import AbstractMeta from bigframes_vendored.ibis.common.dispatch import lazy_singledispatch from bigframes_vendored.ibis.common.patterns import Coercible, CoercionError +import dateutil.parser +import dateutil.tz from public import public +import pytz class AbstractEnumMeta(EnumMeta, AbstractMeta): diff --git a/third_party/bigframes_vendored/ibis/common/typing.py b/third_party/bigframes_vendored/ibis/common/typing.py index c0c8ff3928c..a464054fdb5 100644 --- a/third_party/bigframes_vendored/ibis/common/typing.py +++ b/third_party/bigframes_vendored/ibis/common/typing.py @@ -2,13 +2,14 @@ from __future__ import annotations +from abc import abstractmethod import inspect +from itertools import zip_longest import re import sys -from abc import abstractmethod -from itertools import zip_longest -from typing import TYPE_CHECKING, Any, Optional, TypeVar, Union, get_args, get_origin +from typing import Any, get_args, get_origin from typing import get_type_hints as _get_type_hints +from typing import Optional, TYPE_CHECKING, TypeVar, Union from bigframes_vendored.ibis.common.bases import Abstract from bigframes_vendored.ibis.common.caching import memoize @@ -247,7 +248,8 @@ def __call__(self, *args: Any, **kwargs: Any) -> Any: raise TypeError("Sentinels are not constructible") -class CoercionError(Exception): ... +class CoercionError(Exception): + ... class Coercible(Abstract): @@ -260,7 +262,8 @@ class Coercible(Abstract): @classmethod @abstractmethod - def __coerce__(cls, value: Any, **kwargs: Any) -> Self: ... + def __coerce__(cls, value: Any, **kwargs: Any) -> Self: + ... def get_defining_frame(obj): diff --git a/third_party/bigframes_vendored/ibis/config.py b/third_party/bigframes_vendored/ibis/config.py index 8c2b0b1c718..39e5d94e0b3 100644 --- a/third_party/bigframes_vendored/ibis/config.py +++ b/third_party/bigframes_vendored/ibis/config.py @@ -2,8 +2,8 @@ from __future__ import annotations -import contextlib from collections.abc import Callable # noqa: TCH003 +import contextlib from typing import Annotated, Any, Optional import bigframes_vendored.ibis.common.exceptions as com diff --git a/third_party/bigframes_vendored/ibis/expr/api.py b/third_party/bigframes_vendored/ibis/expr/api.py index 953ecb2979f..4ef10e449bd 100644 --- a/third_party/bigframes_vendored/ibis/expr/api.py +++ b/third_party/bigframes_vendored/ibis/expr/api.py @@ -5,22 +5,17 @@ from __future__ import annotations import builtins +from collections import Counter import datetime import functools import itertools import numbers import operator -from collections import Counter -from typing import TYPE_CHECKING, Any, overload +from typing import Any, overload, TYPE_CHECKING -import bigframes_vendored.ibis.expr.builders as bl -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.schema as sch -import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis import selectors, util from bigframes_vendored.ibis.backends import BaseBackend, connect -from bigframes_vendored.ibis.common.deferred import Deferred, _, deferrable +from bigframes_vendored.ibis.common.deferred import _, deferrable, Deferred from bigframes_vendored.ibis.common.dispatch import lazy_singledispatch from bigframes_vendored.ibis.common.exceptions import IbisInputError from bigframes_vendored.ibis.common.grounds import Concrete @@ -28,34 +23,39 @@ normalize_datetime, normalize_timezone, ) +import bigframes_vendored.ibis.expr.builders as bl +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.decompile import decompile +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.schema import Schema +import bigframes_vendored.ibis.expr.schema as sch from bigframes_vendored.ibis.expr.sql import parse_sql, to_sql from bigframes_vendored.ibis.expr.types import ( + array, Column, DateValue, Expr, + literal, + map, + null, Scalar, + struct, Table, TimestampValue, TimeValue, Value, - array, - literal, - map, - null, - struct, ) +import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.util import experimental if TYPE_CHECKING: from collections.abc import Iterable, Sequence from pathlib import Path + from bigframes_vendored.ibis.expr.schema import SchemaLike import pandas as pd import polars as pl import pyarrow as pa - from bigframes_vendored.ibis.expr.schema import SchemaLike __all__ = ( "Column", @@ -474,8 +474,8 @@ def _memtable( schema: SchemaLike | None = None, name: str | None = None, ) -> Table: - import pandas as pd from bigframes_vendored.ibis.formats.pandas import PandasDataFrameProxy + import pandas as pd if not isinstance(data, pd.DataFrame): df = pd.DataFrame(data, columns=columns) @@ -777,11 +777,13 @@ def timestamp( second: int | ir.IntegerValue | Deferred, /, timezone: str | None = None, -) -> TimestampValue: ... +) -> TimestampValue: + ... @overload -def timestamp(value_or_year: Any, /, timezone: str | None = None) -> TimestampValue: ... +def timestamp(value_or_year: Any, /, timezone: str | None = None) -> TimestampValue: + ... @deferrable @@ -879,11 +881,13 @@ def date( month: int | ir.IntegerValue | Deferred, day: int | ir.IntegerValue | Deferred, /, -) -> DateValue: ... +) -> DateValue: + ... @overload -def date(value_or_year: Any, /) -> DateValue: ... +def date(value_or_year: Any, /) -> DateValue: + ... @deferrable @@ -952,11 +956,13 @@ def time( minute: int | ir.IntegerValue | Deferred, second: int | ir.IntegerValue | Deferred, /, -) -> TimeValue: ... +) -> TimeValue: + ... @overload -def time(value_or_hour: Any, /) -> TimeValue: ... +def time(value_or_hour: Any, /) -> TimeValue: + ... @deferrable @@ -1526,6 +1532,7 @@ 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 @@ -1575,6 +1582,7 @@ 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 @@ -2467,7 +2475,3 @@ def least(*args: Any) -> ir.Value: └────────────┘ """ return ops.Least(args).to_expr() - - -def omitted() -> ir.Value: - return ops.Omitted().to_expr() diff --git a/third_party/bigframes_vendored/ibis/expr/builders.py b/third_party/bigframes_vendored/ibis/expr/builders.py index c7b6e538ff5..a6530629c9b 100644 --- a/third_party/bigframes_vendored/ibis/expr/builders.py +++ b/third_party/bigframes_vendored/ibis/expr/builders.py @@ -3,20 +3,20 @@ from __future__ import annotations import math -from typing import TYPE_CHECKING, Any, Literal, Optional, Union +from typing import Any, Literal, Optional, TYPE_CHECKING, Union import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.rules as rlz -import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.annotations import annotated, attribute -from bigframes_vendored.ibis.common.deferred import Deferred, Resolver, deferrable +from bigframes_vendored.ibis.common.deferred import deferrable, Deferred, Resolver from bigframes_vendored.ibis.common.exceptions import IbisInputError from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.selectors import Selector # noqa: TCH001 from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops +import bigframes_vendored.ibis.expr.rules as rlz +import bigframes_vendored.ibis.expr.types as ir if TYPE_CHECKING: from typing_extensions import Self diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/cast.py b/third_party/bigframes_vendored/ibis/expr/datatypes/cast.py index e7e18419441..af8ddcf1b5f 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/cast.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/cast.py @@ -3,10 +3,10 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING -import bigframes_vendored.ibis.expr.datatypes.core as dt from bigframes_vendored.ibis.common.exceptions import IbisTypeError +import bigframes_vendored.ibis.expr.datatypes.core as dt from public import public if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py index 75bff716626..eb597cfc6a5 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/core.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/core.py @@ -2,26 +2,25 @@ from __future__ import annotations +from abc import abstractmethod +from collections.abc import Iterable, Iterator, Mapping, Sequence import datetime as pydatetime import decimal as pydecimal import numbers -import uuid as pyuuid -from abc import abstractmethod -from collections.abc import Iterable, Iterator, Mapping, Sequence from numbers import Integral, Real from typing import ( Any, Generic, + get_args, + get_origin, + get_type_hints, Literal, NamedTuple, Optional, TypeVar, - get_args, - get_origin, - get_type_hints, ) +import uuid as pyuuid -import toolz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.collections import FrozenOrderedDict, MapSet from bigframes_vendored.ibis.common.dispatch import lazy_singledispatch @@ -29,6 +28,7 @@ from bigframes_vendored.ibis.common.patterns import Coercible, CoercionError from bigframes_vendored.ibis.common.temporal import IntervalUnit, TimestampUnit from public import public +import toolz from typing_extensions import Self @@ -62,6 +62,7 @@ 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) @@ -112,11 +113,13 @@ class DataType(Concrete, Coercible): @property @abstractmethod - def scalar(self): ... + def scalar(self): + ... @property @abstractmethod - def column(self): ... + def column(self): + ... # TODO(kszucs): remove it, prefer to use Annotable.__repr__ instead @property @@ -772,7 +775,8 @@ def __init__( if precision is not None: if not isinstance(precision, numbers.Integral): raise TypeError( - f"Decimal type precision must be an integer; got {type(precision)}" + "Decimal type precision must be an integer; " + f"got {type(precision)}" ) if precision < 0: raise ValueError("Decimal type precision cannot be negative") diff --git a/third_party/bigframes_vendored/ibis/expr/datatypes/value.py b/third_party/bigframes_vendored/ibis/expr/datatypes/value.py index 5856cb8cf94..85be0ac7497 100644 --- a/third_party/bigframes_vendored/ibis/expr/datatypes/value.py +++ b/third_party/bigframes_vendored/ibis/expr/datatypes/value.py @@ -3,20 +3,17 @@ from __future__ import annotations import collections +from collections.abc import Mapping, Sequence import datetime import decimal import enum +from functools import partial import ipaddress import json -import uuid -from collections.abc import Mapping, Sequence -from functools import partial from operator import attrgetter from typing import Any +import uuid -import bigframes_vendored.ibis.expr.datatypes as dt -import pyarrow as pa -import toolz from bigframes_vendored.ibis.common.collections import frozendict from bigframes_vendored.ibis.common.dispatch import lazy_singledispatch from bigframes_vendored.ibis.common.exceptions import IbisTypeError, InputTypeError @@ -27,8 +24,11 @@ normalize_timedelta, normalize_timezone, ) +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 @lazy_singledispatch diff --git a/third_party/bigframes_vendored/ibis/expr/decompile.py b/third_party/bigframes_vendored/ibis/expr/decompile.py index 62913c9fa90..e32a7298f93 100644 --- a/third_party/bigframes_vendored/ibis/expr/decompile.py +++ b/third_party/bigframes_vendored/ibis/expr/decompile.py @@ -7,12 +7,12 @@ import io import itertools +from bigframes_vendored.ibis.common.graph import Graph import bigframes_vendored.ibis.expr.datatypes as dt import bigframes_vendored.ibis.expr.operations as ops +from bigframes_vendored.ibis.expr.rewrites import simplify import bigframes_vendored.ibis.expr.types as ibis_types import bigframes_vendored.ibis.expr.types as ir -from bigframes_vendored.ibis.common.graph import Graph -from bigframes_vendored.ibis.expr.rewrites import simplify from bigframes_vendored.ibis.util import experimental _method_overrides = { diff --git a/third_party/bigframes_vendored/ibis/expr/format.py b/third_party/bigframes_vendored/ibis/expr/format.py index 27eac21ddeb..39530823bc6 100644 --- a/third_party/bigframes_vendored/ibis/expr/format.py +++ b/third_party/bigframes_vendored/ibis/expr/format.py @@ -2,18 +2,18 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence import functools import itertools import textwrap import types -from collections.abc import Mapping, Sequence from typing import Optional import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.graph import Node +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from public import public _infix_ops = { diff --git a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py b/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py deleted file mode 100644 index 9fa043d0bab..00000000000 --- a/third_party/bigframes_vendored/ibis/expr/operations/ai_ops.py +++ /dev/null @@ -1,205 +0,0 @@ -# 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 - -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz -import pyarrow as pa -from bigframes_vendored.ibis.common.annotations import attribute -from bigframes_vendored.ibis.expr.operations.core import Value -from public import public - -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_response", 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_response", 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_response", 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_response", dt.string), - ("status", dt.string), - ) - ) - - -@public -class AIEmbed(Value): - """Create embeddings from text or image data.""" - - content: Value - connection_id: Optional[Value[dt.String]] - endpoint: Optional[Value[dt.String]] - model: Optional[Value[dt.String]] - task_type: Optional[Value[dt.String]] - title: Optional[Value[dt.String]] - model_params: Optional[Value[dt.String]] - - shape = rlz.shape_like("content") - - @attribute - def dtype(self) -> dt.Struct: - return dt.Struct.from_tuples( - ( - ("result", dt.Array(dt.float64)), - ("status", dt.string), - ) - ) - - -@public -class AIIf(Value): - """Generate True/False based on the prompt""" - - prompt: Value - connection_id: Optional[Value[dt.String]] - endpoint: Optional[Value[dt.String]] - optimization_mode: Optional[Value[dt.String]] - max_error_ratio: Optional[Value[dt.Float64]] - - shape = rlz.shape_like("prompt") - - @attribute - def dtype(self) -> dt.Struct: - return dt.bool - - -@public -class AIClassify(Value): - """Generate categories based on the prompt""" - - input: Value - categories: Value[dt.Array[dt.String]] - examples: Optional[Value] - connection_id: Optional[Value[dt.String]] - endpoint: Optional[Value[dt.String]] - output_mode: Optional[Value[dt.String]] - optimization_mode: Optional[Value[dt.String]] - max_error_ratio: Optional[Value[dt.Float64]] - - shape = rlz.shape_like("input") - - @attribute - def dtype(self) -> dt.DataType: - if self.output_mode is not None: - return dt.Array(dt.string) - return dt.string - - -@public -class AIScore(Value): - """Generate scores based on the prompt""" - - prompt: Value - connection_id: Optional[Value[dt.String]] - endpoint: Optional[Value[dt.String]] - max_error_ratio: Optional[Value[dt.Float64]] - - shape = rlz.shape_like("prompt") - - @attribute - def dtype(self) -> dt.DataType: - return dt.float64 - - -@public -class AISimilarity(Value): - """Calculate the similarity between two contents""" - - content1: Value - content2: Value - endpoint: Optional[Value[dt.String]] - model: Optional[Value[dt.String]] - model_params: Optional[Value[dt.String]] - connection_id: Optional[Value[dt.String]] - - shape = rlz.shape_like("content1") - - @attribute - def dtype(self) -> dt.Struct: - return dt.float64 diff --git a/third_party/bigframes_vendored/ibis/expr/operations/analytic.py b/third_party/bigframes_vendored/ibis/expr/operations/analytic.py index 584fba23f66..c394fb1fc9d 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/analytic.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/analytic.py @@ -8,9 +8,9 @@ import bigframes_vendored.ibis.expr.datashape as ds import bigframes_vendored.ibis.expr.datatypes as dt +from bigframes_vendored.ibis.expr.operations.core import Column, Scalar, Value import bigframes_vendored.ibis.expr.operations.udf as ibis_udf import bigframes_vendored.ibis.expr.rules as rlz -from bigframes_vendored.ibis.expr.operations.core import Column, Scalar, Value from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py index 7e10a3e26f9..8134506255f 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/arrays.py @@ -6,12 +6,12 @@ from typing import Optional -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/core.py b/third_party/bigframes_vendored/ibis/expr/operations/core.py index ad0bd095b6c..1b8fb684fe0 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/core.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/core.py @@ -5,14 +5,14 @@ from abc import abstractmethod from typing import Generic, Optional -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.graph import Node as Traversable from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import Coercible, CoercionError from bigframes_vendored.ibis.common.typing import DefaultTypeVars +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.util import is_iterable from public import public from typing_extensions import Any, Self, TypeVar @@ -48,7 +48,7 @@ def __coerce__( ) -> Self: # note that S=Shape is unused here since the pattern will check the # shape of the value expression after executing Value.__coerce__() - from bigframes_vendored.ibis.expr.operations.generic import NULL, Literal + from bigframes_vendored.ibis.expr.operations.generic import Literal, NULL from bigframes_vendored.ibis.expr.types import Expr if isinstance(value, Expr): @@ -136,10 +136,6 @@ def to_expr(self): return getattr(ir, typename)(self) - @property - def omitted(self) -> bool: - return False - # convenience aliases Scalar = Value[T, ds.Scalar] diff --git a/third_party/bigframes_vendored/ibis/expr/operations/generic.py b/third_party/bigframes_vendored/ibis/expr/operations/generic.py index cc0caf21b2e..c77ecc3e718 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/generic.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/generic.py @@ -4,19 +4,20 @@ from __future__ import annotations import itertools -from typing import Annotated, Any, Optional +from typing import Annotated, Any from typing import Literal as LiteralType +from typing import Optional -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.deferred import Deferred # noqa: TCH001 from bigframes_vendored.ibis.common.grounds import Singleton from bigframes_vendored.ibis.common.patterns import InstanceOf, Length # noqa: TCH001 from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Scalar, Unary, Value from bigframes_vendored.ibis.expr.operations.relations import Relation # noqa: TCH001 +import bigframes_vendored.ibis.expr.rules as rlz from public import public from typing_extensions import TypeVar @@ -188,11 +189,6 @@ class Impure(Value): pass -@public -class OmittedArg(Value): - pass - - @public class TimestampNow(Constant): """Return the current timestamp.""" diff --git a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py index efe038599a0..0be832af78f 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/geospatial.py @@ -343,28 +343,6 @@ 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/histograms.py b/third_party/bigframes_vendored/ibis/expr/operations/histograms.py index e7487887761..b3b6ad48bba 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/histograms.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/histograms.py @@ -7,10 +7,10 @@ import numbers # noqa: TCH003 from typing import Literal +from bigframes_vendored.ibis.common.annotations import attribute, ValidationError +from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 import bigframes_vendored.ibis.expr.datashape as ds import bigframes_vendored.ibis.expr.datatypes as dt -from bigframes_vendored.ibis.common.annotations import ValidationError, attribute -from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 from bigframes_vendored.ibis.expr.operations.core import Column, Value from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/json.py b/third_party/bigframes_vendored/ibis/expr/operations/json.py index 6b03cb36672..ea4845dae25 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/json.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/json.py @@ -3,10 +3,10 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations import Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/logical.py b/third_party/bigframes_vendored/ibis/expr/operations/logical.py index 74ac495642a..737db560faf 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/logical.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/logical.py @@ -3,12 +3,12 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz -from bigframes_vendored.ibis.common.annotations import ValidationError, attribute +from bigframes_vendored.ibis.common.annotations import attribute, ValidationError from bigframes_vendored.ibis.common.exceptions import IbisTypeError from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Binary, Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/maps.py b/third_party/bigframes_vendored/ibis/expr/operations/maps.py index 1111e1e6898..10b44e3f38e 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/maps.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/maps.py @@ -4,10 +4,10 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/numeric.py b/third_party/bigframes_vendored/ibis/expr/operations/numeric.py index f4ba57e9d70..384323c5965 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/numeric.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/numeric.py @@ -7,11 +7,11 @@ import operator from typing import Optional -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.annotations import attribute +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Binary, Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public Integer = Value[dt.Integer] @@ -158,6 +158,8 @@ class Round(Value): def dtype(self): if self.arg.dtype.is_decimal(): return self.arg.dtype + elif self.digits is None: + return dt.int64 else: return dt.double diff --git a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py index b739c7048fd..34f6406e0ca 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/reductions.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/reductions.py @@ -6,13 +6,13 @@ from typing import Literal, Optional -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.typing import VarTuple +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Column, Value from bigframes_vendored.ibis.expr.operations.relations import Relation # noqa: TCH001 +import bigframes_vendored.ibis.expr.rules as rlz from public import public @@ -401,20 +401,3 @@ 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/relations.py b/third_party/bigframes_vendored/ibis/expr/operations/relations.py index c230cbe20b5..ef45fdfc0de 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/relations.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/relations.py @@ -4,13 +4,11 @@ from __future__ import annotations +from abc import abstractmethod import itertools import typing -from abc import abstractmethod from typing import Annotated, Any, Literal, Optional, TypeVar -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.collections import FrozenDict, FrozenOrderedDict from bigframes_vendored.ibis.common.exceptions import ( @@ -21,6 +19,8 @@ from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import Between, InstanceOf from bigframes_vendored.ibis.common.typing import Coercible, VarTuple +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import ( Alias, Column, diff --git a/third_party/bigframes_vendored/ibis/expr/operations/sortkeys.py b/third_party/bigframes_vendored/ibis/expr/operations/sortkeys.py index f1c5b9820ac..728deba035a 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/sortkeys.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/sortkeys.py @@ -4,8 +4,8 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.expr.operations.core import Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public # TODO(kszucs): move the content of this file to generic.py diff --git a/third_party/bigframes_vendored/ibis/expr/operations/strings.py b/third_party/bigframes_vendored/ibis/expr/operations/strings.py index c2dc151ae07..ffd93e6fdd1 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/strings.py @@ -6,11 +6,11 @@ from typing import Optional -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Unary, Value +import bigframes_vendored.ibis.expr.rules as rlz from public import public @@ -361,18 +361,17 @@ class ExtractFragment(ExtractURLField): @public -class StringLength(Unary): - """Compute the length of a string or binary value.""" +class StringLength(StringUnary): + """Compute the length of a string.""" - arg: Value[dt.String | dt.Binary] - dtype = dt.int64 + dtype = dt.int32 @public class StringAscii(StringUnary): """Compute the ASCII code of the first character of a string.""" - dtype = dt.int64 + dtype = dt.int32 @public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/structs.py b/third_party/bigframes_vendored/ibis/expr/operations/structs.py index aa26841d9ab..6f083bed0db 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/structs.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/structs.py @@ -4,11 +4,11 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz -from bigframes_vendored.ibis.common.annotations import ValidationError, attribute +from bigframes_vendored.ibis.common.annotations import attribute, ValidationError from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +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 diff --git a/third_party/bigframes_vendored/ibis/expr/operations/subqueries.py b/third_party/bigframes_vendored/ibis/expr/operations/subqueries.py index c0b95a5d36c..0fbb2278969 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/subqueries.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/subqueries.py @@ -3,13 +3,13 @@ from __future__ import annotations -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.exceptions import IntegrityError +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Value from bigframes_vendored.ibis.expr.operations.relations import Relation # noqa: TCH001 +import bigframes_vendored.ibis.expr.rules as rlz from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py index 729b7f14b91..75a0ef9efc3 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/temporal.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/temporal.py @@ -7,8 +7,6 @@ import operator from typing import Annotated, Optional -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.patterns import As, Attrs from bigframes_vendored.ibis.common.temporal import ( @@ -17,8 +15,10 @@ TimestampUnit, TimeUnit, ) +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.core import Binary, Scalar, Unary, Value from bigframes_vendored.ibis.expr.operations.logical import Between +import bigframes_vendored.ibis.expr.rules as rlz from public import public @@ -105,7 +105,7 @@ class ExtractTemporalField(Unary): """Extract a field from a temporal value.""" arg: Value[dt.Temporal] - dtype = dt.int64 + dtype = dt.int32 @public diff --git a/third_party/bigframes_vendored/ibis/expr/operations/udf.py b/third_party/bigframes_vendored/ibis/expr/operations/udf.py index e3e528ee90d..91366cace80 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/udf.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/udf.py @@ -11,8 +11,12 @@ import inspect import itertools import typing -from typing import TYPE_CHECKING, Any, Optional, TypeVar, overload +from typing import Any, Optional, overload, TYPE_CHECKING, TypeVar +from bigframes_vendored.ibis import util +from bigframes_vendored.ibis.common.annotations import Argument, attribute +from bigframes_vendored.ibis.common.collections import FrozenDict +from bigframes_vendored.ibis.common.deferred import deferrable import bigframes_vendored.ibis.common.exceptions as exc import bigframes_vendored.ibis.expr.datashape as ds import bigframes_vendored.ibis.expr.datatypes as dt @@ -20,10 +24,6 @@ import bigframes_vendored.ibis.expr.operations.reductions as reductions import bigframes_vendored.ibis.expr.operations.relations as relations import bigframes_vendored.ibis.expr.rules as rlz -from bigframes_vendored.ibis import util -from bigframes_vendored.ibis.common.annotations import Argument, attribute -from bigframes_vendored.ibis.common.collections import FrozenDict -from bigframes_vendored.ibis.common.deferred import deferrable from public import public if TYPE_CHECKING: @@ -35,9 +35,9 @@ EMPTY = inspect.Parameter.empty -_udf_name_cache: MutableMapping[type[core.Node], Iterable[int]] = ( - collections.defaultdict(itertools.count) -) +_udf_name_cache: MutableMapping[ + type[core.Node], Iterable[int] +] = collections.defaultdict(itertools.count) def _make_udf_name(name: str) -> str: @@ -186,7 +186,8 @@ class scalar(_UDF): @overload @classmethod - def builtin(cls, fn: Callable) -> Callable[..., ir.Value]: ... + def builtin(cls, fn: Callable) -> Callable[..., ir.Value]: + ... @overload @classmethod @@ -198,7 +199,8 @@ def builtin( catalog: str | None = None, signature: tuple[tuple[Any, ...], Any] | None = None, **kwargs: Any, - ) -> Callable[[Callable], Callable[..., ir.Value]]: ... + ) -> Callable[[Callable], Callable[..., ir.Value]]: + ... @util.experimental @classmethod @@ -261,7 +263,8 @@ def builtin( @overload @classmethod - def python(cls, fn: Callable) -> Callable[..., ir.Value]: ... + def python(cls, fn: Callable) -> Callable[..., ir.Value]: + ... @overload @classmethod @@ -273,7 +276,8 @@ def python( catalog: str | None = None, signature: tuple[tuple[Any, ...], Any] | None = None, **kwargs: Any, - ) -> Callable[[Callable], Callable[..., ir.Value]]: ... + ) -> Callable[[Callable], Callable[..., ir.Value]]: + ... @util.experimental @classmethod @@ -381,7 +385,8 @@ def python( @overload @classmethod - def pandas(cls, fn: Callable) -> Callable[..., ir.Value]: ... + def pandas(cls, fn: Callable) -> Callable[..., ir.Value]: + ... @overload @classmethod @@ -393,7 +398,8 @@ def pandas( catalog: str | None = None, signature: tuple[tuple[Any, ...], Any] | None = None, **kwargs: Any, - ) -> Callable[[Callable], Callable[..., ir.Value]]: ... + ) -> Callable[[Callable], Callable[..., ir.Value]]: + ... @util.experimental @classmethod @@ -478,7 +484,8 @@ def pandas( @overload @classmethod - def pyarrow(cls, fn: Callable) -> Callable[..., ir.Value]: ... + def pyarrow(cls, fn: Callable) -> Callable[..., ir.Value]: + ... @overload @classmethod @@ -490,7 +497,8 @@ def pyarrow( catalog: str | None = None, signature: tuple[tuple[Any, ...], Any] | None = None, **kwargs: Any, - ) -> Callable[[Callable], Callable[..., ir.Value]]: ... + ) -> Callable[[Callable], Callable[..., ir.Value]]: + ... @util.experimental @classmethod @@ -578,7 +586,8 @@ class agg(_UDF): @overload @classmethod - def builtin(cls, fn: Callable) -> Callable[..., ir.Value]: ... + def builtin(cls, fn: Callable) -> Callable[..., ir.Value]: + ... @overload @classmethod @@ -590,7 +599,8 @@ def builtin( catalog: str | None = None, signature: tuple[tuple[Any, ...], Any] | None = None, **kwargs: Any, - ) -> Callable[[Callable], Callable[..., ir.Value]]: ... + ) -> Callable[[Callable], Callable[..., ir.Value]]: + ... @util.experimental @classmethod diff --git a/third_party/bigframes_vendored/ibis/expr/operations/window.py b/third_party/bigframes_vendored/ibis/expr/operations/window.py index c40c9db2f0b..0fcecb41093 100644 --- a/third_party/bigframes_vendored/ibis/expr/operations/window.py +++ b/third_party/bigframes_vendored/ibis/expr/operations/window.py @@ -8,17 +8,17 @@ from typing import Optional import bigframes_vendored.ibis.common.exceptions as com -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.rules as rlz from bigframes_vendored.ibis.common.patterns import CoercionError from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.operations.analytic import Analytic # noqa: TCH001 from bigframes_vendored.ibis.expr.operations.core import Column, Value from bigframes_vendored.ibis.expr.operations.generic import Literal from bigframes_vendored.ibis.expr.operations.numeric import Negate from bigframes_vendored.ibis.expr.operations.reductions import Reduction # noqa: TCH001 from bigframes_vendored.ibis.expr.operations.sortkeys import SortKey # noqa: TCH001 +import bigframes_vendored.ibis.expr.rules as rlz from public import public from typing_extensions import TypeVar diff --git a/third_party/bigframes_vendored/ibis/expr/rewrites.py b/third_party/bigframes_vendored/ibis/expr/rewrites.py index 3ec5ea12714..b0569846da3 100644 --- a/third_party/bigframes_vendored/ibis/expr/rewrites.py +++ b/third_party/bigframes_vendored/ibis/expr/rewrites.py @@ -6,17 +6,17 @@ from collections import defaultdict -import bigframes_vendored.ibis.expr.operations as ops -import toolz from bigframes_vendored.ibis.common.collections import FrozenDict # noqa: TCH001 -from bigframes_vendored.ibis.common.deferred import Item, _, deferred, var +from bigframes_vendored.ibis.common.deferred import _, deferred, Item, var from bigframes_vendored.ibis.common.exceptions import ExpressionError, IbisInputError from bigframes_vendored.ibis.common.graph import Node as Traversable from bigframes_vendored.ibis.common.graph import traverse from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import Check, pattern, replace from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.util import Namespace, promote_list +import toolz p = Namespace(pattern, module=ops) d = Namespace(deferred, module=ops) @@ -206,26 +206,21 @@ def replace_parameter(_, params, **kwargs): @replace(p.StringSlice) def lower_stringslice(_, **kwargs): """Rewrite StringSlice in terms of Substring.""" - if _.start is None: - 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) + 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 else: - 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) + length = ops.Subtract(_.end, _.start) + return ops.Substring(_.arg, start=_.start, length=length) @replace(p.Analytic) diff --git a/third_party/bigframes_vendored/ibis/expr/rules.py b/third_party/bigframes_vendored/ibis/expr/rules.py index 95050a6a5bc..a1980291825 100644 --- a/third_party/bigframes_vendored/ibis/expr/rules.py +++ b/third_party/bigframes_vendored/ibis/expr/rules.py @@ -5,13 +5,13 @@ from itertools import product, starmap from typing import Optional -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import CoercionError, NoMatch, Pattern from bigframes_vendored.ibis.common.temporal import IntervalUnit +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/schema.py b/third_party/bigframes_vendored/ibis/expr/schema.py index edc704c3664..acfa3824fd5 100644 --- a/third_party/bigframes_vendored/ibis/expr/schema.py +++ b/third_party/bigframes_vendored/ibis/expr/schema.py @@ -3,15 +3,15 @@ from __future__ import annotations from collections.abc import Iterable, Iterator, Mapping -from typing import TYPE_CHECKING, Any, Union +from typing import Any, TYPE_CHECKING, Union -import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.common.annotations import attribute from bigframes_vendored.ibis.common.collections import FrozenOrderedDict, MapSet from bigframes_vendored.ibis.common.dispatch import lazy_singledispatch from bigframes_vendored.ibis.common.exceptions import InputTypeError, IntegrityError from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import Coercible +import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.util import indent if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/expr/sql.py b/third_party/bigframes_vendored/ibis/expr/sql.py index f375a7351f1..45d9ab6f2f4 100644 --- a/third_party/bigframes_vendored/ibis/expr/sql.py +++ b/third_party/bigframes_vendored/ibis/expr/sql.py @@ -3,8 +3,8 @@ from __future__ import annotations import contextlib -import operator from functools import singledispatch +import operator import bigframes_vendored.ibis import bigframes_vendored.ibis.expr.api as api @@ -12,12 +12,12 @@ import bigframes_vendored.ibis.expr.schema as sch import bigframes_vendored.ibis.expr.types as ibis_types import bigframes_vendored.ibis.expr.types as ir -import bigframes_vendored.sqlglot as sg -import bigframes_vendored.sqlglot.expressions as sge -import bigframes_vendored.sqlglot.optimizer as sgo -import bigframes_vendored.sqlglot.planner as sgp from bigframes_vendored.ibis.util import experimental from public import public +import sqlglot as sg +import sqlglot.expressions as sge +import sqlglot.optimizer as sgo +import sqlglot.planner as sgp class Catalog(dict[str, sch.Schema]): diff --git a/third_party/bigframes_vendored/ibis/expr/types/arrays.py b/third_party/bigframes_vendored/ibis/expr/types/arrays.py index ee11acd56b5..72f01334c15 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/arrays.py +++ b/third_party/bigframes_vendored/ibis/expr/types/arrays.py @@ -5,8 +5,8 @@ import inspect from typing import TYPE_CHECKING +from bigframes_vendored.ibis.common.deferred import deferrable, Deferred import bigframes_vendored.ibis.expr.operations as ops -from bigframes_vendored.ibis.common.deferred import Deferred, deferrable from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value from public import public @@ -1008,6 +1008,7 @@ 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 b89eb6c1f1a..08fea31a1ce 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/binary.py +++ b/third_party/bigframes_vendored/ibis/expr/types/binary.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import Literal, TYPE_CHECKING if TYPE_CHECKING: from bigframes_vendored.ibis.expr import types as ir @@ -35,16 +35,6 @@ def hashbytes( def __invert__(self) -> BinaryValue: return ops.BitwiseNot(self).to_expr() - def length(self) -> ir.IntegerValue: - """Compute the length of a binary value. - - Returns - ------- - IntegerValue - The length of each binary value in the expression - """ - return ops.StringLength(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 7a527bbda28..5704dc993ae 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/core.py +++ b/third_party/bigframes_vendored/ibis/expr/types/core.py @@ -4,12 +4,10 @@ import contextlib import os +from typing import Any, NoReturn, TYPE_CHECKING import webbrowser -from typing import TYPE_CHECKING, Any, NoReturn import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.operations as ops -import pandas as pd from bigframes_vendored.ibis.common.annotations import ValidationError from bigframes_vendored.ibis.common.exceptions import IbisError, TranslationError from bigframes_vendored.ibis.common.grounds import Immutable @@ -18,8 +16,10 @@ from bigframes_vendored.ibis.config import _default_backend from bigframes_vendored.ibis.config import options as opts from bigframes_vendored.ibis.expr.format import pretty +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 @@ -29,15 +29,15 @@ from collections.abc import Iterator, Mapping from pathlib import Path - import bigframes_vendored.ibis.expr.types as ir - import polars as pl - import pyarrow as pa - import torch from bigframes_vendored.ibis.backends import BaseBackend + import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.expr.visualize import ( EdgeAttributeGetter, NodeAttributeGetter, ) + import polars as pl + import pyarrow as pa + import torch class _FixedTextJupyterMixin(JupyterMixin): diff --git a/third_party/bigframes_vendored/ibis/expr/types/generic.py b/third_party/bigframes_vendored/ibis/expr/types/generic.py index 52d07183f66..7de357b1389 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/generic.py +++ b/third_party/bigframes_vendored/ibis/expr/types/generic.py @@ -3,21 +3,21 @@ from __future__ import annotations from collections.abc import Iterable, Sequence -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING import bigframes_vendored.ibis +from bigframes_vendored.ibis.common.deferred import _, deferrable, Deferred import bigframes_vendored.ibis.common.exceptions as com +from bigframes_vendored.ibis.common.grounds import Singleton import bigframes_vendored.ibis.expr.builders as bl import bigframes_vendored.ibis.expr.datatypes as dt import bigframes_vendored.ibis.expr.operations as ops -from bigframes_vendored.ibis.common.deferred import Deferred, _, deferrable -from bigframes_vendored.ibis.common.grounds import Singleton from bigframes_vendored.ibis.expr.rewrites import rewrite_window_input from bigframes_vendored.ibis.expr.types.core import ( - Expr, _binop, _FixedTextJupyterMixin, _is_null_literal, + Expr, ) from bigframes_vendored.ibis.expr.types.pretty import to_rich from bigframes_vendored.ibis.util import deprecated, warn_deprecated @@ -26,11 +26,11 @@ if TYPE_CHECKING: import bigframes_vendored.ibis.expr.schema as sch import bigframes_vendored.ibis.expr.types as ir + from bigframes_vendored.ibis.formats.pyarrow import PyArrowData import pandas as pd import polars as pl import pyarrow as pa import rich.table - from bigframes_vendored.ibis.formats.pyarrow import PyArrowData @public @@ -773,9 +773,7 @@ def over( @deferrable def bind(table): - winfunc = rewrite_window_input( - node, window.bind(table) if (table is not None) else window - ) + winfunc = rewrite_window_input(node, window.bind(table)) if winfunc == node: raise com.IbisTypeError( "No reduction or analytic function found to construct a window expression" @@ -1411,9 +1409,9 @@ def __pandas_result__( ) -> pd.Series: from bigframes_vendored.ibis.formats.pandas import PandasData - assert len(df.columns) == 1, ( - "more than one column when converting columnar result DataFrame to Series" - ) + assert ( + len(df.columns) == 1 + ), "more than one column when converting columnar result DataFrame to Series" # in theory we could use df.iloc[:, 0], but there seems to be a bug in # older geopandas where df.iloc[:, 0] doesn't return the same kind of # object as df.loc[:, column_name] when df is a GeoDataFrame diff --git a/third_party/bigframes_vendored/ibis/expr/types/groupby.py b/third_party/bigframes_vendored/ibis/expr/types/groupby.py index 369eb8a0a82..102800065fa 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/groupby.py +++ b/third_party/bigframes_vendored/ibis/expr/types/groupby.py @@ -18,16 +18,16 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Annotated +from typing import Annotated, TYPE_CHECKING import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.common.grounds import Concrete from bigframes_vendored.ibis.common.patterns import Length # noqa: TCH001 from bigframes_vendored.ibis.common.typing import VarTuple # noqa: TCH001 +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.rewrites import rewrite_window_input +import bigframes_vendored.ibis.expr.types as ir from public import public if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/expr/types/joins.py b/third_party/bigframes_vendored/ibis/expr/types/joins.py index 62c4a334fb5..90aeff655ba 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/joins.py +++ b/third_party/bigframes_vendored/ibis/expr/types/joins.py @@ -3,10 +3,9 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.deferred import Deferred from bigframes_vendored.ibis.common.egraph import DisjointSet @@ -16,12 +15,13 @@ InputTypeError, IntegrityError, ) +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.rewrites import flatten_predicates, peel_join_field from bigframes_vendored.ibis.expr.types.generic import Value from bigframes_vendored.ibis.expr.types.relations import ( + bind, DerefMap, Table, - bind, unwrap_aliases, ) from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/types/logical.py b/third_party/bigframes_vendored/ibis/expr/types/logical.py index 68ad6feae10..cc86c747f6f 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/logical.py +++ b/third_party/bigframes_vendored/ibis/expr/types/logical.py @@ -339,7 +339,7 @@ def any(self, where: BooleanValue | None = None) -> BooleanValue: │ np.False_ │ └───────────┘ """ - from bigframes_vendored.ibis.common.deferred import Call, Deferred, _ + from bigframes_vendored.ibis.common.deferred import _, Call, Deferred parents = self.op().relations diff --git a/third_party/bigframes_vendored/ibis/expr/types/maps.py b/third_party/bigframes_vendored/ibis/expr/types/maps.py index 0be7241b241..881f8327d04 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/maps.py +++ b/third_party/bigframes_vendored/ibis/expr/types/maps.py @@ -2,10 +2,10 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.common.deferred import deferrable +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value from public import public @@ -35,6 +35,7 @@ class MapValue(Value): -------- >>> import ibis >>> ibis.options.interactive = True + >>> import pyarrow as pa >>> tab = pa.table( ... { ... "m": pa.array( @@ -100,6 +101,7 @@ 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( ... { @@ -165,6 +167,7 @@ def length(self) -> ir.IntegerValue: Examples -------- >>> import ibis + >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -221,6 +224,7 @@ def __getitem__(self, key: ir.Value) -> ir.Value: Examples -------- >>> import ibis + >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -272,6 +276,7 @@ def contains( Examples -------- >>> import ibis + >>> import pyarrow as pa >>> ibis.options.interactive = True >>> tab = pa.table( ... { @@ -316,6 +321,7 @@ 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/numeric.py b/third_party/bigframes_vendored/ibis/expr/types/numeric.py index 84ef30b9f80..c8f0d3eb347 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/numeric.py +++ b/third_party/bigframes_vendored/ibis/expr/types/numeric.py @@ -3,11 +3,11 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Literal +from typing import Literal, TYPE_CHECKING import bigframes_vendored.ibis -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.common.exceptions import IbisTypeError +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.types.core import _binop from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/types/pretty.py b/third_party/bigframes_vendored/ibis/expr/types/pretty.py index 22617d84615..d5796484def 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/pretty.py +++ b/third_party/bigframes_vendored/ibis/expr/types/pretty.py @@ -3,8 +3,8 @@ from __future__ import annotations import datetime -import json from functools import singledispatch +import json from math import isfinite from typing import TYPE_CHECKING from urllib.parse import urlparse @@ -12,11 +12,11 @@ import bigframes_vendored.ibis import bigframes_vendored.ibis.expr.datatypes as dt import rich -import rich.table from rich import box from rich.align import Align from rich.panel import Panel from rich.pretty import Pretty +import rich.table from rich.text import Text if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/expr/types/relations.py b/third_party/bigframes_vendored/ibis/expr/types/relations.py index 956bb95dfae..d3d66b1512f 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/relations.py +++ b/third_party/bigframes_vendored/ibis/expr/types/relations.py @@ -2,44 +2,44 @@ from __future__ import annotations -import itertools -import operator -import re from collections import deque from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +import itertools from keyword import iskeyword -from typing import TYPE_CHECKING, Any, Literal +import operator +import re +from typing import Any, Literal, TYPE_CHECKING import bigframes_vendored.ibis -import bigframes_vendored.ibis.common.exceptions as com -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.schema as sch -import toolz from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.deferred import Deferred, Resolver +import bigframes_vendored.ibis.common.exceptions as com from bigframes_vendored.ibis.common.selectors import Selector +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.rewrites import DerefMap -from bigframes_vendored.ibis.expr.types.core import Expr, _FixedTextJupyterMixin -from bigframes_vendored.ibis.expr.types.generic import Value, literal +import bigframes_vendored.ibis.expr.schema as sch +from bigframes_vendored.ibis.expr.types.core import _FixedTextJupyterMixin, Expr +from bigframes_vendored.ibis.expr.types.generic import literal, Value from bigframes_vendored.ibis.expr.types.pretty import to_rich from bigframes_vendored.ibis.expr.types.temporal import TimestampColumn from bigframes_vendored.ibis.util import deprecated from public import public +import toolz if TYPE_CHECKING: - import bigframes_vendored.ibis.expr.types as ir - import bigframes_vendored.ibis.selectors as s - import pandas as pd - import polars as pl - import pyarrow as pa from bigframes_vendored.ibis.expr.operations.relations import JoinKind, Set from bigframes_vendored.ibis.expr.schema import SchemaLike from bigframes_vendored.ibis.expr.types import Table + import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.expr.types.groupby import GroupedTable from bigframes_vendored.ibis.expr.types.temporal_windows import WindowedTable from bigframes_vendored.ibis.formats.pyarrow import PyArrowData from bigframes_vendored.ibis.selectors import IfAnyAll + import bigframes_vendored.ibis.selectors as s + import pandas as pd + import polars as pl + import pyarrow as pa from rich.table import Table as RichTable @@ -3032,8 +3032,8 @@ def describe( │ island │ 1 │ string │ 344 │ 0 │ 3 │ Biscoe │ └─────────┴───────┴────────┴───────┴───────┴────────┴────────┘ """ - import bigframes_vendored.ibis.selectors as s from bigframes_vendored.ibis.expr.types.generic import literal as lit + import bigframes_vendored.ibis.selectors as s quantile = sorted(quantile) aggs = [] @@ -3050,7 +3050,7 @@ def describe( col_max = lit(None).cast(float) col_mode = lit(None).cast(str) quantile_values = { - f"p{100 * q:.6f}".rstrip("0").rstrip("."): lit(None).cast(float) + f"p{100*q:.6f}".rstrip("0").rstrip("."): lit(None).cast(float) for q in quantile } @@ -3061,9 +3061,7 @@ def describe( col_min = col.min().cast(float) col_max = col.max().cast(float) quantile_values = { - f"p{100 * q:.6f}".rstrip("0").rstrip("."): col.quantile(q).cast( - float - ) + f"p{100*q:.6f}".rstrip("0").rstrip("."): col.quantile(q).cast(float) for q in quantile } elif typ.is_string(): @@ -4356,9 +4354,9 @@ def pivot_wider( │ … │ … │ … │ … │ └───────┴──────────┴──────────┴──────────┘ """ + from bigframes_vendored.ibis.expr.rewrites import _, p, x import bigframes_vendored.ibis.selectors as s import pandas as pd - from bigframes_vendored.ibis.expr.rewrites import _, p, x orig_names_from = util.promote_list(names_from) diff --git a/third_party/bigframes_vendored/ibis/expr/types/strings.py b/third_party/bigframes_vendored/ibis/expr/types/strings.py index 29502740082..85b455e66ee 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/strings.py +++ b/third_party/bigframes_vendored/ibis/expr/types/strings.py @@ -4,10 +4,10 @@ import functools import operator -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal, TYPE_CHECKING -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis import util +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.types.core import _binop from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value from public import public @@ -96,6 +96,15 @@ 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/expr/types/structs.py b/third_party/bigframes_vendored/ibis/expr/types/structs.py index eb5b5595a23..1f87e1bb473 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/structs.py +++ b/third_party/bigframes_vendored/ibis/expr/types/structs.py @@ -6,10 +6,10 @@ from keyword import iskeyword from typing import TYPE_CHECKING -import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.common.deferred import deferrable from bigframes_vendored.ibis.common.exceptions import IbisError -from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value, literal +import bigframes_vendored.ibis.expr.operations as ops +from bigframes_vendored.ibis.expr.types.generic import Column, literal, Scalar, Value from public import public if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/ibis/expr/types/temporal.py b/third_party/bigframes_vendored/ibis/expr/types/temporal.py index 91e978d5402..72e41dd942c 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/temporal.py +++ b/third_party/bigframes_vendored/ibis/expr/types/temporal.py @@ -2,15 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal, TYPE_CHECKING -import bigframes_vendored.ibis.expr.datashape as ds -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.operations as ops -from bigframes_vendored import ibis from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.annotations import annotated from bigframes_vendored.ibis.common.temporal import IntervalUnit +import bigframes_vendored.ibis.expr.datashape as ds +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.types.core import _binop from bigframes_vendored.ibis.expr.types.generic import Column, Scalar, Value from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/types/temporal_windows.py b/third_party/bigframes_vendored/ibis/expr/types/temporal_windows.py index 13e917c744c..93faf9c7d41 100644 --- a/third_party/bigframes_vendored/ibis/expr/types/temporal_windows.py +++ b/third_party/bigframes_vendored/ibis/expr/types/temporal_windows.py @@ -2,14 +2,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Literal +from typing import Literal, TYPE_CHECKING -import bigframes_vendored.ibis.common.exceptions as com -import bigframes_vendored.ibis.expr.operations as ops -import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.common.collections import FrozenOrderedDict # noqa: TCH001 +import bigframes_vendored.ibis.common.exceptions as com from bigframes_vendored.ibis.common.grounds import Concrete +import bigframes_vendored.ibis.expr.operations as ops from bigframes_vendored.ibis.expr.operations.relations import Unaliased # noqa: TCH001 +import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis.expr.types.relations import unwrap_aliases from public import public diff --git a/third_party/bigframes_vendored/ibis/expr/visualize.py b/third_party/bigframes_vendored/ibis/expr/visualize.py index 390c9c98282..3b5bb70ccc9 100644 --- a/third_party/bigframes_vendored/ibis/expr/visualize.py +++ b/third_party/bigframes_vendored/ibis/expr/visualize.py @@ -2,18 +2,18 @@ from __future__ import annotations +from collections.abc import Callable import contextlib +from html import escape import sys import tempfile -from collections.abc import Callable -from html import escape from typing import Optional import bigframes_vendored.ibis import bigframes_vendored.ibis.common.exceptions as com +from bigframes_vendored.ibis.common.graph import Graph import bigframes_vendored.ibis.expr.operations as ops import graphviz as gv -from bigframes_vendored.ibis.common.graph import Graph def get_type(node): @@ -187,8 +187,8 @@ def draw(graph, path=None, format="png", verbose: bool = False): if __name__ == "__main__": - import json from argparse import ArgumentParser + import json from bigframes_vendored.ibis import _ diff --git a/third_party/bigframes_vendored/ibis/formats/__init__.py b/third_party/bigframes_vendored/ibis/formats/__init__.py index 627299a0da1..96b5b86b7e1 100644 --- a/third_party/bigframes_vendored/ibis/formats/__init__.py +++ b/third_party/bigframes_vendored/ibis/formats/__init__.py @@ -3,16 +3,16 @@ from __future__ import annotations from abc import abstractmethod -from typing import TYPE_CHECKING, Generic, TypeVar +from typing import Generic, TYPE_CHECKING, TypeVar -from bigframes_vendored.ibis.util import PseudoHashable, indent +from bigframes_vendored.ibis.util import indent, PseudoHashable if TYPE_CHECKING: + from bigframes_vendored.ibis.expr.datatypes import DataType + from bigframes_vendored.ibis.expr.schema import Schema import pandas as pd import polars as pl import pyarrow as pa - from bigframes_vendored.ibis.expr.datatypes import DataType - from bigframes_vendored.ibis.expr.schema import Schema C = TypeVar("C") T = TypeVar("T") diff --git a/third_party/bigframes_vendored/ibis/formats/numpy.py b/third_party/bigframes_vendored/ibis/formats/numpy.py index 76cab2888b3..a1e53b76b70 100644 --- a/third_party/bigframes_vendored/ibis/formats/numpy.py +++ b/third_party/bigframes_vendored/ibis/formats/numpy.py @@ -4,9 +4,9 @@ import bigframes_vendored.ibis.expr.datatypes as dt import bigframes_vendored.ibis.expr.schema as sch +from bigframes_vendored.ibis.formats import SchemaMapper, TypeMapper import numpy as np import toolz -from bigframes_vendored.ibis.formats import SchemaMapper, TypeMapper _from_numpy_types = toolz.keymap( np.dtype, diff --git a/third_party/bigframes_vendored/ibis/formats/pandas.py b/third_party/bigframes_vendored/ibis/formats/pandas.py index a24c170ac50..f47f94853c4 100644 --- a/third_party/bigframes_vendored/ibis/formats/pandas.py +++ b/third_party/bigframes_vendored/ibis/formats/pandas.py @@ -4,19 +4,16 @@ import contextlib import datetime -import warnings -from functools import partial from importlib.util import find_spec as _find_spec +from functools import partial from typing import TYPE_CHECKING +import warnings -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.schema as sch -import numpy as np -import pandas as pd -import pandas.api.types as pdt from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.numeric import normalize_decimal from bigframes_vendored.ibis.common.temporal import normalize_timezone +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.schema as sch from bigframes_vendored.ibis.formats import DataMapper, SchemaMapper, TableProxy from bigframes_vendored.ibis.formats.numpy import NumpyType from bigframes_vendored.ibis.formats.pyarrow import ( @@ -24,6 +21,9 @@ PyArrowSchema, PyArrowType, ) +import numpy as np +import pandas as pd +import pandas.api.types as pdt if TYPE_CHECKING: import polars as pl @@ -179,9 +179,13 @@ def convert_GeoSpatial(cls, s, dtype, pandas_type): return gpd.GeoSeries(s) return gpd.GeoSeries.from_wkb(s) - convert_Point = convert_LineString = convert_Polygon = convert_MultiLineString = ( - convert_MultiPoint - ) = convert_MultiPolygon = convert_GeoSpatial + convert_Point = ( + convert_LineString + ) = ( + convert_Polygon + ) = ( + convert_MultiLineString + ) = convert_MultiPoint = convert_MultiPolygon = convert_GeoSpatial @classmethod def convert_default(cls, s, dtype, pandas_type): @@ -420,8 +424,8 @@ def to_pyarrow(self, schema: sch.Schema) -> pa.Table: return pa.Table.from_pandas(self.obj, schema=pyarrow_schema) def to_polars(self, schema: sch.Schema) -> pl.DataFrame: - import polars as pl from bigframes_vendored.ibis.formats.polars import PolarsSchema + import polars as pl pl_schema = PolarsSchema.from_ibis(schema) return pl.from_pandas(self.obj, schema_overrides=pl_schema) diff --git a/third_party/bigframes_vendored/ibis/formats/polars.py b/third_party/bigframes_vendored/ibis/formats/polars.py index 1c0b38ee804..4825672cfda 100644 --- a/third_party/bigframes_vendored/ibis/formats/polars.py +++ b/third_party/bigframes_vendored/ibis/formats/polars.py @@ -2,10 +2,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING import bigframes_vendored.ibis.expr.datatypes as dt -import polars as pl from bigframes_vendored.ibis.expr.schema import Schema from bigframes_vendored.ibis.formats import ( DataMapper, @@ -13,6 +12,7 @@ TableProxy, TypeMapper, ) +import polars as pl if TYPE_CHECKING: from collections.abc import Sequence diff --git a/third_party/bigframes_vendored/ibis/formats/pyarrow.py b/third_party/bigframes_vendored/ibis/formats/pyarrow.py index 5428264ee7c..491e551ec1e 100644 --- a/third_party/bigframes_vendored/ibis/formats/pyarrow.py +++ b/third_party/bigframes_vendored/ibis/formats/pyarrow.py @@ -3,7 +3,7 @@ from __future__ import annotations import functools -from typing import TYPE_CHECKING, Any +from typing import Any, TYPE_CHECKING import bigframes_vendored.ibis.expr.datatypes as dt from bigframes_vendored.ibis.expr.schema import Schema @@ -372,8 +372,8 @@ def to_pyarrow(self, schema: Schema) -> pa.Table: return self.obj def to_polars(self, schema: Schema) -> pl.DataFrame: - import polars as pl from bigframes_vendored.ibis.formats.polars import PolarsData + import polars as pl df = pl.from_arrow(self.obj) return PolarsData.convert_table(df, schema) diff --git a/third_party/bigframes_vendored/ibis/selectors.py b/third_party/bigframes_vendored/ibis/selectors.py index 3b9f0107728..401aba253f7 100644 --- a/third_party/bigframes_vendored/ibis/selectors.py +++ b/third_party/bigframes_vendored/ibis/selectors.py @@ -52,22 +52,22 @@ from __future__ import annotations +from collections.abc import Callable, Iterable, Mapping, Sequence import functools import inspect import operator import re -from collections.abc import Callable, Iterable, Mapping, Sequence from typing import Optional, Union -import bigframes_vendored.ibis.common.exceptions as exc -import bigframes_vendored.ibis.expr.datatypes as dt -import bigframes_vendored.ibis.expr.types as ir from bigframes_vendored.ibis import util from bigframes_vendored.ibis.common.collections import frozendict # noqa: TCH001 from bigframes_vendored.ibis.common.deferred import Deferred, Resolver from bigframes_vendored.ibis.common.exceptions import IbisError +import bigframes_vendored.ibis.common.exceptions as exc from bigframes_vendored.ibis.common.grounds import Singleton from bigframes_vendored.ibis.common.selectors import Selector +import bigframes_vendored.ibis.expr.datatypes as dt +import bigframes_vendored.ibis.expr.types as ir from public import public diff --git a/third_party/bigframes_vendored/ibis/util.py b/third_party/bigframes_vendored/ibis/util.py index 7da2a7afff8..319f03aeaf2 100644 --- a/third_party/bigframes_vendored/ibis/util.py +++ b/third_party/bigframes_vendored/ibis/util.py @@ -15,14 +15,14 @@ import sys import textwrap import types -import uuid -import warnings from types import ModuleType -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TYPE_CHECKING, TypeVar +import uuid from uuid import uuid4 +import warnings -import toolz from bigframes_vendored.ibis.common.typing import Coercible +import toolz if TYPE_CHECKING: from collections.abc import Callable, Iterator, Sequence diff --git a/third_party/bigframes_vendored/pandas/_config/config.py b/third_party/bigframes_vendored/pandas/_config/config.py index 418f5868e57..13ccfdac894 100644 --- a/third_party/bigframes_vendored/pandas/_config/config.py +++ b/third_party/bigframes_vendored/pandas/_config/config.py @@ -2,6 +2,8 @@ import contextlib import operator +import bigframes + class option_context(contextlib.ContextDecorator): """ @@ -33,11 +35,8 @@ 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._config.options)) + (pat, operator.attrgetter(pat)(bigframes.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. @@ -48,10 +47,6 @@ 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) @@ -59,21 +54,18 @@ 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._config.options.is_bigquery_thread_local: - bigframes.core.global_session.close_session() + if bigframes.options.is_bigquery_thread_local: + bigframes.close_session() # Reset bigquery_options so that we're no longer thread-local. - bigframes._config.options._local.bigquery_options = None + bigframes.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._config.options._init_bigquery_thread_local() + bigframes.options._init_bigquery_thread_local() - parent = operator.attrgetter(root)(bigframes._config.options) + parent = operator.attrgetter(root)(bigframes.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 94319dbc102..fe15e7b40d9 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/arrow/accessors.py @@ -19,6 +19,8 @@ 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], @@ -43,6 +45,8 @@ 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], @@ -79,6 +83,8 @@ 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"}, @@ -123,6 +129,8 @@ 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"}, @@ -150,7 +158,6 @@ 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. @@ -158,6 +165,8 @@ 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"}, @@ -168,8 +177,8 @@ def dtypes(self): ... [("version", pa.int64()), ("project", pa.string())] ... )) ... ) - >>> s.struct.dtypes - version int64[pyarrow] + >>> s.struct.dtypes() + version Int64 project string[pyarrow] dtype: object @@ -191,6 +200,8 @@ 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 ace91dad1e8..1736a7f9ef2 100644 --- a/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py +++ b/third_party/bigframes_vendored/pandas/core/arrays/datetimelike.py @@ -15,6 +15,7 @@ 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'], @@ -50,6 +51,7 @@ 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', @@ -83,6 +85,8 @@ 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/col.py b/third_party/bigframes_vendored/pandas/core/col.py deleted file mode 100644 index 9b71293a7e3..00000000000 --- a/third_party/bigframes_vendored/pandas/core/col.py +++ /dev/null @@ -1,36 +0,0 @@ -# Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/col.py -from __future__ import annotations - -from collections.abc import Hashable - -from bigframes import constants - - -class Expression: - """ - Class representing a deferred column. - - This is not meant to be instantiated directly. Instead, use :meth:`pandas.col`. - """ - - -def col(col_name: Hashable) -> Expression: - """ - Generate deferred object representing a column of a DataFrame. - - Any place which accepts ``lambda df: df[col_name]``, such as - :meth:`DataFrame.assign` or :meth:`DataFrame.loc`, can also accept - ``pd.col(col_name)``. - - Args: - col_name (Hashable): - Column name. - - Returns: - Expression: - A deferred object representing a column of a DataFrame. - """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - - -__all__ = ["Expression", "col"] diff --git a/third_party/bigframes_vendored/pandas/core/common.py b/third_party/bigframes_vendored/pandas/core/common.py index 970ba92a91c..872a64db6c3 100644 --- a/third_party/bigframes_vendored/pandas/core/common.py +++ b/third_party/bigframes_vendored/pandas/core/common.py @@ -1,7 +1,7 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/common.py from __future__ import annotations -from typing import TYPE_CHECKING, Callable +from typing import Callable, TYPE_CHECKING from bigframes_vendored.pandas.core.dtypes.inference import iterable_not_string diff --git a/third_party/bigframes_vendored/pandas/core/computation/align.py b/third_party/bigframes_vendored/pandas/core/computation/align.py index fbc53a094df..2608dabe7ac 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/align.py +++ b/third_party/bigframes_vendored/pandas/core/computation/align.py @@ -2,34 +2,30 @@ """ Core eval alignment algorithms. """ - from __future__ import annotations -import warnings from functools import partial, wraps -from typing import TYPE_CHECKING, Callable, Union +from typing import Callable, TYPE_CHECKING +import warnings import bigframes_vendored.pandas.core.common as com -import numpy as np from bigframes_vendored.pandas.core.computation.common import result_type_many from bigframes_vendored.pandas.util._exceptions import find_stack_level +import numpy as np from pandas.errors import PerformanceWarning if TYPE_CHECKING: from collections.abc import Sequence + from bigframes_vendored.pandas.core.generic import NDFrame from bigframes_vendored.pandas.core.indexes.base import Index from pandas._typing import F - from bigframes.pandas import DataFrame, Series - - FrameT = Union[Series, DataFrame] - def _align_core_single_unary_op( term, -) -> tuple[partial | FrameT, dict[str, Index] | None]: - typ: partial | FrameT +) -> tuple[partial | type[NDFrame], dict[str, Index] | None]: + typ: partial | type[NDFrame] axes: dict[str, Index] | None = None if isinstance(term.value, np.ndarray): @@ -42,7 +38,9 @@ def _align_core_single_unary_op( return typ, axes -def _zip_axes_from_type(typ: FrameT, new_axes: Sequence[Index]) -> dict[str, Index]: +def _zip_axes_from_type( + typ: type[NDFrame], new_axes: Sequence[Index] +) -> dict[str, Index]: return {name: new_axes[i] for i, name in enumerate(typ._AXIS_ORDERS)} @@ -209,18 +207,20 @@ def is_series(obj) -> bool: def is_series_or_dataframe(obj) -> bool: - from bigframes.pandas import DataFrame, Series + from bigframes_vendored.pandas.core.frame import NDFrame - return isinstance(obj, Series | DataFrame) + return isinstance(obj, NDFrame) def is_pandas_object(obj) -> bool: - from bigframes.pandas import DataFrame, Index, Series + from bigframes_vendored.pandas.core.frame import NDFrame + from bigframes_vendored.pandas.core.indexes.base import Index - return isinstance(obj, Series | DataFrame | Index) + return isinstance(obj, NDFrame) or isinstance(obj, Index) def is_pandas_type(type) -> bool: - from bigframes.pandas import DataFrame, Index, Series + from bigframes_vendored.pandas.core.frame import NDFrame + from bigframes_vendored.pandas.core.indexes.base import Index - return issubclass(type, Series | DataFrame | Index) + return issubclass(type, NDFrame) or issubclass(type, Index) diff --git a/third_party/bigframes_vendored/pandas/core/computation/engines.py b/third_party/bigframes_vendored/pandas/core/computation/engines.py index 8902bb08adb..15fd48b2376 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/engines.py +++ b/third_party/bigframes_vendored/pandas/core/computation/engines.py @@ -2,7 +2,6 @@ """ Engine classes for :func:`~pandas.eval` """ - from __future__ import annotations import abc diff --git a/third_party/bigframes_vendored/pandas/core/computation/eval.py b/third_party/bigframes_vendored/pandas/core/computation/eval.py index bf7e1de3bf1..d3d11a9c2ac 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/eval.py +++ b/third_party/bigframes_vendored/pandas/core/computation/eval.py @@ -2,17 +2,17 @@ """ Top level ``eval`` module. """ - from __future__ import annotations import tokenize -import warnings from typing import TYPE_CHECKING +import warnings from bigframes_vendored.pandas.core.computation.engines import ENGINES -from bigframes_vendored.pandas.core.computation.expr import PARSERS, Expr +from bigframes_vendored.pandas.core.computation.expr import Expr, PARSERS from bigframes_vendored.pandas.core.computation.parsing import tokenize_string from bigframes_vendored.pandas.core.computation.scope import ensure_scope +from bigframes_vendored.pandas.core.generic import NDFrame from bigframes_vendored.pandas.util._validators import validate_bool_kwarg from pandas.io.formats.printing import pprint_thing @@ -172,6 +172,8 @@ def eval( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({"animal": ["dog", "pig"], "age": [10, 20]}) >>> df @@ -317,8 +319,6 @@ def eval( # assign if needed assigner = parsed_expr.assigner - from bigframes.pandas import DataFrame, Series - if env.target is not None and assigner is not None: target_modified = True @@ -326,7 +326,7 @@ def eval( if not inplace and first_expr: try: target = env.target - if isinstance(target, Series | DataFrame): + if isinstance(target, NDFrame): target = target.copy() except AttributeError as err: raise ValueError("Cannot return a copy of the target") from err @@ -340,7 +340,7 @@ def eval( try: with warnings.catch_warnings(record=True): # TODO: Filter the warnings we actually care about here. - if inplace and isinstance(target, Series | DataFrame): + if inplace and isinstance(target, NDFrame): target.loc[:, assigner] = ret else: target[ # pyright: ignore[reportGeneralTypeIssues] diff --git a/third_party/bigframes_vendored/pandas/core/computation/expr.py b/third_party/bigframes_vendored/pandas/core/computation/expr.py index e8def559a88..44f649e59dc 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/expr.py +++ b/third_party/bigframes_vendored/pandas/core/computation/expr.py @@ -2,37 +2,36 @@ """ :func:`~pandas.eval` parsers. """ - from __future__ import annotations import ast -import tokenize from functools import partial, reduce from keyword import iskeyword +import tokenize from typing import Callable, TypeVar import bigframes_vendored.pandas.core.common as com -import numpy as np from bigframes_vendored.pandas.core.computation.ops import ( ARITH_OPS_SYMS, + BinOp, BOOL_OPS_SYMS, CMP_OPS_SYMS, - LOCAL_TAG, - UNARY_OPS_SYMS, - BinOp, Constant, Div, FuncNode, + is_term, + LOCAL_TAG, Op, Term, + UNARY_OPS_SYMS, UnaryOp, - is_term, ) from bigframes_vendored.pandas.core.computation.parsing import ( clean_backtick_quoted_toks, tokenize_string, ) from bigframes_vendored.pandas.core.computation.scope import Scope +import numpy as np from pandas.errors import UndefinedVariableError from pandas.io.formats import printing diff --git a/third_party/bigframes_vendored/pandas/core/computation/ops.py b/third_party/bigframes_vendored/pandas/core/computation/ops.py index 0dfd77daf36..75b914c876b 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/ops.py +++ b/third_party/bigframes_vendored/pandas/core/computation/ops.py @@ -5,18 +5,18 @@ from __future__ import annotations -import operator from datetime import datetime from functools import partial -from typing import TYPE_CHECKING, Callable, Literal +import operator +from typing import Callable, Literal, TYPE_CHECKING import bigframes_vendored.pandas.core.common as com -import numpy as np from bigframes_vendored.pandas.core.computation.common import ( ensure_decoded, result_type_many, ) from bigframes_vendored.pandas.core.computation.scope import DEFAULT_GLOBALS +import numpy as np from pandas._libs.tslibs import Timestamp from pandas.core.dtypes.common import is_list_like, is_scalar from pandas.io.formats.printing import pprint_thing, pprint_thing_encoded diff --git a/third_party/bigframes_vendored/pandas/core/computation/parsing.py b/third_party/bigframes_vendored/pandas/core/computation/parsing.py index 569c3c50330..e54f4597357 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/parsing.py +++ b/third_party/bigframes_vendored/pandas/core/computation/parsing.py @@ -2,13 +2,12 @@ """ :func:`~pandas.eval` source string parsing functions """ - from __future__ import annotations -import token -import tokenize from io import StringIO from keyword import iskeyword +import token +import tokenize from typing import TYPE_CHECKING if TYPE_CHECKING: diff --git a/third_party/bigframes_vendored/pandas/core/computation/scope.py b/third_party/bigframes_vendored/pandas/core/computation/scope.py index 51b15e74a27..bfd7eb1d12b 100644 --- a/third_party/bigframes_vendored/pandas/core/computation/scope.py +++ b/third_party/bigframes_vendored/pandas/core/computation/scope.py @@ -2,17 +2,16 @@ """ Module for scope operations """ - from __future__ import annotations +from collections import ChainMap import datetime import inspect +from io import StringIO import itertools import pprint import struct import sys -from collections import ChainMap -from io import StringIO from typing import TypeVar import numpy as np diff --git a/third_party/bigframes_vendored/pandas/core/config_init.py b/third_party/bigframes_vendored/pandas/core/config_init.py index bd40d05154b..3425674e4f8 100644 --- a/third_party/bigframes_vendored/pandas/core/config_init.py +++ b/third_party/bigframes_vendored/pandas/core/config_init.py @@ -1,5 +1,4 @@ -# Contains code from -# https://github.com/pandas-dev/pandas/blob/main/pandas/core/config_init.py +# Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/config_init.py """ This module is imported from the pandas package __init__.py file in order to ensure that the core.config options registered here will @@ -11,229 +10,110 @@ module is imported, register them here rather than in the module. """ - from __future__ import annotations -import dataclasses -from typing import Literal, Optional - - -@dataclasses.dataclass -class DisplayOptions: - """ - Encapsulates the configuration for displaying objects. - - **Examples:** - - Define Repr mode to "deferred" will prevent job execution in repr. - - >>> import bigframes.pandas as bpd - >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") - - >>> bpd.options.display.repr_mode = "deferred" # doctest: +SKIP - >>> df.head(20) # will no longer run the job # doctest: +SKIP - Computation deferred. Computation will process 28.9 kB - - 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.query_job.total_bytes_processed # doctest: +SKIP - 28947 - - User can execute the job by calling .to_pandas() - - >>> # df.to_pandas() - - Reset repr_mode option - - >>> bpd.options.display.repr_mode = "head" # doctest: +SKIP - - Can also set the progress_bar option to see the progress bar in terminal, - - >>> bpd.options.display.progress_bar = "terminal" # doctest: +SKIP - - notebook, - - >>> bpd.options.display.progress_bar = "notebook" # doctest: +SKIP - - or just remove it. - - Setting to default value "auto" will detect and show progress bar - automatically. - - >>> bpd.options.display.progress_bar = "auto" # doctest: +SKIP - """ - - # 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.max_columns = 50 # doctest: +SKIP - """ - - max_rows: int = 10 - """ - Maximum number of rows to display. Default 10. - - If `max_rows` is exceeded, switch to truncate view. +display_options_doc = """ +Encapsulates the configuration for displaying objects. - **Examples:** +**Examples:** - >>> import bigframes.pandas as bpd - >>> bpd.options.display.max_rows = 50 # doctest: +SKIP - """ +Define Repr mode to "deferred" will prevent job execution in repr. - precision: int = 6 - """ - Controls the floating point output precision. Defaults to 6. + >>> import bigframes.pandas as bpd + >>> df = bpd.read_gbq("bigquery-public-data.ml_datasets.penguins") - See :attr:`pandas.options.display.precision`. + >>> bpd.options.display.repr_mode = "deferred" + >>> df.head(20) # will no longer run the job + Computation deferred. Computation will process 28.9 kB - **Examples:** +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 - >>> bpd.options.display.precision = 2 # doctest: +SKIP - """ + >>> df.query_job.total_bytes_processed + 28947 - # Options unique to BigQuery DataFrames. - progress_bar: Optional[Literal["auto", "notebook", "terminal"]] = "auto" - """ - Determines if progress bars are shown during job runs. Default "auto". +User can execute the job by calling .to_pandas() - Valid values are `auto`, `notebook`, and `terminal`. Set - to `None` to remove progress bars. + >>> # df.to_pandas() - **Examples:** +Reset repr_mode option - >>> import bigframes.pandas as bpd - >>> bpd.options.display.progress_bar = "terminal" # doctest: +SKIP - """ + >>> bpd.options.display.repr_mode = "head" - repr_mode: Literal["head", "deferred", "anywidget"] = "head" - """ - Determines how to display a DataFrame or Series. Default "head". +Can also set the progress_bar option to see the progress bar in terminal, - `head` - Execute, download, and display results (limited to head) from - Dataframe and Series objects during repr. + >>> bpd.options.display.progress_bar = "terminal" - `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. +notebook, - `anywidget` - Display as interactive widget using `anywidget` library. + >>> bpd.options.display.progress_bar = "notebook" - **Examples:** +or just remove it. - >>> import bigframes.pandas as bpd - >>> bpd.options.display.repr_mode = "deferred" # doctest: +SKIP - """ + >>> bpd.options.display.progress_bar = None - render_mode: Literal["plaintext", "html", "anywidget"] = "html" - """ - Determines how to visualize a DataFrame or Series. Default "html". +Setting to default value "auto" will detect and show progress bar automatically. - `plaintext` - Display as plain text. + >>> bpd.options.display.progress_bar = "auto" - `html` - Display as HTML table. - - `anywidget` - Display as interactive widget using `anywidget` library. - """ - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.max_colwidth = 20 # doctest: +SKIP - """ - - max_info_columns: int = 100 - """ - Used in DataFrame.info method to decide if information in each column will - be printed. Default 100. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.max_info_columns = 50 # doctest: +SKIP - """ - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.max_info_rows = 100 # doctest: +SKIP - """ - - 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. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.memory_usage = False # doctest: +SKIP - """ - - blob_display: bool = True - """ - If True, display the blob content in notebook DataFrame preview. Default - True. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.blob_display = True # doctest: +SKIP - """ - - blob_display_width: Optional[int] = None - """ - Width in pixels that the blob constrained to. Default None.. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> bpd.options.display.blob_display_width = 100 # doctest: +SKIP - """ - blob_display_height: Optional[int] = None - """ - Height in pixels that the blob constrained to. Default None.. - - **Examples:** +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, + precision (int): + Controls the floating point output precision, similar to + `pandas.options.display.precision`. + blob_display (bool): + Whether to display the blob content in notebook DataFrame preview. Default True. + blob_display_width (int or None): + Width in pixels that the blob constrained to. + blob_display_height (int or None): + Height in pixels that the blob constrained to. +""" - >>> import bigframes.pandas as bpd - >>> bpd.options.display.blob_display_height = 100 # doctest: +SKIP - """ +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. +""" diff --git a/third_party/bigframes_vendored/pandas/core/dtypes/inference.py b/third_party/bigframes_vendored/pandas/core/dtypes/inference.py index 7875c297bc2..fcbb4c242f4 100644 --- a/third_party/bigframes_vendored/pandas/core/dtypes/inference.py +++ b/third_party/bigframes_vendored/pandas/core/dtypes/inference.py @@ -1,5 +1,5 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/dtypes/inference.py -"""basic inference routines""" +""" basic inference routines """ from __future__ import annotations diff --git a/third_party/bigframes_vendored/pandas/core/frame.py b/third_party/bigframes_vendored/pandas/core/frame.py index e84f46861d9..953ece9beb6 100644 --- a/third_party/bigframes_vendored/pandas/core/frame.py +++ b/third_party/bigframes_vendored/pandas/core/frame.py @@ -9,16 +9,14 @@ alignment and a host of useful data manipulation methods having to do with the labeling information """ - from __future__ import annotations -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 bigframes_vendored import constants from pandas.api import extensions as pd_ext # ----------------------------------------------------------------------- @@ -41,6 +39,8 @@ 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]}) @@ -63,12 +63,14 @@ 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:] - [Index(['col1', 'col2'], dtype='str')] + [Index(['col1', 'col2'], dtype='object')] """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + return [self.index, self.columns] @property def values(self) -> np.ndarray: @@ -76,6 +78,8 @@ 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 @@ -106,6 +110,8 @@ 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 @@ -140,6 +146,8 @@ 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) @@ -248,6 +256,8 @@ 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']) @@ -370,6 +380,8 @@ def to_numpy( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> df.to_numpy() @@ -408,6 +420,7 @@ def to_gbq( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Write a DataFrame to a BigQuery table. @@ -421,7 +434,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. # doctest: +SKIP + >>> bpd.close_session() # Optional, to demonstrate a new session. >>> bpd.read_gbq(destination, index_col="ordering_id") col1 col2 ordering_id @@ -517,6 +530,8 @@ 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) @@ -553,49 +568,6 @@ def to_parquet( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_csv( - self, - path_or_buf=None, - sep=",", - *, - header: bool = True, - index: bool = True, - allow_large_results: Optional[bool] = None, - ) -> Optional[str]: - """ - Write object to a comma-separated values (csv) file. - - **Examples:** - - >>> import bigframes.pandas as bpd - - >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) - >>> df.to_csv() - \',col1,col2\\n0,1,3\\n1,2,4\\n\' - - Args: - path_or_buf (str, path object, file-like object, or None, default None): - String, path object (implementing os.PathLike[str]), or file-like object - implementing a write() function. If None, the result is returned as a string. - If a non-binary file object is passed, it should be opened with newline='', - disabling universal newlines. If a binary file object is passed, - mode might need to contain a 'b'. - Must contain a wildcard character '*' if this is a GCS path. - sep (str, default ','): - String of length 1. Field delimiter for the output file. - header (bool, default True): - Write out 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: - If path_or_buf is None, returns the resulting csv format as a string. Otherwise returns None. - """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_dict( self, orient: Literal[ @@ -614,6 +586,8 @@ 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() @@ -692,7 +666,9 @@ def to_excel( **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()) @@ -727,6 +703,8 @@ 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()) @@ -776,6 +754,8 @@ 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() @@ -834,6 +814,8 @@ def to_string( **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()) @@ -932,6 +914,8 @@ def to_html( **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()) @@ -1040,6 +1024,8 @@ def to_markdown( **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()) @@ -1072,6 +1058,8 @@ def to_pickle(self, path, *, allow_large_results, **kwargs) -> None: **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" @@ -1092,6 +1080,8 @@ def to_orc(self, path=None, *, allow_large_results=None, **kwargs) -> bytes | No **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'col1': [1, 2], 'col2': [3, 4]}) >>> import tempfile @@ -1200,6 +1190,8 @@ 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]}) @@ -1251,6 +1243,8 @@ 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']) @@ -1290,6 +1284,7 @@ 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], @@ -1407,6 +1402,8 @@ 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 @@ -1477,6 +1474,8 @@ 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], @@ -1617,7 +1616,10 @@ 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), @@ -1657,6 +1659,7 @@ 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'), @@ -1792,10 +1795,12 @@ 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": [pd.NA, "1940-04-25", pd.NA]}) + ... "born": [bpd.NA, "1940-04-25", bpd.NA]}) >>> df name toy born 0 Alfred @@ -1903,6 +1908,8 @@ 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']) @@ -1957,13 +1964,15 @@ def keys(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], ... 'B': [4, 5, 6], ... }) >>> df.keys() - Index(['A', 'B'], dtype='str') + Index(['A', 'B'], dtype='object') Returns: pandas.Index: Info axis. @@ -1976,6 +1985,8 @@ 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], @@ -2000,6 +2011,8 @@ 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], @@ -2031,6 +2044,8 @@ 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]}, @@ -2070,6 +2085,9 @@ 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 a b @@ -2140,14 +2158,14 @@ def where(self, cond, other): with corresponding value from other. If cond is callable, it is computed on the Series/DataFrame and returns boolean Series/DataFrame or array. The callable must not change input - Series/DataFrame. + Series/DataFrame (though pandas doesn’t check it). other (scalar, DataFrame, or callable): Entries where cond is False are replaced with corresponding value from other. If other is callable, it is computed on the DataFrame and returns scalar or DataFrame. The callable must not - change input DataFrame. If not specified, entries will be filled - with the corresponding NULL value (np.nan for numpy dtypes, - pd.NA for extension dtypes). + change input DataFrame (though pandas doesn’t check it). If not + specified, entries will be filled with the corresponding NULL + value (np.nan for numpy dtypes, pd.NA for extension dtypes). Returns: DataFrame: DataFrame after the replacement. @@ -2159,6 +2177,8 @@ 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 @@ -2253,16 +2273,18 @@ def sort_values( *, inplace: bool = False, ascending: bool | Sequence[bool] = True, - kind: str | None = None, + kind: str = "quicksort", 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', pd.NA, 'D', 'C'], + ... 'col1': ['A', 'A', 'B', bpd.NA, 'D', 'C'], ... 'col2': [2, 1, 9, 8, 7, 4], ... 'col3': [0, 1, 9, 4, 2, 3], ... 'col4': ['a', 'B', 'c', 'D', 'e', 'F'] @@ -2339,7 +2361,7 @@ def sort_values( the by. inplace (bool, default False): If True, perform operation in-place. - kind (str, default None): + kind (str, default 'quicksort'): Choice of sorting algorithm. Accepts 'quicksort', 'mergesort', 'heapsort', 'stable'. Ignored except when determining whether to sort stably. 'mergesort' or 'stable' will result in stable reorder. @@ -2360,26 +2382,17 @@ def sort_values( def sort_index( self, *, - axis: str | int = 0, ascending: bool = True, inplace: bool = False, - kind: str | None = None, 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. - kind (str, default None): - Choice of sorting algorithm. Accepts 'quicksort', 'mergesort', - 'heapsort', 'stable'. Ignored except when determining whether to - sort stably. 'mergesort' or 'stable' will result in stable reorder. na_position ({'first', 'last'}, default 'last'): Puts NaNs at the beginning if `first`; `last` puts NaNs at the end. Not implemented for MultiIndex. @@ -2411,6 +2424,8 @@ 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: @@ -2452,6 +2467,8 @@ def __eq__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2481,6 +2498,8 @@ 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 @@ -2508,6 +2527,8 @@ 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: @@ -2548,6 +2569,8 @@ def __ne__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 3, 4], @@ -2586,6 +2609,8 @@ 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: @@ -2627,6 +2652,8 @@ def __le__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2665,6 +2692,8 @@ 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: @@ -2706,6 +2735,8 @@ def __lt__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2744,6 +2775,8 @@ 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: @@ -2785,6 +2818,8 @@ def __ge__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2823,6 +2858,8 @@ 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]}, @@ -2862,6 +2899,8 @@ def __gt__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, -1, 1], @@ -2897,6 +2936,8 @@ 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], @@ -2939,6 +2980,8 @@ 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], @@ -3012,6 +3055,8 @@ 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], @@ -3073,6 +3118,8 @@ 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], @@ -3115,6 +3162,8 @@ def __sub__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can subtract a scalar: @@ -3161,6 +3210,8 @@ 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], @@ -3220,6 +3271,8 @@ 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], @@ -3262,6 +3315,8 @@ def __mul__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3308,6 +3363,8 @@ 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], @@ -3350,6 +3407,8 @@ def __rmul__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3396,6 +3455,8 @@ 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], @@ -3438,6 +3499,8 @@ def __truediv__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3484,6 +3547,8 @@ 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], @@ -3543,6 +3608,8 @@ 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], @@ -3585,6 +3652,8 @@ def __floordiv__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3631,6 +3700,8 @@ 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], @@ -3690,6 +3761,8 @@ 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], @@ -3732,6 +3805,8 @@ def __mod__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -3778,6 +3853,8 @@ 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], @@ -3838,6 +3915,8 @@ 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], @@ -3881,6 +3960,8 @@ def __pow__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -3928,6 +4009,8 @@ 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], @@ -4022,6 +4105,8 @@ 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]}) @@ -4070,6 +4155,8 @@ 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]}) @@ -4098,6 +4185,9 @@ def explode( **Examples:** + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'A': [[0, 1, 2], [], [], [3, 4]], ... 'B': 1, @@ -4154,6 +4244,8 @@ 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], @@ -4186,6 +4278,8 @@ 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], @@ -4223,6 +4317,8 @@ def corrwith( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> index = ["a", "b", "c", "d", "e"] >>> columns = ["one", "two", "three", "four"] @@ -4257,6 +4353,8 @@ 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]}) @@ -4320,6 +4418,8 @@ def groupby( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({'Animal': ['Falcon', 'Falcon', ... 'Parrot', 'Parrot'], @@ -4415,12 +4515,15 @@ 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, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -4437,8 +4540,8 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: [5 rows x 2 columns] - >>> df_hours = df_minutes.map(minutes_to_hours) # doctest: +SKIP - >>> df_hours # doctest: +SKIP + >>> df_hours = df_minutes.map(minutes_to_hours) + >>> df_hours system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4454,27 +4557,11 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: >>> df_minutes = bpd.DataFrame( ... { - ... "system_minutes" : [0, 30, 60, None, 90, 120, pd.NA], - ... "user_minutes" : [0, 15, 75, 90, 6, None, pd.NA] + ... "system_minutes" : [0, 30, 60, None, 90, 120, bpd.NA], + ... "user_minutes" : [0, 15, 75, 90, 6, None, bpd.NA] ... }, dtype="Int64") - >>> 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 - 2 1.0 1.25 - 3 1.5 - 4 1.5 0.1 - 5 2.0 - 6 - - [7 rows x 2 columns] - - With experimental Python Transpiler enabled, you can use some lambda functions without - deploying them as remote functions. - - >>> bpd.options.experiments.enable_python_transpiler = True - >>> df_minutes.map(lambda hours: hours / 60) + >>> df_hours = df_minutes.map(minutes_to_hours, na_action='ignore') + >>> df_hours system_minutes user_minutes 0 0.0 0.0 1 0.5 0.25 @@ -4514,8 +4601,9 @@ def map(self, func, na_action: Optional[str] = None) -> DataFrame: def join( self, other, + *, on: Optional[str] = None, - how: str = "left", + how: str, lsuffix: str = "", rsuffix: str = "", ) -> DataFrame: @@ -4525,6 +4613,8 @@ def join( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Join two DataFrames by specifying how to handle the operation: @@ -4582,10 +4672,10 @@ def join( Another option to join using the key columns is to use the on parameter: - >>> df1.join(df2, on="col2", how="right") + >>> df1.join(df2, on="col1", how="right") col1 col2 col3 col4 - 11 foo 3 - 22 baz 4 + 11 foo 3 + 22 baz 4 [2 rows x 4 columns] @@ -4657,8 +4747,6 @@ 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: @@ -4677,6 +4765,8 @@ def merge( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Merge DataFrames df1 and df2 by specifying type of merge: @@ -4771,10 +4861,6 @@ 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 @@ -4805,88 +4891,6 @@ 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 - timestamp_col - 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 - timestamp_col - 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. @@ -4894,6 +4898,7 @@ def round(self, decimals): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame([(.21, .32), (.01, .67), (.66, .03), (.21, .18)], ... columns=['dogs', 'cats']) >>> df @@ -4976,6 +4981,9 @@ 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 @@ -5001,14 +5009,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, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... 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) # doctest: +SKIP + >>> df[["col1", "col2"]].apply(foo, axis=1) 0 11 1 19 dtype: Int64 @@ -5016,7 +5024,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, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def marks_analyzer(marks: pd.Series) -> list[float]: ... import statistics ... average = marks.mean() @@ -5033,8 +5041,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) # doctest: +SKIP - >>> stats # doctest: +SKIP + >>> stats = df.apply(marks_analyzer, axis=1) + >>> stats Alice [77.67 78. 77.19 76.71] Bob [75.67 80. 74.15 72.56] Charlie [75.33 75. 75.28 75.22] @@ -5057,23 +5065,14 @@ def apply(self, func, *, axis=0, args=(), **kwargs): [2 rows x 3 columns] - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def foo(x: int, y: int, z: int) -> float: ... result = 1 ... result += x ... result += y/z ... return result - >>> df.apply(foo, axis=1) # doctest: +SKIP - 0 2.6 - 1 3.8 - dtype: Float64 - - With experimental Python Transpiler enabled, you can use some lambda functions without - deploying them as remote functions: - - >>> bpd.options.experiments.enable_python_transpiler = True - >>> df.apply(lambda row: 1 + row.col1 + row.col2/row.col3, axis=1) + >>> df.apply(foo, axis=1) 0 2.6 1 3.8 dtype: Float64 @@ -5133,6 +5132,8 @@ 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 @@ -5178,6 +5179,8 @@ 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 @@ -5220,6 +5223,8 @@ 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 @@ -5264,6 +5269,8 @@ 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 @@ -5307,6 +5314,8 @@ 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 @@ -5349,6 +5358,8 @@ 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 @@ -5389,6 +5400,8 @@ 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 @@ -5430,6 +5443,8 @@ 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 @@ -5466,6 +5481,7 @@ 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) @@ -5502,6 +5518,8 @@ 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 @@ -5545,6 +5563,8 @@ 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], @@ -5584,6 +5604,8 @@ 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], @@ -5622,6 +5644,8 @@ 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], @@ -5662,6 +5686,8 @@ 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], @@ -5714,6 +5740,8 @@ 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']}) @@ -5804,6 +5832,8 @@ 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']}) @@ -5883,6 +5913,8 @@ 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 @@ -5911,6 +5943,8 @@ 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 @@ -5943,6 +5977,8 @@ 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], @@ -6016,6 +6052,8 @@ 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 @@ -6043,6 +6081,8 @@ 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 @@ -6073,6 +6113,8 @@ 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 @@ -6103,6 +6145,8 @@ 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 @@ -6138,6 +6182,8 @@ 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 @@ -6177,6 +6223,8 @@ 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 @@ -6223,6 +6271,8 @@ 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 @@ -6286,6 +6336,8 @@ def describe(self, include: None | Literal["all"] = None): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame({"A": [3, 1, 2], "B": [0, 2, 8], "C": ["cat", "cat", "dog"]}) >>> df A B C @@ -6355,6 +6407,8 @@ 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"], @@ -6424,6 +6478,8 @@ 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'], @@ -6489,10 +6545,6 @@ 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. """ @@ -6518,6 +6570,8 @@ 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 @@ -6555,6 +6609,8 @@ 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 @@ -6594,6 +6650,8 @@ 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. @@ -6645,6 +6703,8 @@ 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. @@ -6660,7 +6720,7 @@ def columns(self): [3 rows x 3 columns] >>> df.columns - Index(['Name', 'Age', 'Location'], dtype='str') + Index(['Name', 'Age', 'Location'], dtype='object') You can also set new labels for columns. @@ -6673,7 +6733,7 @@ def columns(self): [3 rows x 3 columns] >>> df.columns - Index(['NewName', 'NewAge', 'NewLocation'], dtype='str') + Index(['NewName', 'NewAge', 'NewLocation'], dtype='object') """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -6691,9 +6751,11 @@ 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, pd.NA]}, + ... 'num_wings': [2, 0, 0, 0, bpd.NA]}, ... index=['falcon', 'dog', 'cat', 'ant', 'octopus'], ... dtype='Int64') >>> df @@ -6770,6 +6832,8 @@ 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 @@ -6844,6 +6908,8 @@ 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), @@ -6913,10 +6979,12 @@ def query(self, expr: str) -> DataFrame | None: def interpolate(self, method: str = "linear"): """ - Fill NA (NULL in BigQuery) values using an interpolation method. + Fill NaN 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], @@ -6961,39 +7029,35 @@ def interpolate(self, method: str = "linear"): def fillna(self, value): """ - Fill NA (NULL in BigQuery) values using the specified method. - - 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`. + Fill NA/NaN values using the specified method. **Examples:** - >>> 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())) + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + + >>> 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 - A B C D - 0 NaN 2.0 0.0 - 1 3.0 NaN 1.0 - 2 NaN - 3 4.0 5.0 NaN + A B C D + 0 2.0 0.0 + 1 3.0 4.0 1.0 + 2 + 3 3.0 4.0 [4 rows x 4 columns] - Replace all NA (NULL) elements with 0s. + Replace all NA elements with 0s. >>> df.fillna(0) A B C D - 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 + 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 [4 rows x 4 columns] @@ -7009,11 +7073,11 @@ def fillna(self, value): [3 rows x 4 columns] >>> df.fillna(df_fill) - 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 + 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 [4 rows x 4 columns] @@ -7047,6 +7111,8 @@ 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"], @@ -7141,6 +7207,8 @@ 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']) @@ -7173,6 +7241,8 @@ 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']) @@ -7220,6 +7290,8 @@ 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 @@ -7312,6 +7384,8 @@ 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 @@ -7360,7 +7434,7 @@ def plot(self): Make plots of Dataframes. Returns: - bigframes.pandas.api.typing.PlotAccessor: + bigframes.operations.plotting.PlotAccessor: An accessor making plots. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -7370,6 +7444,8 @@ def __len__(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'a': [0, 1, 2], @@ -7391,6 +7467,9 @@ def __array__(self, dtype=None, copy: Optional[bool] = None): **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]}) @@ -7423,6 +7502,8 @@ def __getitem__(self, key): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], @@ -7467,6 +7548,7 @@ 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 @@ -7495,6 +7577,8 @@ def __setitem__(self, key, value): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... "name" : ["alpha", "beta", "gamma"], diff --git a/third_party/bigframes_vendored/pandas/core/generic.py b/third_party/bigframes_vendored/pandas/core/generic.py index 0e4ac335c8a..4c9d1338f4c 100644 --- a/third_party/bigframes_vendored/pandas/core/generic.py +++ b/third_party/bigframes_vendored/pandas/core/generic.py @@ -1,10 +1,11 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/generic.py from __future__ import annotations -from typing import TYPE_CHECKING, Callable, Iterator, Literal, Optional +from typing import Callable, Iterator, Literal, Optional, TYPE_CHECKING import bigframes_vendored.constants as constants from bigframes_vendored.pandas.core import indexing +import bigframes_vendored.pandas.core.common as common if TYPE_CHECKING: from bigframes_vendored.pandas.pandas._typing import T @@ -16,9 +17,6 @@ class NDFrame(indexing.IndexingMixin): size-mutable, labeled data structure """ - # Explicitly mark the class as unhashable - __hash__ = None # type: ignore - # ---------------------------------------------------------------------- # Axis @@ -37,6 +35,8 @@ 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,6 +62,8 @@ def __iter__(self) -> Iterator: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'A': [1, 2, 3], @@ -101,6 +103,9 @@ 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]} @@ -144,7 +149,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) # doctest: +SKIP + >>> bpd.to_datetime(ser, unit='us', utc=True) 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 @@ -342,6 +347,8 @@ def get(self, key, default=None): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... [ @@ -394,7 +401,10 @@ def get(self, key, default=None): Any: same type as items contained in object """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + try: + return self[key] + except (KeyError, ValueError, IndexError): + return default def add_prefix(self, prefix: str, axis: int | str | None = None): """Prefix labels with string `prefix`. @@ -448,6 +458,8 @@ 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']}) @@ -547,6 +559,8 @@ 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]}, @@ -626,12 +640,14 @@ 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 - float Float64 - int Int64 - string string + float Float64 + int Int64 + string string[pyarrow] dtype: object Returns: @@ -649,6 +665,8 @@ 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: @@ -720,6 +738,9 @@ 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], @@ -792,80 +813,67 @@ def bfill(self, *, limit: Optional[int] = None): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def isna(self) -> NDFrame: - """Detect missing (NULL) values. + """Detect missing 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. - - 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`. + 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. **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> import numpy as np + >>> df = bpd.DataFrame(dict( - ... 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())), + ... age=[5, 6, np.nan], + ... born=[bpd.NA, "1940-04-25", "1940-04-25"], + ... name=['Alfred', 'Batman', ''], + ... toy=[None, 'Batmobile', 'Joker'], ... )) >>> df - 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 + age born name toy + 0 5.0 Alfred + 1 6.0 1940-04-25 Batman Batmobile + 2 1940-04-25 Joker - [4 rows x 5 columns] + [3 rows x 4 columns] - Show which entries in a DataFrame are NA (NULL in BigQuery): + Show which entries in a DataFrame are NA: >>> df.isna() - 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 + age born name toy + 0 False True False True + 1 False False False False + 2 True False False False - [4 rows x 5 columns] + [3 rows x 4 columns] >>> df.isnull() - 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 + age born name toy + 0 False True False True + 1 False False False False + 2 True False False False - [4 rows x 5 columns] + [3 rows x 4 columns] - Show which entries in a Series are NA (NULL in BigQuery): + Show which entries in a Series are NA: - >>> ser = bpd.Series(pa.array( - ... [5, None, 6, np.nan, None], - ... type=pa.float64(), - ... ), dtype=pd.ArrowDtype(pa.float64())) + >>> ser = bpd.Series([5, None, 6, np.nan, bpd.NA]) >>> ser - 0 5.0 + 0 5 1 - 2 6.0 - 3 NaN + 2 6 + 3 4 - dtype: Float64 + dtype: Int64 >>> ser.isna() 0 False 1 True 2 False - 3 False + 3 True 4 True dtype: boolean @@ -873,7 +881,7 @@ def isna(self) -> NDFrame: 0 False 1 True 2 False - 3 False + 3 True 4 True dtype: boolean @@ -1034,10 +1042,6 @@ 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. @@ -1057,6 +1061,8 @@ def rolling( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> s = bpd.Series([0,1,2,3,4]) >>> s.rolling(window=3).min() 0 @@ -1141,6 +1147,9 @@ 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']) @@ -1223,7 +1232,16 @@ def pipe( bigframes.pandas.DataFrame or bigframes.pandas.Series: Object of same type as caller """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) + return common.pipe(self, func, *args, **kwargs) + + def __nonzero__(self): + """Returns the truth value of the object.""" + raise ValueError( + f"The truth value of a {type(self).__name__} is ambiguous. " + "Use a.empty, a.bool(), a.item(), a.any() or a.all()." + ) + + __bool__ = __nonzero__ def __getattr__(self, name: str): """ diff --git a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py index 579765fad5f..f0bc6348f8d 100644 --- a/third_party/bigframes_vendored/pandas/core/groupby/__init__.py +++ b/third_party/bigframes_vendored/pandas/core/groupby/__init__.py @@ -7,11 +7,8 @@ class providing the base-class of operations. (defined in pandas.core.groupby.generic) expose these user-facing objects to provide specific functionality. """ - from __future__ import annotations -from typing import Literal - from bigframes import constants @@ -20,62 +17,6 @@ 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. @@ -84,6 +25,8 @@ 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) @@ -121,6 +64,8 @@ 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) @@ -158,6 +103,9 @@ 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) @@ -194,6 +142,9 @@ 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']) @@ -252,6 +203,9 @@ 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() @@ -290,6 +244,7 @@ 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] @@ -328,6 +283,9 @@ 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) @@ -372,6 +330,9 @@ 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) @@ -414,6 +375,9 @@ def rank( **Examples:** >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame( ... { ... "group": ["a", "a", "a", "a", "a", "b", "b", "b", "b", "b"], @@ -464,8 +428,6 @@ def rank( * 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 @@ -486,6 +448,9 @@ 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', @@ -519,6 +484,8 @@ 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) @@ -550,6 +517,8 @@ 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) @@ -575,8 +544,9 @@ def first(self, numeric_only: bool = False, min_count: int = -1): Defaults to skipping NA elements. **Examples:** - >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[None, 5, 6], C=[1, 2, 3])) >>> df.groupby("A").first() B C @@ -615,6 +585,8 @@ def last(self, numeric_only: bool = False, min_count: int = -1): Defaults to skipping NA elements. **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame(dict(A=[1, 1, 3], B=[5, None, 6], C=[1, 2, 3])) >>> df.groupby("A").last() @@ -651,6 +623,8 @@ 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) @@ -694,6 +668,9 @@ 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) @@ -727,6 +704,9 @@ 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) @@ -773,6 +753,8 @@ 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) @@ -815,6 +797,8 @@ def cumcount(self, ascending: bool = True): 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) @@ -851,6 +835,9 @@ 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) @@ -887,6 +874,9 @@ 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) @@ -923,6 +913,9 @@ 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) @@ -959,6 +952,9 @@ 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) @@ -997,6 +993,9 @@ 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) @@ -1040,6 +1039,9 @@ 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) @@ -1081,6 +1083,9 @@ 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() @@ -1137,6 +1142,9 @@ 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() @@ -1160,6 +1168,8 @@ 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']) @@ -1187,9 +1197,11 @@ def size(self): **Examples:** - For SeriesGroupBy: + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> lst = ['a', 'a', 'b'] >>> ser = bpd.Series([1, 2, 3], index=lst) >>> ser @@ -1227,72 +1239,6 @@ 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): @@ -1301,6 +1247,9 @@ 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']) @@ -1331,6 +1280,9 @@ 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']) @@ -1361,6 +1313,9 @@ 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) @@ -1409,6 +1364,9 @@ 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], @@ -1466,6 +1424,9 @@ 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], @@ -1517,74 +1478,15 @@ 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'], @@ -1618,6 +1520,9 @@ def value_counts( **Examples:** + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame({ ... 'gender': ['male', 'male', 'female', 'male', 'female', 'male'], diff --git a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py index da5f9e3b88a..0dd487d056c 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/accessor.py @@ -1,5 +1,3 @@ -from typing import Literal - from bigframes import constants @@ -14,6 +12,9 @@ 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") ... ) @@ -41,6 +42,9 @@ 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() ... ) @@ -72,6 +76,9 @@ def day_of_week(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() ... ) @@ -93,68 +100,15 @@ def day_of_week(self): 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 pandas as pd >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2016-12-28', '2017-01-03', freq='D').to_series() ... ) @@ -180,7 +134,9 @@ def day_of_year(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-28', '2017-01-03', freq='D').to_series() ... ) @@ -212,6 +168,7 @@ 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 @@ -232,7 +189,9 @@ 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") ... ) @@ -256,7 +215,9 @@ 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") ... ) @@ -280,8 +241,11 @@ 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="ME") + ... pd.date_range("2000-01-01", periods=3, freq="M") ... ) >>> s 0 2000-01-31 00:00:00 @@ -303,6 +267,9 @@ def isocalendar(self): **Examples:** + >>> import pandas as pd + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... pd.date_range('2009-12-27', '2010-01-04', freq='d').to_series() ... ) @@ -333,7 +300,9 @@ def second(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="s") ... ) @@ -362,6 +331,7 @@ 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 @@ -383,6 +353,7 @@ 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 @@ -403,8 +374,11 @@ 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="YE") + ... pd.date_range("2000-01-01", periods=3, freq="Y") ... ) >>> s 0 2000-12-31 00:00:00 @@ -426,6 +400,9 @@ def days(self): **Examples:** + >>> import pandas as pd + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -441,6 +418,9 @@ def seconds(self): **Examples:** + >>> import pandas as pd + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -456,6 +436,9 @@ def microseconds(self): **Examples:** + >>> import pandas as pd + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("4d3m2s1us")]) >>> s 0 4 days 00:03:02.000001 @@ -470,6 +453,9 @@ def total_seconds(self): **Examples:** + >>> import pandas as pd + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([pd.Timedelta("1d1m1s1us")]) >>> s 0 1 days 00:01:01.000001 @@ -486,6 +472,7 @@ 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 @@ -501,34 +488,6 @@ def tz(self): raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - @property - def tz_localize(self, tz: Literal["UTC"] | None): - """Localize tz-naive Datetime Array/Index to tz-aware Datetime Array/Index. - - This method takes a time zone (tz) naive Datetime Array/Index object and makes - this time zone aware. It does not move the time to another time zone. Only "UTC" - timezone is supported. - - This method can also be used to do the inverse - to create a time zone unaware - object from an aware object. To that end, pass tz=None. - - **Examples:** - - >>> import bigframes.pandas as bpd - >>> s = bpd.Series([pd.Timestamp(year = 2026, month=1, day=1)]) - >>> s - 0 2026-01-01 00:00:00 - dtype: timestamp[us][pyarrow] - >>> s.dt.tz_localize('UTC') - 0 2026-01-01 00:00:00+00:00 - dtype: timestamp[us, tz=UTC][pyarrow] - - Returns: - A BigFrames series with the updated timezone. - """ - - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - @property def unit(self) -> str: """Returns the unit of time precision. @@ -536,6 +495,7 @@ 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 632026a3311..eba47fc1f96 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/base.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/base.py @@ -1,8 +1,8 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/indexes/base.py from __future__ import annotations -import typing from collections.abc import Hashable +import typing import bigframes from bigframes import constants @@ -32,6 +32,8 @@ 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,6 +63,8 @@ def values(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -82,6 +86,8 @@ def ndim(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -115,6 +121,8 @@ def size(self) -> int: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None For Series: @@ -148,6 +156,8 @@ 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 @@ -171,6 +181,8 @@ 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 @@ -194,6 +206,8 @@ 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']], @@ -232,6 +246,8 @@ def shape(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -252,6 +268,8 @@ 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 @@ -272,6 +290,8 @@ 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 @@ -293,6 +313,8 @@ 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) @@ -314,6 +336,8 @@ def dtype(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -340,6 +364,8 @@ def T(self) -> Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -366,36 +392,6 @@ 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, @@ -407,6 +403,8 @@ def copy( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['a', 'b', 'c']) >>> new_idx = idx.copy() @@ -440,6 +438,8 @@ def astype(self, dtype): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 2, 3]) >>> idx @@ -487,6 +487,8 @@ 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,6 +517,8 @@ def to_series(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['Ant', 'Bear', 'Cow'], name='animal') @@ -567,6 +571,8 @@ def isin(self, values): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1,2,3]) >>> idx @@ -605,6 +611,8 @@ def all(self) -> bool: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None True, because nonzero integers are considered True. @@ -631,6 +639,8 @@ 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()) @@ -655,6 +665,8 @@ def min(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.min()) @@ -675,6 +687,8 @@ def max(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([3, 2, 1]) >>> int(idx.max()) @@ -699,6 +713,8 @@ def argmin(self) -> int: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories @@ -734,6 +750,8 @@ def get_loc( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> unique_index = bpd.Index(list('abc')) >>> unique_index.get_loc('b') @@ -776,6 +794,8 @@ 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}) @@ -808,6 +828,8 @@ 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 @@ -828,11 +850,7 @@ def nunique(self) -> int: raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) def sort_values( - self, - *, - ascending: bool = True, - kind: str | None = None, - na_position: str = "last", + self, *, ascending: bool = True, na_position: str = "last" ) -> Index: """ Return a sorted copy of the index. @@ -842,6 +860,8 @@ def sort_values( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([10, 100, 1, 1000]) >>> idx @@ -855,10 +875,6 @@ def sort_values( Args: ascending (bool, default True): Should the index values be sorted in an ascending order. - kind (str, default None): - Choice of sorting algorithm. Accepts 'quicksort', 'mergesort', - 'heapsort', 'stable'. Ignored except when determining whether to - sort stably. 'mergesort' or 'stable' will result in stable reorder. na_position ({'first' or 'last'}, default 'last'): Argument 'first' puts NaNs at the beginning, 'last' puts NaNs at the end. @@ -888,6 +904,9 @@ 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() @@ -938,23 +957,17 @@ def value_counts( def fillna(self, value) -> Index: """ - Fill NA (NULL in BigQuery) values using the specified method. - - 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`. + Fill NA/NaN values with the specified value. **Examples:** - >>> 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') + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None + + >>> idx = bpd.Index([np.nan, np.nan, 3]) >>> idx.fillna(0) - Index([0.0, nan, 3.0, 0.0], dtype='Float64') + Index([0.0, 0.0, 3.0], dtype='Float64') Args: value (scalar): @@ -979,6 +992,8 @@ def rename(self, name, *, inplace): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index(['A', 'C', 'A', 'B'], name='score') >>> idx.rename('grade') @@ -1007,6 +1022,8 @@ 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']) @@ -1025,6 +1042,9 @@ 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() @@ -1051,6 +1071,7 @@ 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. @@ -1092,6 +1113,8 @@ def unique(self, level: Hashable | int | None = None): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([1, 1, 2, 3, 3]) >>> idx.unique() Index([1, 2, 3], dtype='Int64') @@ -1111,6 +1134,8 @@ def item(self, *args, **kwargs): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1], index=['a']) >>> s.index.item() 'a' diff --git a/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py index f22554e174d..105a376728c 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/datetimes.py @@ -15,6 +15,9 @@ def year(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.year @@ -28,6 +31,9 @@ def month(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.month @@ -41,6 +47,9 @@ def day(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.day @@ -54,6 +63,9 @@ def day_of_week(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.day_of_week @@ -67,6 +79,9 @@ def dayofweek(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.dayofweek @@ -80,6 +95,9 @@ def weekday(self) -> base.Index: **Examples:** + >>> import bigframes.pandas as bpd + >>> import pandas as pd + >>> bpd.options.display.progress_bar = None >>> idx = bpd.Index([pd.Timestamp("20250215")]) >>> idx.weekday diff --git a/third_party/bigframes_vendored/pandas/core/indexes/multi.py b/third_party/bigframes_vendored/pandas/core/indexes/multi.py index 018e638de35..a882aa40e37 100644 --- a/third_party/bigframes_vendored/pandas/core/indexes/multi.py +++ b/third_party/bigframes_vendored/pandas/core/indexes/multi.py @@ -25,6 +25,8 @@ 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')) @@ -60,6 +62,8 @@ 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/concat.py b/third_party/bigframes_vendored/pandas/core/reshape/concat.py index 0a6c4153bd7..685a73abc20 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/concat.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/concat.py @@ -2,7 +2,6 @@ """ Concat routines """ - from __future__ import annotations from bigframes import constants diff --git a/third_party/bigframes_vendored/pandas/core/reshape/encoding.py b/third_party/bigframes_vendored/pandas/core/reshape/encoding.py index 8d3b26a2a2b..31b2ba4a59b 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/encoding.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/encoding.py @@ -2,7 +2,6 @@ """ Encoding routines """ - from __future__ import annotations from bigframes import constants diff --git a/third_party/bigframes_vendored/pandas/core/reshape/merge.py b/third_party/bigframes_vendored/pandas/core/reshape/merge.py index 448b21819c0..66fb2c2160e 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/merge.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/merge.py @@ -2,7 +2,6 @@ """ SQL-style merge routines """ - from __future__ import annotations @@ -14,8 +13,6 @@ def merge( *, left_on=None, right_on=None, - left_index: bool = False, - right_index: bool = False, sort=False, suffixes=("_x", "_y"), ): @@ -64,10 +61,6 @@ 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 deleted file mode 100644 index 8cc33525a4b..00000000000 --- a/third_party/bigframes_vendored/pandas/core/reshape/pivot.py +++ /dev/null @@ -1,57 +0,0 @@ -# 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 546a65b73c2..fccaffdadf5 100644 --- a/third_party/bigframes_vendored/pandas/core/reshape/tile.py +++ b/third_party/bigframes_vendored/pandas/core/reshape/tile.py @@ -2,18 +2,17 @@ """ Quantilization functions and related routines """ - from __future__ import annotations import typing import pandas as pd -from bigframes import constants +from bigframes import constants, series def cut( - x, + x: series.Series, bins: typing.Union[ int, pd.IntervalIndex, @@ -35,6 +34,8 @@ def cut( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + >>> s = bpd.Series([0, 1, 5, 10]) >>> s 0 0 @@ -72,6 +73,7 @@ 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 @@ -111,7 +113,7 @@ def cut( dtype: struct[pyarrow] Args: - x (array-like): + x (bigframes.pandas.Series): The input Series to be binned. Must be 1-dimensional. bins (int, pd.IntervalIndex, Iterable): The criteria to bin by. diff --git a/third_party/bigframes_vendored/pandas/core/series.py b/third_party/bigframes_vendored/pandas/core/series.py index 183f36ef5a4..932959a8264 100644 --- a/third_party/bigframes_vendored/pandas/core/series.py +++ b/third_party/bigframes_vendored/pandas/core/series.py @@ -1,27 +1,24 @@ """ Data structure for 1-dimensional cross-sectional and time series data """ - from __future__ import annotations -import datetime from typing import ( - IO, - TYPE_CHECKING, Hashable, + IO, List, Literal, Mapping, Optional, Sequence, Tuple, + TYPE_CHECKING, Union, ) +from bigframes_vendored.pandas.core.generic import NDFrame import numpy import numpy as np -import pandas as pd -from bigframes_vendored.pandas.core.generic import NDFrame from pandas._typing import Axis, FilePath, NaPosition, WriteBuffer from pandas.api import extensions as pd_ext @@ -33,10 +30,6 @@ class Series(NDFrame): # type: ignore[misc] - """ - One-dimensional ndarray with axis labels (including time series). - """ - @property def dt(self): """ @@ -45,6 +38,9 @@ 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 @@ -114,6 +110,8 @@ 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. @@ -163,11 +161,13 @@ 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', pd.NA]) + >>> s = bpd.Series(['Alice', 'Bob', bpd.NA]) >>> s.shape (3,) """ @@ -180,6 +180,8 @@ def dtype(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.dtype @@ -198,6 +200,8 @@ def name(self) -> Hashable: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None For a Series: @@ -244,6 +248,8 @@ def hasnans(self) -> bool: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, None]) >>> s @@ -266,6 +272,8 @@ def T(self) -> Series: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -289,6 +297,8 @@ def transpose(self) -> Series: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['Ant', 'Bear', 'Cow']) >>> s @@ -327,6 +337,9 @@ 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']) @@ -427,6 +440,8 @@ 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() @@ -507,6 +522,8 @@ 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()) @@ -549,49 +566,6 @@ def to_markdown( """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_csv( - self, - path_or_buf=None, - sep=",", - *, - header: bool = True, - index: bool = True, - allow_large_results: Optional[bool] = None, - ) -> Optional[str]: - """ - Write object to a comma-separated values (csv) file. - - **Examples:** - - >>> import bigframes.pandas as bpd - - >>> s = bpd.Series([1,2,3], name='my_series') - >>> s.to_csv() - \',my_series\\n0,1\\n1,2\\n2,3\\n\' - - Args: - path_or_buf (str, path object, file-like object, or None, default None): - String, path object (implementing os.PathLike[str]), or file-like object - implementing a write() function. If None, the result is returned as a string. - If a non-binary file object is passed, it should be opened with newline='', - disabling universal newlines. If a binary file object is passed, - mode might need to contain a 'b'. - Must contain a wildcard character '*' if this is a GCS path. - sep (str, default ','): - String of length 1. Field delimiter for the output file. - header (bool, default True): - Write out 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: - If path_or_buf is None, returns the resulting csv format as a string. Otherwise returns None. - """ - raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) - def to_dict( self, into: type[dict] = dict, @@ -603,7 +577,9 @@ def to_dict( **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() @@ -641,6 +617,8 @@ 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") @@ -736,6 +714,8 @@ def tolist(self, *, allow_large_results: Optional[bool] = None) -> list: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -768,6 +748,9 @@ def to_numpy( **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() @@ -820,6 +803,8 @@ def to_pickle(self, path, *, allow_large_results=None, **kwargs): **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 @@ -880,6 +865,8 @@ def agg(self, func): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3, 4]) >>> s @@ -915,8 +902,10 @@ def count(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([0.0, 1.0, pd.NA]) + >>> s = bpd.Series([0.0, 1.0, bpd.NA]) >>> s 0 0.0 1 1.0 @@ -939,6 +928,8 @@ 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 @@ -972,6 +963,8 @@ 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 @@ -1013,6 +1006,8 @@ 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() @@ -1036,9 +1031,11 @@ 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 @@ -1104,6 +1101,7 @@ 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: @@ -1174,6 +1172,8 @@ 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']) @@ -1201,6 +1201,8 @@ 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']) @@ -1227,6 +1229,8 @@ 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 @@ -1258,6 +1262,8 @@ 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 @@ -1295,6 +1301,8 @@ 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]) @@ -1331,19 +1339,21 @@ 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]) - >>> float(s.autocorr()) # doctest: +ELLIPSIS - 0.1035526330902... + >>> s.autocorr() # doctest: +ELLIPSIS + np.float64(0.10355263309024067) - >>> float(s.autocorr(lag=2)) - -1.0 + >>> s.autocorr(lag=2) + np.float64(-1.0) If the Pearson correlation is not well defined, then 'NaN' is returned. >>> s = bpd.Series([1, 0, 0, 0]) - >>> float(s.autocorr()) - nan + >>> s.autocorr() + np.float64(nan) Args: lag (int, default 1): @@ -1367,6 +1377,8 @@ 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]) @@ -1394,6 +1406,8 @@ def diff(self) -> Series: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Difference with previous row @@ -1458,6 +1472,8 @@ 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]) @@ -1502,7 +1518,7 @@ def sort_values( axis: Axis = 0, inplace: bool = False, ascending: bool | int | Sequence[bool] | Sequence[int] = True, - kind: str | None = None, + kind: str = "quicksort", na_position: str = "last", ): """ @@ -1513,6 +1529,9 @@ 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 @@ -1579,7 +1598,7 @@ def sort_values( 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 None): + kind (str, default to 'quicksort'): Choice of sorting algorithm. Accepts quicksort', 'mergesort', 'heapsort', 'stable'. Ignored except when determining whether to sort stably. 'mergesort' or 'stable' will result in stable reorder @@ -1599,7 +1618,6 @@ def sort_index( axis: Axis = 0, inplace: bool = False, ascending: bool | Sequence[bool] = True, - kind: str | None = None, na_position: NaPosition = "last", ): """ @@ -1610,6 +1628,9 @@ 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() @@ -1647,10 +1668,6 @@ def sort_index( 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. - kind (str, default None): - Choice of sorting algorithm. Accepts 'quicksort', 'mergesort', - 'heapsort', 'stable'. Ignored except when determining whether to - sort stably. 'mergesort' or 'stable' will result in stable reorder. na_position ({'first', 'last'}, default 'last'): If 'first' puts NaNs at the beginning, 'last' puts NaNs at the end. Not implemented for MultiIndex. @@ -1673,6 +1690,8 @@ 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, @@ -1757,6 +1776,8 @@ 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, @@ -1842,47 +1863,16 @@ def apply( **Examples:** - 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 + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - 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. + 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. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def minutes_to_hours(x: int) -> float: ... return x/60 @@ -1895,8 +1885,8 @@ def apply( 4 120 dtype: Int64 - >>> hours = minutes.apply(minutes_to_hours) # doctest: +SKIP - >>> hours # doctest: +SKIP + >>> hours = minutes.apply(minutes_to_hours) + >>> hours 0 0.0 1 0.5 2 1.0 @@ -1908,7 +1898,7 @@ def apply( a `remote_function`, you would provide the names of the packages via `packages` param. - >>> @bpd.remote_function( # doctest: +SKIP + >>> @bpd.remote_function( ... reuse=False, ... packages=["cryptography"], ... cloud_function_service_account="default" @@ -1925,11 +1915,11 @@ def apply( ... return f.encrypt(input.encode()).decode() >>> names = bpd.Series(["Alice", "Bob"]) - >>> hashes = names.apply(get_hash) # doctest: +SKIP + >>> hashes = names.apply(get_hash) You could return an array output from the remote function. - >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def text_analyzer(text: str) -> list[int]: ... words = text.count(" ") + 1 ... periods = text.count(".") @@ -1942,13 +1932,46 @@ def apply( ... "I love this product! It's amazing.", ... "Hungry? Wanna eat? Lets go!" ... ]) - >>> features = texts.apply(text_analyzer) # doctest: +SKIP - >>> features # doctest: +SKIP + >>> features = texts.apply(text_analyzer) + >>> features 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 @@ -1982,10 +2005,13 @@ 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 @@ -2039,6 +2065,8 @@ def groupby( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can group by a named index level. @@ -2061,6 +2089,7 @@ 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"), @@ -2209,6 +2238,8 @@ 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 @@ -2225,6 +2256,7 @@ 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], @@ -2337,6 +2369,9 @@ 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. @@ -2375,30 +2410,26 @@ def fillna( value=None, ) -> Series | None: """ - Fill NA (NULL in BigQuery) values using the specified method. - - 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`. + Fill NA/NaN values using the specified method. **Examples:** - >>> s = bpd.Series( - ... pa.array([np.nan, 2, None, -1], type=pa.float64()), - ... dtype=pd.ArrowDtype(pa.float64()), - ... ) + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None + + >>> s = bpd.Series([np.nan, 2, np.nan, -1]) >>> s - 0 NaN + 0 1 2.0 2 3 -1.0 dtype: Float64 - Replace all NA (NULL) elements with 0s. + Replace all NA elements with 0s. >>> s.fillna(0) - 0 NaN + 0 0.0 1 2.0 2 0.0 3 -1.0 @@ -2408,7 +2439,7 @@ def fillna( >>> s_fill = bpd.Series([11, 22, 33]) >>> s.fillna(s_fill) - 0 NaN + 0 11.0 1 2.0 2 33.0 3 -1.0 @@ -2439,6 +2470,8 @@ 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 @@ -2557,75 +2590,15 @@ 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 - timestamp_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: @@ -2643,7 +2616,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', pd.NA, '', None, 'I stay'], dtype='object') + >>> ser = bpd.Series(['2', bpd.NA, '', None, 'I stay'], dtype='object') >>> ser 0 2 1 @@ -2687,6 +2660,9 @@ def between( **Examples:** + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None Boundary values are included by default: @@ -2743,6 +2719,9 @@ 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]) @@ -2810,6 +2789,9 @@ 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 @@ -2844,6 +2826,9 @@ 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 @@ -2884,6 +2869,9 @@ 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 @@ -2920,6 +2908,9 @@ 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 @@ -2954,6 +2945,9 @@ 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 @@ -2996,6 +2990,9 @@ 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 @@ -3040,6 +3037,9 @@ 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 @@ -3083,6 +3083,9 @@ 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 @@ -3127,6 +3130,9 @@ 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 @@ -3171,6 +3177,9 @@ 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 @@ -3214,8 +3223,10 @@ def add(self, other) -> Series: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> a = bpd.Series([1, 2, 3, pd.NA]) + >>> a = bpd.Series([1, 2, 3, bpd.NA]) >>> a 0 1 1 2 @@ -3276,6 +3287,8 @@ 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 @@ -3326,6 +3339,9 @@ 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 @@ -3388,6 +3404,9 @@ 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 @@ -3430,6 +3449,8 @@ 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 @@ -3480,6 +3501,9 @@ 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 @@ -3539,6 +3563,9 @@ 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 @@ -3582,6 +3609,8 @@ def __mul__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3620,6 +3649,9 @@ 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 @@ -3678,6 +3710,9 @@ 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 @@ -3721,6 +3756,8 @@ def __truediv__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can multiply with a scalar: @@ -3759,6 +3796,9 @@ 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 @@ -3818,6 +3858,9 @@ 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 @@ -3861,6 +3904,8 @@ def __floordiv__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can divide by a scalar: @@ -3899,6 +3944,9 @@ 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 @@ -3958,6 +4006,9 @@ 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 @@ -4001,6 +4052,8 @@ def __mod__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can modulo with a scalar: @@ -4038,6 +4091,9 @@ 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 @@ -4099,6 +4155,9 @@ 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 @@ -4131,7 +4190,6 @@ 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): @@ -4143,6 +4201,8 @@ def __pow__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None You can exponentiate with a scalar: @@ -4182,6 +4242,9 @@ 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 @@ -4241,6 +4304,9 @@ 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 @@ -4290,6 +4356,9 @@ 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 @@ -4342,6 +4411,9 @@ 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,6 +4453,10 @@ 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])) @@ -4406,7 +4482,7 @@ def update(self, other) -> None: 2 6 dtype: Int64 - If ``other`` contains NA (NULL values) the corresponding values are not updated + If ``other`` contains NaNs the corresponding values are not updated in the original Series. >>> s = bpd.Series([1, 2, 3]) @@ -4471,6 +4547,9 @@ 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. @@ -4504,6 +4583,8 @@ def max( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Calculating the max of a Series: @@ -4518,7 +4599,7 @@ def max( Calculating the max of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, pd.NA]) + >>> s = bpd.Series([1, 3, bpd.NA]) >>> s 0 1 1 3 @@ -4544,6 +4625,8 @@ def min( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Calculating the min of a Series: @@ -4558,7 +4641,7 @@ def min( Calculating the min of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, pd.NA]) + >>> s = bpd.Series([1, 3, bpd.NA]) >>> s 0 1 1 3 @@ -4583,6 +4666,8 @@ 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], @@ -4629,6 +4714,8 @@ def sum(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Calculating the sum of a Series: @@ -4643,7 +4730,7 @@ def sum(self): Calculating the sum of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, pd.NA]) + >>> s = bpd.Series([1, 3, bpd.NA]) >>> s 0 1 1 3 @@ -4663,6 +4750,8 @@ def mean(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Calculating the mean of a Series: @@ -4677,7 +4766,7 @@ def mean(self): Calculating the mean of a Series containing ``NA`` values: - >>> s = bpd.Series([1, 3, pd.NA]) + >>> s = bpd.Series([1, 3, bpd.NA]) >>> s 0 1 1 3 @@ -4698,6 +4787,8 @@ 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) @@ -4737,6 +4828,8 @@ 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) @@ -4787,6 +4880,8 @@ def describe(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['A', 'A', 'B']) >>> s @@ -4813,6 +4908,8 @@ def skew(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s.skew() @@ -4849,6 +4946,8 @@ 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 @@ -4890,6 +4989,9 @@ def item(self: Series, *args, **kwargs): **Examples:** + >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1]) >>> s.item() np.int64(1) @@ -4911,6 +5013,8 @@ 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(): @@ -4931,6 +5035,8 @@ 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 @@ -4997,6 +5103,9 @@ 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 @@ -5040,7 +5149,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, cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(reuse=False, cloud_function_service_account="default") ... def should_mask(name: str) -> bool: ... hash = 0 ... for char_ in name: @@ -5053,12 +5162,12 @@ def mask(self, cond, other): 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask) # doctest: +SKIP + >>> s.mask(should_mask) 0 1 Bob 2 Caroline dtype: string - >>> s.mask(should_mask, "REDACTED") # doctest: +SKIP + >>> s.mask(should_mask, "REDACTED") 0 REDACTED 1 Bob 2 Caroline @@ -5152,6 +5261,8 @@ def argmax(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5188,6 +5299,8 @@ def argmin(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Consider dataset containing cereal calories. @@ -5227,6 +5340,8 @@ def rename(self, index, *, inplace, **kwargs): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> s @@ -5277,6 +5392,8 @@ def rename_axis(self, mapper, *, inplace, **kwargs): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Series @@ -5340,8 +5457,10 @@ def value_counts( **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series([3, 1, 2, 3, 4, pd.NA], dtype="Int64") + >>> s = bpd.Series([3, 1, 2, 3, 4, bpd.NA], dtype="Int64") >>> s 0 3 @@ -5417,6 +5536,8 @@ 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 @@ -5444,13 +5565,15 @@ 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 Returns: - bigframes.pandas.api.typing.PlotAccessor: + bigframes.operations.plotting.PlotAccessor: An accessor making plots. """ raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) @@ -5469,6 +5592,8 @@ 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') @@ -5533,6 +5658,8 @@ 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 @@ -5555,6 +5682,8 @@ 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 @@ -5595,7 +5724,10 @@ def map( **Examples:** - >>> s = bpd.Series(['cat', 'dog', pd.NA, 'rabbit']) + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None + + >>> s = bpd.Series(['cat', 'dog', bpd.NA, 'rabbit']) >>> s 0 cat 1 dog @@ -5615,7 +5747,7 @@ def map( It also accepts a remote function: - >>> @bpd.remote_function(cloud_function_service_account="default") # doctest: +SKIP + >>> @bpd.remote_function(cloud_function_service_account="default") ... def my_mapper(val: str) -> str: ... vowels = ["a", "e", "i", "o", "u"] ... if val: @@ -5624,24 +5756,13 @@ def map( ... ]) ... return "N/A" - >>> s.map(my_mapper) # doctest: +SKIP + >>> s.map(my_mapper) 0 cAt 1 dOg 2 N/A 3 rAbbIt dtype: string - With experimental Python Transpiler enabled, you can use some lambda functions without - deploying them as remote functions: - - >>> bpd.options.experiments.enable_python_transpiler = True - >>> s.map(lambda val: val + "fish") - 0 catfish - 1 dogfish - 2 - 3 rabbitfish - dtype: string - Args: arg (function, Mapping, Series): remote function, collections.abc.Mapping subclass or Series @@ -5669,6 +5790,8 @@ 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}, @@ -5686,8 +5809,8 @@ def iloc(self): With a scalar integer. - >>> type(df.iloc[0]) # doctest: +ELLIPSIS - + >>> type(df.iloc[0]) + >>> df.iloc[0] a 1 @@ -5747,6 +5870,8 @@ 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'], @@ -5832,6 +5957,8 @@ 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']) @@ -5865,6 +5992,8 @@ 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']) @@ -5899,6 +6028,8 @@ 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]) @@ -5919,6 +6050,8 @@ def size(self) -> int: **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None For Series: @@ -5954,6 +6087,9 @@ def __array__(self, dtype=None, copy: Optional[bool] = 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]) @@ -5979,6 +6115,8 @@ def __len__(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([1, 2, 3]) >>> len(s) @@ -5993,6 +6131,8 @@ def __invert__(self): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> ser = bpd.Series([True, False, True]) >>> ~ser @@ -6012,6 +6152,8 @@ def __and__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6049,6 +6191,8 @@ def __or__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6086,6 +6230,8 @@ def __xor__(self, other): **Examples:** + >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([0, 1, 2, 3]) @@ -6123,6 +6269,8 @@ 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 9a72b98aee8..9b5b461ea5e 100644 --- a/third_party/bigframes_vendored/pandas/core/strings/accessor.py +++ b/third_party/bigframes_vendored/pandas/core/strings/accessor.py @@ -20,6 +20,7 @@ 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] @@ -53,6 +54,7 @@ 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`. @@ -113,6 +115,7 @@ 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("_") @@ -143,10 +146,11 @@ 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', '', pd.NA]) + >>> s = bpd.Series(['dog', '', bpd.NA]) >>> s.str.len() 0 3 1 0 @@ -168,6 +172,7 @@ def lower(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -192,6 +197,7 @@ 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 @@ -244,12 +250,13 @@ def strip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([ ... '1. Ant.', ... ' 2. Bee? ', ... '\\t3. Cat!\\n', - ... pd.NA, + ... bpd.NA, ... ]) >>> s.str.strip() 0 1. Ant. @@ -286,6 +293,7 @@ def upper(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -314,6 +322,7 @@ def isnumeric(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isnumeric() @@ -340,6 +349,7 @@ 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,6 +375,7 @@ 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() @@ -390,6 +401,7 @@ def isalnum(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s1 = bpd.Series(['one', 'one1', '1', '']) >>> s1.str.isalnum() @@ -427,6 +439,7 @@ def isspace(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series([' ', '\\t\\r\\n ', '']) >>> s.str.isspace() @@ -452,6 +465,7 @@ 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() @@ -478,6 +492,7 @@ 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() @@ -504,6 +519,7 @@ 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. @@ -534,8 +550,9 @@ def rstrip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', bpd.NA]) >>> s.str.rstrip() 0 Ant 1 Bee @@ -566,8 +583,9 @@ def lstrip(self, to_strip: typing.Optional[str] = None): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', pd.NA]) + >>> s = bpd.Series(['Ant', ' Bee ', '\tCat\n', bpd.NA]) >>> s.str.lstrip() 0 Ant 1 Bee @@ -593,6 +611,7 @@ def repeat(self, repeats: int): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['a', 'b', 'c']) >>> s @@ -626,6 +645,7 @@ def capitalize(self): **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(['lower', ... 'CAPITALS', @@ -653,6 +673,7 @@ 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. @@ -709,6 +730,7 @@ 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. @@ -812,12 +834,13 @@ 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', pd.NA]) + >>> s = bpd.Series(['foo', 'fuz', bpd.NA]) >>> s.str.replace('f.', 'ba', regex=True) 0 bao 1 baz @@ -827,7 +850,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', pd.NA]) + >>> s = bpd.Series(['f.o', 'fuz', bpd.NA]) >>> s.str.replace('f.', 'ba', regex=False) 0 bao 1 fuz @@ -873,8 +896,9 @@ def startswith( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'Bear', 'caT', pd.NA]) + >>> s = bpd.Series(['bat', 'Bear', 'caT', bpd.NA]) >>> s 0 bat 1 Bear @@ -917,8 +941,9 @@ def endswith( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['bat', 'bear', 'caT', pd.NA]) + >>> s = bpd.Series(['bat', 'bear', 'caT', bpd.NA]) >>> s 0 bat 1 bear @@ -962,6 +987,8 @@ def split( **Examples:** >>> import bigframes.pandas as bpd + >>> import numpy as np + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series( ... [ @@ -1004,6 +1031,7 @@ 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") @@ -1032,6 +1060,7 @@ 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.+') @@ -1063,6 +1092,7 @@ 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) @@ -1092,6 +1122,7 @@ def pad( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> s = bpd.Series(["caribou", "tiger"]) >>> s @@ -1139,6 +1170,7 @@ 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='.') @@ -1170,6 +1202,7 @@ 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='.') @@ -1205,8 +1238,9 @@ def zfill( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None - >>> s = bpd.Series(['-1', '1', '1000', pd.NA]) + >>> s = bpd.Series(['-1', '1', '1000', bpd.NA]) >>> s 0 -1 1 1 @@ -1244,6 +1278,7 @@ 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='.') @@ -1263,41 +1298,3 @@ 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 c5f9f8330f6..9c17b9632eb 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/datetimes.py +++ b/third_party/bigframes_vendored/pandas/core/tools/datetimes.py @@ -1,7 +1,5 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/core/tools/datetimes.py -from __future__ import annotations - from datetime import date, datetime from typing import List, Mapping, Tuple, Union @@ -40,6 +38,7 @@ def to_datetime( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None Converting a Scalar to datetime: diff --git a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py index 92cac856a59..9442e965fa0 100644 --- a/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py +++ b/third_party/bigframes_vendored/pandas/core/tools/timedeltas.py @@ -2,8 +2,8 @@ import typing -import pandas as pd from bigframes_vendored import constants +import pandas as pd from bigframes import series @@ -54,9 +54,11 @@ 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 7ca676fbe6d..a869c86e72a 100644 --- a/third_party/bigframes_vendored/pandas/core/window/rolling.py +++ b/third_party/bigframes_vendored/pandas/core/window/rolling.py @@ -37,52 +37,3 @@ 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/common.py b/third_party/bigframes_vendored/pandas/io/common.py index cab3c36cc9f..e186f02b5bd 100644 --- a/third_party/bigframes_vendored/pandas/io/common.py +++ b/third_party/bigframes_vendored/pandas/io/common.py @@ -1,6 +1,5 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/io/common.py """Common IO api utilities""" - from __future__ import annotations from collections import defaultdict diff --git a/third_party/bigframes_vendored/pandas/io/gbq.py b/third_party/bigframes_vendored/pandas/io/gbq.py index 242d2c50c8d..3dae2b6bbed 100644 --- a/third_party/bigframes_vendored/pandas/io/gbq.py +++ b/third_party/bigframes_vendored/pandas/io/gbq.py @@ -1,12 +1,12 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/io/gbq.py -"""Google BigQuery support""" +""" Google BigQuery support """ from __future__ import annotations from typing import Any, Dict, Iterable, Literal, Optional, Tuple, Union -import bigframes.enums from bigframes import constants +import bigframes.enums FilterOps = Literal["in", "not in", "<", "<=", "==", "!=", ">=", ">", "LIKE"] FilterType = Tuple[str, FilterOps, Any] @@ -25,7 +25,6 @@ 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. @@ -61,6 +60,7 @@ def read_gbq( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None If the input is a table ID: @@ -86,11 +86,9 @@ def read_gbq( ... WHERE year = 2016 ... GROUP BY pitcherFirstName, pitcherLastName ... ''', index_col="rowindex") - >>> print("START_OF_OUTPUT"); df.head(2) # doctest: +ELLIPSIS,+NORMALIZE_WHITESPACE - START_OF_OUTPUT - ... + >>> df.head(2) pitcherFirstName pitcherLastName averagePitchSpeed - ... + rowindex 1 Albertin Chapman 96.514113 2 Zachary Britton 94.591039 @@ -158,11 +156,6 @@ 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 cfb653481bf..aec911d2fec 100644 --- a/third_party/bigframes_vendored/pandas/io/parquet.py +++ b/third_party/bigframes_vendored/pandas/io/parquet.py @@ -1,6 +1,5 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/io/parquet.py -"""parquet compat""" - +""" parquet compat """ from __future__ import annotations from bigframes import constants @@ -28,6 +27,7 @@ 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 537974b5f32..4757f5ed9d3 100644 --- a/third_party/bigframes_vendored/pandas/io/parsers/readers.py +++ b/third_party/bigframes_vendored/pandas/io/parsers/readers.py @@ -4,13 +4,12 @@ GH#48849 provides a convenient way of deprecating keyword arguments """ - from __future__ import annotations from typing import ( - IO, Any, Dict, + IO, Literal, MutableSequence, Optional, @@ -21,8 +20,8 @@ import numpy as np -import bigframes.enums from bigframes import constants +import bigframes.enums class ReaderIOMixin: @@ -72,6 +71,7 @@ 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) @@ -192,6 +192,7 @@ 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 10ceab3c2fa..33088dc0196 100644 --- a/third_party/bigframes_vendored/pandas/io/pickle.py +++ b/third_party/bigframes_vendored/pandas/io/pickle.py @@ -1,6 +1,5 @@ # Contains code from https://github.com/pandas-dev/pandas/blob/main/pandas/io/pickle.py -"""pickle compat""" - +""" pickle compat """ from __future__ import annotations from pandas._typing import ( @@ -36,6 +35,7 @@ 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/pandas/_typing.py b/third_party/bigframes_vendored/pandas/pandas/_typing.py index 3640ba25163..e665339fc83 100644 --- a/third_party/bigframes_vendored/pandas/pandas/_typing.py +++ b/third_party/bigframes_vendored/pandas/pandas/_typing.py @@ -1,11 +1,10 @@ # Copied from https://github.com/pandas-dev/pandas/blob/main/pandas/_typing.py from __future__ import annotations -import sys from datetime import datetime, timedelta, tzinfo from os import PathLike +import sys from typing import ( - TYPE_CHECKING, Any, Callable, Dict, @@ -18,10 +17,9 @@ Protocol, Sequence, Tuple, - TypeVar, - Union, ) from typing import Type as type_t +from typing import TYPE_CHECKING, TypeVar, Union import numpy as np @@ -236,11 +234,13 @@ def flush(self) -> Any: class ReadPickleBuffer(ReadBuffer[bytes], Protocol): - def readline(self) -> bytes: ... + def readline(self) -> bytes: + ... class WriteExcelBuffer(WriteBuffer[bytes], Protocol): - def truncate(self, size: int | None = ...) -> int: ... + def truncate(self, size: int | None = ...) -> int: + ... class ReadCsvBuffer(ReadBuffer[AnyStr_co], Protocol): diff --git a/third_party/bigframes_vendored/pandas/plotting/_core.py b/third_party/bigframes_vendored/pandas/plotting/_core.py index 6c2aed970de..4ed5c8eb0b7 100644 --- a/third_party/bigframes_vendored/pandas/plotting/_core.py +++ b/third_party/bigframes_vendored/pandas/plotting/_core.py @@ -11,6 +11,7 @@ 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") @@ -56,6 +57,9 @@ 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) @@ -92,6 +96,7 @@ def line( **Examples:** >>> import bigframes.pandas as bpd + >>> bpd.options.display.progress_bar = None >>> df = bpd.DataFrame( ... { ... 'one': [1, 2, 3, 4], @@ -159,6 +164,7 @@ 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], @@ -227,6 +233,7 @@ 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) @@ -268,107 +275,6 @@ 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, @@ -390,6 +296,7 @@ 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/pandas/util/_validators.py b/third_party/bigframes_vendored/pandas/util/_validators.py index fe8c9b5d9c6..1f36e0d528c 100644 --- a/third_party/bigframes_vendored/pandas/util/_validators.py +++ b/third_party/bigframes_vendored/pandas/util/_validators.py @@ -3,7 +3,6 @@ Module that contains many useful utilities for validating data or function arguments """ - from __future__ import annotations from typing import TypeVar diff --git a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py index 2b1778eec8a..a7344d49d4f 100644 --- a/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py +++ b/third_party/bigframes_vendored/sklearn/cluster/_kmeans.py @@ -30,6 +30,7 @@ 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]}) @@ -115,26 +116,6 @@ 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/compose/_column_transformer.py b/third_party/bigframes_vendored/sklearn/compose/_column_transformer.py index e0c03536e09..e4e71c1ff9f 100644 --- a/third_party/bigframes_vendored/sklearn/compose/_column_transformer.py +++ b/third_party/bigframes_vendored/sklearn/compose/_column_transformer.py @@ -3,6 +3,7 @@ # Andreas Mueller # License: BSD + from abc import ABCMeta from bigframes_vendored.sklearn.base import BaseEstimator diff --git a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py index 0ce79995d0c..c3c3a77b718 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_mf.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_mf.py @@ -1,4 +1,5 @@ -"""Matrix Factorization.""" +""" Matrix Factorization. +""" # Author: Alexandre Gramfort # Olivier Grisel @@ -23,13 +24,14 @@ class MatrixFactorization(BaseEstimator, metaclass=ABCMeta): >>> import bigframes.pandas as bpd >>> from bigframes.ml.decomposition import MatrixFactorization + >>> bpd.options.display.progress_bar = None >>> 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) # doctest: +SKIP + >>> W = model.fit(X) Args: feedback_type ('explicit' | 'implicit'): @@ -93,23 +95,3 @@ def predict(self, X): 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 138b7772b24..f13c52bfb6c 100644 --- a/third_party/bigframes_vendored/sklearn/decomposition/_pca.py +++ b/third_party/bigframes_vendored/sklearn/decomposition/_pca.py @@ -1,4 +1,5 @@ -"""Principal Component Analysis.""" +""" Principal Component Analysis. +""" # Author: Alexandre Gramfort # Olivier Grisel @@ -23,6 +24,7 @@ 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 @@ -100,26 +102,6 @@ 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 175ad86b21b..42eab24c823 100644 --- a/third_party/bigframes_vendored/sklearn/impute/_base.py +++ b/third_party/bigframes_vendored/sklearn/impute/_base.py @@ -22,6 +22,7 @@ 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 7543edd10b7..21ba5a3bf89 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_base.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_base.py @@ -66,6 +66,7 @@ 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 a309166c90c..a85c6fae8dc 100644 --- a/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py +++ b/third_party/bigframes_vendored/sklearn/linear_model/_logistic.py @@ -11,6 +11,7 @@ # Arthur Mensch >> 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] + >>> 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] 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 085388b0456..c1a909e8490 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_classification.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_classification.py @@ -30,6 +30,7 @@ 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]) @@ -79,6 +80,7 @@ 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]) @@ -130,15 +132,16 @@ 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]) >>> recall_score = bigframes.ml.metrics.recall_score(y_true, y_pred, average=None) >>> recall_score - 0 1.0 - 1 0.0 - 2 0.0 - dtype: float64 + 0 1 + 1 0 + 2 0 + dtype: int64 Args: @@ -178,6 +181,7 @@ 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]) @@ -197,7 +201,7 @@ def precision_score( default='binary' This parameter is required for multiclass/multilabel targets. Possible values are 'None', 'micro', 'macro', 'samples', 'weighted', 'binary'. - Only None and 'binary' is supported. + Only average=None is supported. Returns: precision: float (if average is not None) or Series of float of shape \ @@ -228,6 +232,7 @@ 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 cd5bd2cbcd5..9262ffbd3d0 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_ranking.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_ranking.py @@ -33,6 +33,7 @@ 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]) @@ -88,6 +89,7 @@ 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]) @@ -137,6 +139,7 @@ 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 85f0c1ecf94..1c14e8068b5 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/_regression.py +++ b/third_party/bigframes_vendored/sklearn/metrics/_regression.py @@ -46,6 +46,7 @@ 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]) @@ -72,6 +73,7 @@ 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]) @@ -98,6 +100,7 @@ def mean_absolute_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]) diff --git a/third_party/bigframes_vendored/sklearn/metrics/pairwise.py b/third_party/bigframes_vendored/sklearn/metrics/pairwise.py index 37cbf23b29c..7584230be6b 100644 --- a/third_party/bigframes_vendored/sklearn/metrics/pairwise.py +++ b/third_party/bigframes_vendored/sklearn/metrics/pairwise.py @@ -7,8 +7,8 @@ # Joel Nothman # License: BSD 3 clause -import bigframes.pandas as bpd from bigframes import constants +import bigframes.pandas as bpd def paired_cosine_distances(X, Y) -> bpd.DataFrame: diff --git a/third_party/bigframes_vendored/sklearn/model_selection/_split.py b/third_party/bigframes_vendored/sklearn/model_selection/_split.py index 2398cbe77ca..ec16fa8cf95 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_split.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_split.py @@ -11,6 +11,7 @@ # Rodion Martynov # License: BSD 3 clause + from abc import ABCMeta from bigframes import constants @@ -68,6 +69,7 @@ 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) @@ -160,6 +162,7 @@ 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 6f840188534..b93c47ea04d 100644 --- a/third_party/bigframes_vendored/sklearn/model_selection/_validation.py +++ b/third_party/bigframes_vendored/sklearn/model_selection/_validation.py @@ -19,6 +19,7 @@ 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 1301ef329ab..5476a9fb3c0 100644 --- a/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py +++ b/third_party/bigframes_vendored/sklearn/preprocessing/_encoder.py @@ -25,6 +25,7 @@ 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"]}) @@ -84,6 +85,5 @@ def transform(self, X): Returns: bigframes.dataframe.DataFrame: The result is categorized as index: number, value: number, - where index is the position of the dict seeing the category, and value is 0 or 1. - """ + where index is the position of the dict seeing the category, and value is 0 or 1.""" raise NotImplementedError(constants.ABSTRACT_METHOD_ERROR_MESSAGE) diff --git a/third_party/bigframes_vendored/sqlglot/LICENSE b/third_party/bigframes_vendored/sqlglot/LICENSE deleted file mode 100644 index 72c4dbcc54f..00000000000 --- a/third_party/bigframes_vendored/sqlglot/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2025 Toby Mao - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/third_party/bigframes_vendored/sqlglot/__init__.py b/third_party/bigframes_vendored/sqlglot/__init__.py deleted file mode 100644 index 7369b9b444b..00000000000 --- a/third_party/bigframes_vendored/sqlglot/__init__.py +++ /dev/null @@ -1,189 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/__init__.py - -# ruff: noqa: F401 -""" -.. include:: ../README.md - ----- -""" - -from __future__ import annotations - -import logging -import typing as t - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect as Dialect # noqa: F401 -from bigframes_vendored.sqlglot.dialects.dialect import ( # noqa: F401 - Dialects as Dialects, -) -from bigframes_vendored.sqlglot.diff import diff as diff # noqa: F401 -from bigframes_vendored.sqlglot.errors import ErrorLevel as ErrorLevel -from bigframes_vendored.sqlglot.errors import ParseError as ParseError -from bigframes_vendored.sqlglot.errors import TokenError as TokenError # noqa: F401 -from bigframes_vendored.sqlglot.errors import ( # noqa: F401 - UnsupportedError as UnsupportedError, -) -from bigframes_vendored.sqlglot.expressions import ( # noqa: F401 - Expression as Expression, -) -from bigframes_vendored.sqlglot.expressions import alias_ as alias # noqa: F401 -from bigframes_vendored.sqlglot.expressions import and_ as and_ # noqa: F401 -from bigframes_vendored.sqlglot.expressions import case as case # noqa: F401 -from bigframes_vendored.sqlglot.expressions import cast as cast # noqa: F401 -from bigframes_vendored.sqlglot.expressions import column as column # noqa: F401 -from bigframes_vendored.sqlglot.expressions import condition as condition # noqa: F401 -from bigframes_vendored.sqlglot.expressions import delete as delete # noqa: F401 -from bigframes_vendored.sqlglot.expressions import except_ as except_ # noqa: F401 -from bigframes_vendored.sqlglot.expressions import ( # noqa: F401 - find_tables as find_tables, -) -from bigframes_vendored.sqlglot.expressions import from_ as from_ # noqa: F401 -from bigframes_vendored.sqlglot.expressions import func as func # noqa: F401 -from bigframes_vendored.sqlglot.expressions import insert as insert # noqa: F401 -from bigframes_vendored.sqlglot.expressions import intersect as intersect # noqa: F401 -from bigframes_vendored.sqlglot.expressions import ( # noqa: F401 - maybe_parse as maybe_parse, -) -from bigframes_vendored.sqlglot.expressions import merge as merge # noqa: F401 -from bigframes_vendored.sqlglot.expressions import not_ as not_ # noqa: F401 -from bigframes_vendored.sqlglot.expressions import or_ as or_ # noqa: F401 -from bigframes_vendored.sqlglot.expressions import select as select # noqa: F401 -from bigframes_vendored.sqlglot.expressions import subquery as subquery # noqa: F401 -from bigframes_vendored.sqlglot.expressions import table_ as table # noqa: F401 -from bigframes_vendored.sqlglot.expressions import to_column as to_column # noqa: F401 -from bigframes_vendored.sqlglot.expressions import ( # noqa: F401 - to_identifier as to_identifier, -) -from bigframes_vendored.sqlglot.expressions import to_table as to_table # noqa: F401 -from bigframes_vendored.sqlglot.expressions import union as union # noqa: F401 -from bigframes_vendored.sqlglot.generator import Generator as Generator # noqa: F401 -from bigframes_vendored.sqlglot.parser import Parser as Parser # noqa: F401 -from bigframes_vendored.sqlglot.schema import ( # noqa: F401 - MappingSchema as MappingSchema, -) -from bigframes_vendored.sqlglot.schema import Schema as Schema # noqa: F401 -from bigframes_vendored.sqlglot.tokens import Token as Token # noqa: F401 -from bigframes_vendored.sqlglot.tokens import Tokenizer as Tokenizer # noqa: F401 -from bigframes_vendored.sqlglot.tokens import TokenType as TokenType # noqa: F401 - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - from bigframes_vendored.sqlglot.dialects.dialect import DialectType as DialectType - -logger = logging.getLogger("sqlglot") - - -pretty = False -"""Whether to format generated SQL by default.""" - - -def tokenize( - sql: str, read: DialectType = None, dialect: DialectType = None -) -> t.List[Token]: - """ - Tokenizes the given SQL string. - - Args: - sql: the SQL code string to tokenize. - read: the SQL dialect to apply during tokenizing (eg. "spark", "hive", "presto", "mysql"). - dialect: the SQL dialect (alias for read). - - Returns: - The resulting list of tokens. - """ - return Dialect.get_or_raise(read or dialect).tokenize(sql) - - -def parse( - sql: str, read: DialectType = None, dialect: DialectType = None, **opts -) -> t.List[t.Optional[Expression]]: - """ - Parses the given SQL string into a collection of syntax trees, one per parsed SQL statement. - - Args: - sql: the SQL code string to parse. - read: the SQL dialect to apply during parsing (eg. "spark", "hive", "presto", "mysql"). - dialect: the SQL dialect (alias for read). - **opts: other `sqlglot.parser.Parser` options. - - Returns: - The resulting syntax tree collection. - """ - return Dialect.get_or_raise(read or dialect).parse(sql, **opts) - - -@t.overload -def parse_one(sql: str, *, into: t.Type[E], **opts) -> E: ... - - -@t.overload -def parse_one(sql: str, **opts) -> Expression: ... - - -def parse_one( - sql: str, - read: DialectType = None, - dialect: DialectType = None, - into: t.Optional[exp.IntoType] = None, - **opts, -) -> Expression: - """ - Parses the given SQL string and returns a syntax tree for the first parsed SQL statement. - - Args: - sql: the SQL code string to parse. - read: the SQL dialect to apply during parsing (eg. "spark", "hive", "presto", "mysql"). - dialect: the SQL dialect (alias for read) - into: the SQLGlot Expression to parse into. - **opts: other `sqlglot.parser.Parser` options. - - Returns: - The syntax tree for the first parsed statement. - """ - - dialect = Dialect.get_or_raise(read or dialect) - - if into: - result = dialect.parse_into(into, sql, **opts) - else: - result = dialect.parse(sql, **opts) - - for expression in result: - if not expression: - raise ParseError(f"No expression was parsed from '{sql}'") - return expression - else: - raise ParseError(f"No expression was parsed from '{sql}'") - - -def transpile( - sql: str, - read: DialectType = None, - write: DialectType = None, - identity: bool = True, - error_level: t.Optional[ErrorLevel] = None, - **opts, -) -> t.List[str]: - """ - Parses the given SQL string in accordance with the source dialect and returns a list of SQL strings transformed - to conform to the target dialect. Each string in the returned list represents a single transformed SQL statement. - - Args: - sql: the SQL code string to transpile. - read: the source dialect used to parse the input string (eg. "spark", "hive", "presto", "mysql"). - write: the target dialect into which the input should be transformed (eg. "spark", "hive", "presto", "mysql"). - identity: if set to `True` and if the target dialect is not specified the source dialect will be used as both: - the source and the target dialect. - error_level: the desired error level of the parser. - **opts: other `sqlglot.generator.Generator` options. - - Returns: - The list of transpiled SQL statements. - """ - write = (read if write is None else write) if identity else write - write = Dialect.get_or_raise(write) - return [ - write.generate(expression, copy=False, **opts) if expression else "" - for expression in parse(sql, read, error_level=error_level) - ] diff --git a/third_party/bigframes_vendored/sqlglot/dialects/__init__.py b/third_party/bigframes_vendored/sqlglot/dialects/__init__.py deleted file mode 100644 index 78285be445a..00000000000 --- a/third_party/bigframes_vendored/sqlglot/dialects/__init__.py +++ /dev/null @@ -1,99 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/dialects/__init__.py - -# ruff: noqa: F401 -""" -## Dialects - -While there is a SQL standard, most SQL engines support a variation of that standard. This makes it difficult -to write portable SQL code. SQLGlot bridges all the different variations, called "dialects", with an extensible -SQL transpilation framework. - -The base `sqlglot.dialects.dialect.Dialect` class implements a generic dialect that aims to be as universal as possible. - -Each SQL variation has its own `Dialect` subclass, extending the corresponding `Tokenizer`, `Parser` and `Generator` -classes as needed. - -### Implementing a custom Dialect - -Creating a new SQL dialect may seem complicated at first, but it is actually quite simple in SQLGlot: - -```python -from sqlglot import exp -from sqlglot.dialects.dialect import Dialect -from sqlglot.generator import Generator -from sqlglot.tokens import Tokenizer, TokenType - - -class Custom(Dialect): - class Tokenizer(Tokenizer): - QUOTES = ["'", '"'] # Strings can be delimited by either single or double quotes - IDENTIFIERS = ["`"] # Identifiers can be delimited by backticks - - # Associates certain meaningful words with tokens that capture their intent - KEYWORDS = { - **Tokenizer.KEYWORDS, - "INT64": TokenType.BIGINT, - "FLOAT64": TokenType.DOUBLE, - } - - class Generator(Generator): - # Specifies how AST nodes, i.e. subclasses of exp.Expression, should be converted into SQL - TRANSFORMS = { - exp.Array: lambda self, e: f"[{self.expressions(e)}]", - } - - # Specifies how AST nodes representing data types should be converted into SQL - TYPE_MAPPING = { - exp.DataType.Type.TINYINT: "INT64", - exp.DataType.Type.SMALLINT: "INT64", - exp.DataType.Type.INT: "INT64", - exp.DataType.Type.BIGINT: "INT64", - exp.DataType.Type.DECIMAL: "NUMERIC", - exp.DataType.Type.FLOAT: "FLOAT64", - exp.DataType.Type.DOUBLE: "FLOAT64", - exp.DataType.Type.BOOLEAN: "BOOL", - exp.DataType.Type.TEXT: "STRING", - } -``` - -The above example demonstrates how certain parts of the base `Dialect` class can be overridden to match a different -specification. Even though it is a fairly realistic starting point, we strongly encourage the reader to study existing -dialect implementations in order to understand how their various components can be modified, depending on the use-case. - ----- -""" - -import importlib -import threading - -DIALECTS = [ - "BigQuery", -] - -MODULE_BY_DIALECT = {name: name.lower() for name in DIALECTS} -DIALECT_MODULE_NAMES = MODULE_BY_DIALECT.values() - -MODULE_BY_ATTRIBUTE = { - **MODULE_BY_DIALECT, - "Dialect": "dialect", - "Dialects": "dialect", -} - -__all__ = list(MODULE_BY_ATTRIBUTE) - -# We use a reentrant lock because a dialect may depend on (i.e., import) other dialects. -# Without it, the first dialect import would never be completed, because subsequent -# imports would be blocked on the lock held by the first import. -_import_lock = threading.RLock() - - -def __getattr__(name): - module_name = MODULE_BY_ATTRIBUTE.get(name) - if module_name: - with _import_lock: - module = importlib.import_module( - f"bigframes_vendored.sqlglot.dialects.{module_name}" - ) - return getattr(module, name) - - raise AttributeError(f"module {__name__} has no attribute {name}") diff --git a/third_party/bigframes_vendored/sqlglot/dialects/bigquery.py b/third_party/bigframes_vendored/sqlglot/dialects/bigquery.py deleted file mode 100644 index 7da30231ff4..00000000000 --- a/third_party/bigframes_vendored/sqlglot/dialects/bigquery.py +++ /dev/null @@ -1,1681 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/dialects/bigquery.py - -from __future__ import annotations - -import logging -import re -import typing as t - -from bigframes_vendored.sqlglot import ( - exp, - generator, - jsonpath, - parser, - tokens, - transforms, -) -from bigframes_vendored.sqlglot.dialects.dialect import ( - Dialect, - NormalizationStrategy, - arg_max_or_min_no_count, - binary_from_function, - build_date_delta_with_interval, - build_formatted_time, - date_add_interval_sql, - datestrtodate_sql, - filter_array_using_unnest, - groupconcat_sql, - if_sql, - inline_array_unless_query, - max_or_greatest, - min_or_least, - no_ilike_sql, - regexp_replace_sql, - rename_func, - sha2_digest_sql, - sha256_sql, - strposition_sql, - timestrtotime_sql, - ts_or_ds_add_cast, - unit_to_var, -) -from bigframes_vendored.sqlglot.expressions import Expression as E -from bigframes_vendored.sqlglot.generator import unsupported_args -from bigframes_vendored.sqlglot.helper import seq_get, split_num_words -from bigframes_vendored.sqlglot.optimizer.annotate_types import TypeAnnotator -from bigframes_vendored.sqlglot.tokens import TokenType -from bigframes_vendored.sqlglot.typing.bigquery import EXPRESSION_METADATA - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import Lit - -logger = logging.getLogger("sqlglot") - - -JSON_EXTRACT_TYPE = t.Union[ - exp.JSONExtract, exp.JSONExtractScalar, exp.JSONExtractArray -] - -DQUOTES_ESCAPING_JSON_FUNCTIONS = ("JSON_QUERY", "JSON_VALUE", "JSON_QUERY_ARRAY") - -MAKE_INTERVAL_KWARGS = ["year", "month", "day", "hour", "minute", "second"] - - -def _derived_table_values_to_unnest( - self: BigQuery.Generator, expression: exp.Values -) -> str: - if not expression.find_ancestor(exp.From, exp.Join): - return self.values_sql(expression) - - structs = [] - alias = expression.args.get("alias") - for tup in expression.find_all(exp.Tuple): - field_aliases = ( - alias.columns - if alias and alias.columns - else (f"_c{i}" for i in range(len(tup.expressions))) - ) - expressions = [ - exp.PropertyEQ(this=exp.to_identifier(name), expression=fld) - for name, fld in zip(field_aliases, tup.expressions) - ] - structs.append(exp.Struct(expressions=expressions)) - - # Due to `UNNEST_COLUMN_ONLY`, it is expected that the table alias be contained in the columns expression - alias_name_only = exp.TableAlias(columns=[alias.this]) if alias else None - return self.unnest_sql( - exp.Unnest(expressions=[exp.array(*structs, copy=False)], alias=alias_name_only) - ) - - -def _returnsproperty_sql( - self: BigQuery.Generator, expression: exp.ReturnsProperty -) -> str: - this = expression.this - if isinstance(this, exp.Schema): - this = f"{self.sql(this, 'this')} <{self.expressions(this)}>" - else: - this = self.sql(this) - return f"RETURNS {this}" - - -def _create_sql(self: BigQuery.Generator, expression: exp.Create) -> str: - returns = expression.find(exp.ReturnsProperty) - if expression.kind == "FUNCTION" and returns and returns.args.get("is_table"): - expression.set("kind", "TABLE FUNCTION") - - if isinstance(expression.expression, (exp.Subquery, exp.Literal)): - expression.set("expression", expression.expression.this) - - return self.create_sql(expression) - - -# https://issuetracker.google.com/issues/162294746 -# workaround for bigquery bug when grouping by an expression and then ordering -# WITH x AS (SELECT 1 y) -# SELECT y + 1 z -# FROM x -# GROUP BY x + 1 -# ORDER by z -def _alias_ordered_group(expression: exp.Expression) -> exp.Expression: - if isinstance(expression, exp.Select): - group = expression.args.get("group") - order = expression.args.get("order") - - if group and order: - aliases = { - select.this: select.args["alias"] - for select in expression.selects - if isinstance(select, exp.Alias) - } - - for grouped in group.expressions: - if grouped.is_int: - continue - alias = aliases.get(grouped) - if alias: - grouped.replace(exp.column(alias)) - - return expression - - -def _pushdown_cte_column_names(expression: exp.Expression) -> exp.Expression: - """BigQuery doesn't allow column names when defining a CTE, so we try to push them down.""" - if isinstance(expression, exp.CTE) and expression.alias_column_names: - cte_query = expression.this - - if cte_query.is_star: - logger.warning( - "Can't push down CTE column names for star queries. Run the query through" - " the optimizer or use 'qualify' to expand the star projections first." - ) - return expression - - column_names = expression.alias_column_names - expression.args["alias"].set("columns", None) - - for name, select in zip(column_names, cte_query.selects): - to_replace = select - - if isinstance(select, exp.Alias): - select = select.this - - # Inner aliases are shadowed by the CTE column names - to_replace.replace(exp.alias_(select, name)) - - return expression - - -def _build_parse_timestamp(args: t.List) -> exp.StrToTime: - this = build_formatted_time(exp.StrToTime, "bigquery")( - [seq_get(args, 1), seq_get(args, 0)] - ) - this.set("zone", seq_get(args, 2)) - return this - - -def _build_timestamp(args: t.List) -> exp.Timestamp: - timestamp = exp.Timestamp.from_arg_list(args) - timestamp.set("with_tz", True) - return timestamp - - -def _build_date(args: t.List) -> exp.Date | exp.DateFromParts: - expr_type = exp.DateFromParts if len(args) == 3 else exp.Date - return expr_type.from_arg_list(args) - - -def _build_to_hex(args: t.List) -> exp.Hex | exp.MD5: - # TO_HEX(MD5(..)) is common in BigQuery, so it's parsed into MD5 to simplify its transpilation - arg = seq_get(args, 0) - return ( - exp.MD5(this=arg.this) - if isinstance(arg, exp.MD5Digest) - else exp.LowerHex(this=arg) - ) - - -def _build_json_strip_nulls(args: t.List) -> exp.JSONStripNulls: - expression = exp.JSONStripNulls(this=seq_get(args, 0)) - - for arg in args[1:]: - if isinstance(arg, exp.Kwarg): - expression.set(arg.this.name.lower(), arg) - else: - expression.set("expression", arg) - - return expression - - -def _array_contains_sql(self: BigQuery.Generator, expression: exp.ArrayContains) -> str: - return self.sql( - exp.Exists( - this=exp.select("1") - .from_( - exp.Unnest(expressions=[expression.left]).as_("_unnest", table=["_col"]) - ) - .where(exp.column("_col").eq(expression.right)) - ) - ) - - -def _ts_or_ds_add_sql(self: BigQuery.Generator, expression: exp.TsOrDsAdd) -> str: - return date_add_interval_sql("DATE", "ADD")(self, ts_or_ds_add_cast(expression)) - - -def _ts_or_ds_diff_sql(self: BigQuery.Generator, expression: exp.TsOrDsDiff) -> str: - expression.this.replace(exp.cast(expression.this, exp.DataType.Type.TIMESTAMP)) - expression.expression.replace( - exp.cast(expression.expression, exp.DataType.Type.TIMESTAMP) - ) - unit = unit_to_var(expression) - return self.func("DATE_DIFF", expression.this, expression.expression, unit) - - -def _unix_to_time_sql(self: BigQuery.Generator, expression: exp.UnixToTime) -> str: - scale = expression.args.get("scale") - timestamp = expression.this - - if scale in (None, exp.UnixToTime.SECONDS): - return self.func("TIMESTAMP_SECONDS", timestamp) - if scale == exp.UnixToTime.MILLIS: - return self.func("TIMESTAMP_MILLIS", timestamp) - if scale == exp.UnixToTime.MICROS: - return self.func("TIMESTAMP_MICROS", timestamp) - - unix_seconds = exp.cast( - exp.Div(this=timestamp, expression=exp.func("POW", 10, scale)), - exp.DataType.Type.BIGINT, - ) - return self.func("TIMESTAMP_SECONDS", unix_seconds) - - -def _build_time(args: t.List) -> exp.Func: - if len(args) == 1: - return exp.TsOrDsToTime(this=args[0]) - if len(args) == 2: - return exp.Time.from_arg_list(args) - return exp.TimeFromParts.from_arg_list(args) - - -def _build_datetime(args: t.List) -> exp.Func: - if len(args) == 1: - return exp.TsOrDsToDatetime.from_arg_list(args) - if len(args) == 2: - return exp.Datetime.from_arg_list(args) - return exp.TimestampFromParts.from_arg_list(args) - - -def build_date_diff(args: t.List) -> exp.Expression: - expr = exp.DateDiff( - this=seq_get(args, 0), - expression=seq_get(args, 1), - unit=seq_get(args, 2), - date_part_boundary=True, - ) - - # Normalize plain WEEK to WEEK(SUNDAY) to preserve the semantic in the AST to facilitate transpilation - # This is done post exp.DateDiff construction since the TimeUnit mixin performs canonicalizations in its constructor too - unit = expr.args.get("unit") - - if isinstance(unit, exp.Var) and unit.name.upper() == "WEEK": - expr.set("unit", exp.WeekStart(this=exp.var("SUNDAY"))) - - return expr - - -def _build_regexp_extract( - expr_type: t.Type[E], default_group: t.Optional[exp.Expression] = None -) -> t.Callable[[t.List, BigQuery], E]: - def _builder(args: t.List, dialect: BigQuery) -> E: - try: - group = re.compile(args[1].name).groups == 1 - except re.error: - group = False - - # Default group is used for the transpilation of REGEXP_EXTRACT_ALL - return expr_type( - this=seq_get(args, 0), - expression=seq_get(args, 1), - position=seq_get(args, 2), - occurrence=seq_get(args, 3), - group=exp.Literal.number(1) if group else default_group, - **( - { - "null_if_pos_overflow": dialect.REGEXP_EXTRACT_POSITION_OVERFLOW_RETURNS_NULL - } - if expr_type is exp.RegexpExtract - else {} - ), - ) - - return _builder - - -def _build_extract_json_with_default_path( - expr_type: t.Type[E], -) -> t.Callable[[t.List, Dialect], E]: - def _builder(args: t.List, dialect: Dialect) -> E: - if len(args) == 1: - # The default value for the JSONPath is '$' i.e all of the data - args.append(exp.Literal.string("$")) - return parser.build_extract_json_with_path(expr_type)(args, dialect) - - return _builder - - -def _str_to_datetime_sql( - self: BigQuery.Generator, expression: exp.StrToDate | exp.StrToTime -) -> str: - this = self.sql(expression, "this") - dtype = "DATE" if isinstance(expression, exp.StrToDate) else "TIMESTAMP" - - if expression.args.get("safe"): - fmt = self.format_time( - expression, - self.dialect.INVERSE_FORMAT_MAPPING, - self.dialect.INVERSE_FORMAT_TRIE, - ) - return f"SAFE_CAST({this} AS {dtype} FORMAT {fmt})" - - fmt = self.format_time(expression) - return self.func(f"PARSE_{dtype}", fmt, this, expression.args.get("zone")) - - -@unsupported_args("ins_cost", "del_cost", "sub_cost") -def _levenshtein_sql(self: BigQuery.Generator, expression: exp.Levenshtein) -> str: - max_dist = expression.args.get("max_dist") - if max_dist: - max_dist = exp.Kwarg(this=exp.var("max_distance"), expression=max_dist) - - return self.func("EDIT_DISTANCE", expression.this, expression.expression, max_dist) - - -def _build_levenshtein(args: t.List) -> exp.Levenshtein: - max_dist = seq_get(args, 2) - return exp.Levenshtein( - this=seq_get(args, 0), - expression=seq_get(args, 1), - max_dist=max_dist.expression if max_dist else None, - ) - - -def _build_format_time( - expr_type: t.Type[exp.Expression], -) -> t.Callable[[t.List], exp.TimeToStr]: - def _builder(args: t.List) -> exp.TimeToStr: - formatted_time = build_formatted_time(exp.TimeToStr, "bigquery")( - [expr_type(this=seq_get(args, 1)), seq_get(args, 0)] - ) - formatted_time.set("zone", seq_get(args, 2)) - return formatted_time - - return _builder - - -def _build_contains_substring(args: t.List) -> exp.Contains: - # Lowercase the operands in case of transpilation, as exp.Contains - # is case-sensitive on other dialects - this = exp.Lower(this=seq_get(args, 0)) - expr = exp.Lower(this=seq_get(args, 1)) - - return exp.Contains(this=this, expression=expr, json_scope=seq_get(args, 2)) - - -def _json_extract_sql(self: BigQuery.Generator, expression: JSON_EXTRACT_TYPE) -> str: - name = (expression._meta and expression.meta.get("name")) or expression.sql_name() - upper = name.upper() - - dquote_escaping = upper in DQUOTES_ESCAPING_JSON_FUNCTIONS - - if dquote_escaping: - self._quote_json_path_key_using_brackets = False - - sql = rename_func(upper)(self, expression) - - if dquote_escaping: - self._quote_json_path_key_using_brackets = True - - return sql - - -class BigQuery(Dialect): - WEEK_OFFSET = -1 - UNNEST_COLUMN_ONLY = True - SUPPORTS_USER_DEFINED_TYPES = False - SUPPORTS_SEMI_ANTI_JOIN = False - LOG_BASE_FIRST = False - HEX_LOWERCASE = True - FORCE_EARLY_ALIAS_REF_EXPANSION = True - EXPAND_ONLY_GROUP_ALIAS_REF = True - PRESERVE_ORIGINAL_NAMES = True - HEX_STRING_IS_INTEGER_TYPE = True - BYTE_STRING_IS_BYTES_TYPE = True - UUID_IS_STRING_TYPE = True - ANNOTATE_ALL_SCOPES = True - PROJECTION_ALIASES_SHADOW_SOURCE_NAMES = True - TABLES_REFERENCEABLE_AS_COLUMNS = True - SUPPORTS_STRUCT_STAR_EXPANSION = True - EXCLUDES_PSEUDOCOLUMNS_FROM_STAR = True - QUERY_RESULTS_ARE_STRUCTS = True - JSON_EXTRACT_SCALAR_SCALAR_ONLY = True - LEAST_GREATEST_IGNORES_NULLS = False - DEFAULT_NULL_TYPE = exp.DataType.Type.BIGINT - PRIORITIZE_NON_LITERAL_TYPES = True - - # https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#initcap - INITCAP_DEFAULT_DELIMITER_CHARS = ' \t\n\r\f\v\\[\\](){}/|<>!?@"^#$&~_,.:;*%+\\-' - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#case_sensitivity - NORMALIZATION_STRATEGY = NormalizationStrategy.CASE_INSENSITIVE - - # bigquery udfs are case sensitive - NORMALIZE_FUNCTIONS = False - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/format-elements#format_elements_date_time - TIME_MAPPING = { - "%x": "%m/%d/%y", - "%D": "%m/%d/%y", - "%E6S": "%S.%f", - "%e": "%-d", - "%F": "%Y-%m-%d", - "%T": "%H:%M:%S", - "%c": "%a %b %e %H:%M:%S %Y", - } - - INVERSE_TIME_MAPPING = { - # Preserve %E6S instead of expanding to %T.%f - since both %E6S & %T.%f are semantically different in BigQuery - # %E6S is semantically different from %T.%f: %E6S works as a single atomic specifier for seconds with microseconds, while %T.%f expands incorrectly and fails to parse. - "%H:%M:%S.%f": "%H:%M:%E6S", - } - - FORMAT_MAPPING = { - "DD": "%d", - "MM": "%m", - "MON": "%b", - "MONTH": "%B", - "YYYY": "%Y", - "YY": "%y", - "HH": "%I", - "HH12": "%I", - "HH24": "%H", - "MI": "%M", - "SS": "%S", - "SSSSS": "%f", - "TZH": "%z", - } - - # The _PARTITIONTIME and _PARTITIONDATE pseudo-columns are not returned by a SELECT * statement - # https://cloud.google.com/bigquery/docs/querying-partitioned-tables#query_an_ingestion-time_partitioned_table - # https://cloud.google.com/bigquery/docs/querying-wildcard-tables#scanning_a_range_of_tables_using_table_suffix - # https://cloud.google.com/bigquery/docs/query-cloud-storage-data#query_the_file_name_pseudo-column - PSEUDOCOLUMNS = { - "_PARTITIONTIME", - "_PARTITIONDATE", - "_TABLE_SUFFIX", - "_FILE_NAME", - "_DBT_MAX_PARTITION", - } - - # All set operations require either a DISTINCT or ALL specifier - SET_OP_DISTINCT_BY_DEFAULT = dict.fromkeys( - (exp.Except, exp.Intersect, exp.Union), None - ) - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/navigation_functions#percentile_cont - COERCES_TO = { - **TypeAnnotator.COERCES_TO, - exp.DataType.Type.BIGDECIMAL: {exp.DataType.Type.DOUBLE}, - } - COERCES_TO[exp.DataType.Type.DECIMAL] |= {exp.DataType.Type.BIGDECIMAL} - COERCES_TO[exp.DataType.Type.BIGINT] |= {exp.DataType.Type.BIGDECIMAL} - COERCES_TO[exp.DataType.Type.VARCHAR] |= { - exp.DataType.Type.DATE, - exp.DataType.Type.DATETIME, - exp.DataType.Type.TIME, - exp.DataType.Type.TIMESTAMP, - exp.DataType.Type.TIMESTAMPTZ, - } - - EXPRESSION_METADATA = EXPRESSION_METADATA.copy() - - def normalize_identifier(self, expression: E) -> E: - if ( - isinstance(expression, exp.Identifier) - and self.normalization_strategy is NormalizationStrategy.CASE_INSENSITIVE - ): - parent = expression.parent - while isinstance(parent, exp.Dot): - parent = parent.parent - - # In BigQuery, CTEs are case-insensitive, but UDF and table names are case-sensitive - # by default. The following check uses a heuristic to detect tables based on whether - # they are qualified. This should generally be correct, because tables in BigQuery - # must be qualified with at least a dataset, unless @@dataset_id is set. - case_sensitive = ( - isinstance(parent, exp.UserDefinedFunction) - or ( - isinstance(parent, exp.Table) - and parent.db - and ( - parent.meta.get("quoted_table") - or not parent.meta.get("maybe_column") - ) - ) - or expression.meta.get("is_table") - ) - if not case_sensitive: - expression.set("this", expression.this.lower()) - - return t.cast(E, expression) - - return super().normalize_identifier(expression) - - class JSONPathTokenizer(jsonpath.JSONPathTokenizer): - VAR_TOKENS = { - TokenType.DASH, - TokenType.VAR, - } - - class Tokenizer(tokens.Tokenizer): - QUOTES = ["'", '"', '"""', "'''"] - COMMENTS = ["--", "#", ("/*", "*/")] - IDENTIFIERS = ["`"] - STRING_ESCAPES = ["\\"] - - HEX_STRINGS = [("0x", ""), ("0X", "")] - - BYTE_STRINGS = [ - (prefix + q, q) - for q in t.cast(t.List[str], QUOTES) - for prefix in ("b", "B") - ] - - RAW_STRINGS = [ - (prefix + q, q) - for q in t.cast(t.List[str], QUOTES) - for prefix in ("r", "R") - ] - - NESTED_COMMENTS = False - - KEYWORDS = { - **tokens.Tokenizer.KEYWORDS, - "ANY TYPE": TokenType.VARIANT, - "BEGIN": TokenType.COMMAND, - "BEGIN TRANSACTION": TokenType.BEGIN, - "BYTEINT": TokenType.INT, - "BYTES": TokenType.BINARY, - "CURRENT_DATETIME": TokenType.CURRENT_DATETIME, - "DATETIME": TokenType.TIMESTAMP, - "DECLARE": TokenType.DECLARE, - "ELSEIF": TokenType.COMMAND, - "EXCEPTION": TokenType.COMMAND, - "EXPORT": TokenType.EXPORT, - "FLOAT64": TokenType.DOUBLE, - "FOR SYSTEM_TIME": TokenType.TIMESTAMP_SNAPSHOT, - "LOOP": TokenType.COMMAND, - "MODEL": TokenType.MODEL, - "NOT DETERMINISTIC": TokenType.VOLATILE, - "RECORD": TokenType.STRUCT, - "REPEAT": TokenType.COMMAND, - "TIMESTAMP": TokenType.TIMESTAMPTZ, - "WHILE": TokenType.COMMAND, - } - KEYWORDS.pop("DIV") - KEYWORDS.pop("VALUES") - KEYWORDS.pop("/*+") - - class Parser(parser.Parser): - PREFIXED_PIVOT_COLUMNS = True - LOG_DEFAULTS_TO_LN = True - SUPPORTS_IMPLICIT_UNNEST = True - JOINS_HAVE_EQUAL_PRECEDENCE = True - - # BigQuery does not allow ASC/DESC to be used as an identifier, allows GRANT as an identifier - ID_VAR_TOKENS = { - *parser.Parser.ID_VAR_TOKENS, - TokenType.GRANT, - } - {TokenType.ASC, TokenType.DESC} - - ALIAS_TOKENS = { - *parser.Parser.ALIAS_TOKENS, - TokenType.GRANT, - } - {TokenType.ASC, TokenType.DESC} - - TABLE_ALIAS_TOKENS = { - *parser.Parser.TABLE_ALIAS_TOKENS, - TokenType.GRANT, - } - {TokenType.ASC, TokenType.DESC} - - COMMENT_TABLE_ALIAS_TOKENS = { - *parser.Parser.COMMENT_TABLE_ALIAS_TOKENS, - TokenType.GRANT, - } - {TokenType.ASC, TokenType.DESC} - - UPDATE_ALIAS_TOKENS = { - *parser.Parser.UPDATE_ALIAS_TOKENS, - TokenType.GRANT, - } - {TokenType.ASC, TokenType.DESC} - - FUNCTIONS = { - **parser.Parser.FUNCTIONS, - "APPROX_TOP_COUNT": exp.ApproxTopK.from_arg_list, - "BIT_AND": exp.BitwiseAndAgg.from_arg_list, - "BIT_OR": exp.BitwiseOrAgg.from_arg_list, - "BIT_XOR": exp.BitwiseXorAgg.from_arg_list, - "BIT_COUNT": exp.BitwiseCount.from_arg_list, - "BOOL": exp.JSONBool.from_arg_list, - "CONTAINS_SUBSTR": _build_contains_substring, - "DATE": _build_date, - "DATE_ADD": build_date_delta_with_interval(exp.DateAdd), - "DATE_DIFF": build_date_diff, - "DATE_SUB": build_date_delta_with_interval(exp.DateSub), - "DATE_TRUNC": lambda args: exp.DateTrunc( - unit=seq_get(args, 1), - this=seq_get(args, 0), - zone=seq_get(args, 2), - ), - "DATETIME": _build_datetime, - "DATETIME_ADD": build_date_delta_with_interval(exp.DatetimeAdd), - "DATETIME_SUB": build_date_delta_with_interval(exp.DatetimeSub), - "DIV": binary_from_function(exp.IntDiv), - "EDIT_DISTANCE": _build_levenshtein, - "FORMAT_DATE": _build_format_time(exp.TsOrDsToDate), - "GENERATE_ARRAY": exp.GenerateSeries.from_arg_list, - "JSON_EXTRACT_SCALAR": _build_extract_json_with_default_path( - exp.JSONExtractScalar - ), - "JSON_EXTRACT_ARRAY": _build_extract_json_with_default_path( - exp.JSONExtractArray - ), - "JSON_EXTRACT_STRING_ARRAY": _build_extract_json_with_default_path( - exp.JSONValueArray - ), - "JSON_KEYS": exp.JSONKeysAtDepth.from_arg_list, - "JSON_QUERY": parser.build_extract_json_with_path(exp.JSONExtract), - "JSON_QUERY_ARRAY": _build_extract_json_with_default_path( - exp.JSONExtractArray - ), - "JSON_STRIP_NULLS": _build_json_strip_nulls, - "JSON_VALUE": _build_extract_json_with_default_path(exp.JSONExtractScalar), - "JSON_VALUE_ARRAY": _build_extract_json_with_default_path( - exp.JSONValueArray - ), - "LENGTH": lambda args: exp.Length(this=seq_get(args, 0), binary=True), - "MD5": exp.MD5Digest.from_arg_list, - "SHA1": exp.SHA1Digest.from_arg_list, - "NORMALIZE_AND_CASEFOLD": lambda args: exp.Normalize( - this=seq_get(args, 0), form=seq_get(args, 1), is_casefold=True - ), - "OCTET_LENGTH": exp.ByteLength.from_arg_list, - "TO_HEX": _build_to_hex, - "PARSE_DATE": lambda args: build_formatted_time(exp.StrToDate, "bigquery")( - [seq_get(args, 1), seq_get(args, 0)] - ), - "PARSE_TIME": lambda args: build_formatted_time(exp.ParseTime, "bigquery")( - [seq_get(args, 1), seq_get(args, 0)] - ), - "PARSE_TIMESTAMP": _build_parse_timestamp, - "PARSE_DATETIME": lambda args: build_formatted_time( - exp.ParseDatetime, "bigquery" - )([seq_get(args, 1), seq_get(args, 0)]), - "REGEXP_CONTAINS": exp.RegexpLike.from_arg_list, - "REGEXP_EXTRACT": _build_regexp_extract(exp.RegexpExtract), - "REGEXP_SUBSTR": _build_regexp_extract(exp.RegexpExtract), - "REGEXP_EXTRACT_ALL": _build_regexp_extract( - exp.RegexpExtractAll, default_group=exp.Literal.number(0) - ), - "SHA256": lambda args: exp.SHA2Digest( - this=seq_get(args, 0), length=exp.Literal.number(256) - ), - "SHA512": lambda args: exp.SHA2( - this=seq_get(args, 0), length=exp.Literal.number(512) - ), - "SPLIT": lambda args: exp.Split( - # https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#split - this=seq_get(args, 0), - expression=seq_get(args, 1) or exp.Literal.string(","), - ), - "STRPOS": exp.StrPosition.from_arg_list, - "TIME": _build_time, - "TIME_ADD": build_date_delta_with_interval(exp.TimeAdd), - "TIME_SUB": build_date_delta_with_interval(exp.TimeSub), - "TIMESTAMP": _build_timestamp, - "TIMESTAMP_ADD": build_date_delta_with_interval(exp.TimestampAdd), - "TIMESTAMP_SUB": build_date_delta_with_interval(exp.TimestampSub), - "TIMESTAMP_MICROS": lambda args: exp.UnixToTime( - this=seq_get(args, 0), scale=exp.UnixToTime.MICROS - ), - "TIMESTAMP_MILLIS": lambda args: exp.UnixToTime( - this=seq_get(args, 0), scale=exp.UnixToTime.MILLIS - ), - "TIMESTAMP_SECONDS": lambda args: exp.UnixToTime(this=seq_get(args, 0)), - "TO_JSON": lambda args: exp.JSONFormat( - this=seq_get(args, 0), options=seq_get(args, 1), to_json=True - ), - "TO_JSON_STRING": exp.JSONFormat.from_arg_list, - "FORMAT_DATETIME": _build_format_time(exp.TsOrDsToDatetime), - "FORMAT_TIMESTAMP": _build_format_time(exp.TsOrDsToTimestamp), - "FORMAT_TIME": _build_format_time(exp.TsOrDsToTime), - "FROM_HEX": exp.Unhex.from_arg_list, - "WEEK": lambda args: exp.WeekStart(this=exp.var(seq_get(args, 0))), - } - # Remove SEARCH to avoid parameter routing issues - let it fall back to Anonymous function - FUNCTIONS.pop("SEARCH") - - FUNCTION_PARSERS = { - **parser.Parser.FUNCTION_PARSERS, - "ARRAY": lambda self: self.expression( - exp.Array, - expressions=[self._parse_statement()], - struct_name_inheritance=True, - ), - "JSON_ARRAY": lambda self: self.expression( - exp.JSONArray, expressions=self._parse_csv(self._parse_bitwise) - ), - "MAKE_INTERVAL": lambda self: self._parse_make_interval(), - "PREDICT": lambda self: self._parse_ml(exp.Predict), - "TRANSLATE": lambda self: self._parse_translate(), - "FEATURES_AT_TIME": lambda self: self._parse_features_at_time(), - "GENERATE_EMBEDDING": lambda self: self._parse_ml(exp.GenerateEmbedding), - "GENERATE_TEXT_EMBEDDING": lambda self: self._parse_ml( - exp.GenerateEmbedding, is_text=True - ), - "VECTOR_SEARCH": lambda self: self._parse_vector_search(), - "FORECAST": lambda self: self._parse_ml(exp.MLForecast), - } - FUNCTION_PARSERS.pop("TRIM") - - NO_PAREN_FUNCTIONS = { - **parser.Parser.NO_PAREN_FUNCTIONS, - TokenType.CURRENT_DATETIME: exp.CurrentDatetime, - } - - NESTED_TYPE_TOKENS = { - *parser.Parser.NESTED_TYPE_TOKENS, - TokenType.TABLE, - } - - PROPERTY_PARSERS = { - **parser.Parser.PROPERTY_PARSERS, - "NOT DETERMINISTIC": lambda self: self.expression( - exp.StabilityProperty, this=exp.Literal.string("VOLATILE") - ), - "OPTIONS": lambda self: self._parse_with_property(), - } - - CONSTRAINT_PARSERS = { - **parser.Parser.CONSTRAINT_PARSERS, - "OPTIONS": lambda self: exp.Properties( - expressions=self._parse_with_property() - ), - } - - RANGE_PARSERS = parser.Parser.RANGE_PARSERS.copy() - RANGE_PARSERS.pop(TokenType.OVERLAPS) - - DASHED_TABLE_PART_FOLLOW_TOKENS = { - TokenType.DOT, - TokenType.L_PAREN, - TokenType.R_PAREN, - } - - STATEMENT_PARSERS = { - **parser.Parser.STATEMENT_PARSERS, - TokenType.ELSE: lambda self: self._parse_as_command(self._prev), - TokenType.END: lambda self: self._parse_as_command(self._prev), - TokenType.FOR: lambda self: self._parse_for_in(), - TokenType.EXPORT: lambda self: self._parse_export_data(), - TokenType.DECLARE: lambda self: self._parse_declare(), - } - - BRACKET_OFFSETS = { - "OFFSET": (0, False), - "ORDINAL": (1, False), - "SAFE_OFFSET": (0, True), - "SAFE_ORDINAL": (1, True), - } - - def _parse_for_in(self) -> t.Union[exp.ForIn, exp.Command]: - index = self._index - this = self._parse_range() - self._match_text_seq("DO") - if self._match(TokenType.COMMAND): - self._retreat(index) - return self._parse_as_command(self._prev) - return self.expression( - exp.ForIn, this=this, expression=self._parse_statement() - ) - - def _parse_table_part(self, schema: bool = False) -> t.Optional[exp.Expression]: - this = super()._parse_table_part(schema=schema) or self._parse_number() - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#table_names - if isinstance(this, exp.Identifier): - table_name = this.name - while self._match(TokenType.DASH, advance=False) and self._next: - start = self._curr - while self._is_connected() and not self._match_set( - self.DASHED_TABLE_PART_FOLLOW_TOKENS, advance=False - ): - self._advance() - - if start == self._curr: - break - - table_name += self._find_sql(start, self._prev) - - this = exp.Identifier( - this=table_name, quoted=this.args.get("quoted") - ).update_positions(this) - elif isinstance(this, exp.Literal): - table_name = this.name - - if self._is_connected() and self._parse_var(any_token=True): - table_name += self._prev.text - - this = exp.Identifier(this=table_name, quoted=True).update_positions( - this - ) - - return this - - def _parse_table_parts( - self, - schema: bool = False, - is_db_reference: bool = False, - wildcard: bool = False, - ) -> exp.Table: - table = super()._parse_table_parts( - schema=schema, is_db_reference=is_db_reference, wildcard=True - ) - - # proj-1.db.tbl -- `1.` is tokenized as a float so we need to unravel it here - if not table.catalog: - if table.db: - previous_db = table.args["db"] - parts = table.db.split(".") - if len(parts) == 2 and not table.args["db"].quoted: - table.set( - "catalog", - exp.Identifier(this=parts[0]).update_positions(previous_db), - ) - table.set( - "db", - exp.Identifier(this=parts[1]).update_positions(previous_db), - ) - else: - previous_this = table.this - parts = table.name.split(".") - if len(parts) == 2 and not table.this.quoted: - table.set( - "db", - exp.Identifier(this=parts[0]).update_positions( - previous_this - ), - ) - table.set( - "this", - exp.Identifier(this=parts[1]).update_positions( - previous_this - ), - ) - - if isinstance(table.this, exp.Identifier) and any( - "." in p.name for p in table.parts - ): - alias = table.this - catalog, db, this, *rest = ( - exp.to_identifier(p, quoted=True) - for p in split_num_words( - ".".join(p.name for p in table.parts), ".", 3 - ) - ) - - for part in (catalog, db, this): - if part: - part.update_positions(table.this) - - if rest and this: - this = exp.Dot.build([this, *rest]) # type: ignore - - table = exp.Table( - this=this, db=db, catalog=catalog, pivots=table.args.get("pivots") - ) - table.meta["quoted_table"] = True - else: - alias = None - - # The `INFORMATION_SCHEMA` views in BigQuery need to be qualified by a region or - # dataset, so if the project identifier is omitted we need to fix the ast so that - # the `INFORMATION_SCHEMA.X` bit is represented as a single (quoted) Identifier. - # Otherwise, we wouldn't correctly qualify a `Table` node that references these - # views, because it would seem like the "catalog" part is set, when it'd actually - # be the region/dataset. Merging the two identifiers into a single one is done to - # avoid producing a 4-part Table reference, which would cause issues in the schema - # module, when there are 3-part table names mixed with information schema views. - # - # See: https://cloud.google.com/bigquery/docs/information-schema-intro#syntax - table_parts = table.parts - if ( - len(table_parts) > 1 - and table_parts[-2].name.upper() == "INFORMATION_SCHEMA" - ): - # We need to alias the table here to avoid breaking existing qualified columns. - # This is expected to be safe, because if there's an actual alias coming up in - # the token stream, it will overwrite this one. If there isn't one, we are only - # exposing the name that can be used to reference the view explicitly (a no-op). - exp.alias_( - table, - t.cast(exp.Identifier, alias or table_parts[-1]), - table=True, - copy=False, - ) - - info_schema_view = f"{table_parts[-2].name}.{table_parts[-1].name}" - new_this = exp.Identifier( - this=info_schema_view, quoted=True - ).update_positions( - line=table_parts[-2].meta.get("line"), - col=table_parts[-1].meta.get("col"), - start=table_parts[-2].meta.get("start"), - end=table_parts[-1].meta.get("end"), - ) - table.set("this", new_this) - table.set("db", seq_get(table_parts, -3)) - table.set("catalog", seq_get(table_parts, -4)) - - return table - - def _parse_column(self) -> t.Optional[exp.Expression]: - column = super()._parse_column() - if isinstance(column, exp.Column): - parts = column.parts - if any("." in p.name for p in parts): - catalog, db, table, this, *rest = ( - exp.to_identifier(p, quoted=True) - for p in split_num_words( - ".".join(p.name for p in parts), ".", 4 - ) - ) - - if rest and this: - this = exp.Dot.build([this, *rest]) # type: ignore - - column = exp.Column(this=this, table=table, db=db, catalog=catalog) - column.meta["quoted_column"] = True - - return column - - @t.overload - def _parse_json_object(self, agg: Lit[False]) -> exp.JSONObject: ... - - @t.overload - def _parse_json_object(self, agg: Lit[True]) -> exp.JSONObjectAgg: ... - - def _parse_json_object(self, agg=False): - json_object = super()._parse_json_object() - array_kv_pair = seq_get(json_object.expressions, 0) - - # Converts BQ's "signature 2" of JSON_OBJECT into SQLGlot's canonical representation - # https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#json_object_signature2 - if ( - array_kv_pair - and isinstance(array_kv_pair.this, exp.Array) - and isinstance(array_kv_pair.expression, exp.Array) - ): - keys = array_kv_pair.this.expressions - values = array_kv_pair.expression.expressions - - json_object.set( - "expressions", - [ - exp.JSONKeyValue(this=k, expression=v) - for k, v in zip(keys, values) - ], - ) - - return json_object - - def _parse_bracket( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - bracket = super()._parse_bracket(this) - - if isinstance(bracket, exp.Array): - bracket.set("struct_name_inheritance", True) - - if this is bracket: - return bracket - - if isinstance(bracket, exp.Bracket): - for expression in bracket.expressions: - name = expression.name.upper() - - if name not in self.BRACKET_OFFSETS: - break - - offset, safe = self.BRACKET_OFFSETS[name] - bracket.set("offset", offset) - bracket.set("safe", safe) - expression.replace(expression.expressions[0]) - - return bracket - - def _parse_unnest(self, with_alias: bool = True) -> t.Optional[exp.Unnest]: - unnest = super()._parse_unnest(with_alias=with_alias) - - if not unnest: - return None - - unnest_expr = seq_get(unnest.expressions, 0) - if unnest_expr: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - unnest_expr = annotate_types(unnest_expr, dialect=self.dialect) - - # Unnesting a nested array (i.e array of structs) explodes the top-level struct fields, - # in contrast to other dialects such as DuckDB which flattens only the array by default - if unnest_expr.is_type(exp.DataType.Type.ARRAY) and any( - array_elem.is_type(exp.DataType.Type.STRUCT) - for array_elem in unnest_expr._type.expressions - ): - unnest.set("explode_array", True) - - return unnest - - def _parse_make_interval(self) -> exp.MakeInterval: - expr = exp.MakeInterval() - - for arg_key in MAKE_INTERVAL_KWARGS: - value = self._parse_lambda() - - if not value: - break - - # Non-named arguments are filled sequentially, (optionally) followed by named arguments - # that can appear in any order e.g MAKE_INTERVAL(1, minute => 5, day => 2) - if isinstance(value, exp.Kwarg): - arg_key = value.this.name - - expr.set(arg_key, value) - - self._match(TokenType.COMMA) - - return expr - - def _parse_ml(self, expr_type: t.Type[E], **kwargs) -> E: - self._match_text_seq("MODEL") - this = self._parse_table() - - self._match(TokenType.COMMA) - self._match_text_seq("TABLE") - - # Certain functions like ML.FORECAST require a STRUCT argument but not a TABLE/SELECT one - expression = ( - self._parse_table() - if not self._match(TokenType.STRUCT, advance=False) - else None - ) - - self._match(TokenType.COMMA) - - return self.expression( - expr_type, - this=this, - expression=expression, - params_struct=self._parse_bitwise(), - **kwargs, - ) - - def _parse_translate(self) -> exp.Translate | exp.MLTranslate: - # Check if this is ML.TRANSLATE by looking at previous tokens - token = seq_get(self._tokens, self._index - 4) - if token and token.text.upper() == "ML": - return self._parse_ml(exp.MLTranslate) - - return exp.Translate.from_arg_list(self._parse_function_args()) - - def _parse_features_at_time(self) -> exp.FeaturesAtTime: - self._match(TokenType.TABLE) - this = self._parse_table() - - expr = self.expression(exp.FeaturesAtTime, this=this) - - while self._match(TokenType.COMMA): - arg = self._parse_lambda() - - # Get the LHS of the Kwarg and set the arg to that value, e.g - # "num_rows => 1" sets the expr's `num_rows` arg - if arg: - expr.set(arg.this.name, arg) - - return expr - - def _parse_vector_search(self) -> exp.VectorSearch: - self._match(TokenType.TABLE) - base_table = self._parse_table() - - self._match(TokenType.COMMA) - - column_to_search = self._parse_bitwise() - self._match(TokenType.COMMA) - - self._match(TokenType.TABLE) - query_table = self._parse_table() - - expr = self.expression( - exp.VectorSearch, - this=base_table, - column_to_search=column_to_search, - query_table=query_table, - ) - - while self._match(TokenType.COMMA): - # query_column_to_search can be named argument or positional - if self._match(TokenType.STRING, advance=False): - query_column = self._parse_string() - expr.set("query_column_to_search", query_column) - else: - arg = self._parse_lambda() - if arg: - expr.set(arg.this.name, arg) - - return expr - - def _parse_export_data(self) -> exp.Export: - self._match_text_seq("DATA") - - return self.expression( - exp.Export, - connection=self._match_text_seq("WITH", "CONNECTION") - and self._parse_table_parts(), - options=self._parse_properties(), - this=self._match_text_seq("AS") and self._parse_select(), - ) - - def _parse_column_ops( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - this = super()._parse_column_ops(this) - - if isinstance(this, exp.Dot): - prefix_name = this.this.name.upper() - func_name = this.name.upper() - if prefix_name == "NET": - if func_name == "HOST": - this = self.expression( - exp.NetHost, this=seq_get(this.expression.expressions, 0) - ) - elif prefix_name == "SAFE": - if func_name == "TIMESTAMP": - this = _build_timestamp(this.expression.expressions) - this.set("safe", True) - - return this - - class Generator(generator.Generator): - INTERVAL_ALLOWS_PLURAL_FORM = False - JOIN_HINTS = False - QUERY_HINTS = False - TABLE_HINTS = False - LIMIT_FETCH = "LIMIT" - RENAME_TABLE_WITH_DB = False - NVL2_SUPPORTED = False - UNNEST_WITH_ORDINALITY = False - COLLATE_IS_FUNC = True - LIMIT_ONLY_LITERALS = True - SUPPORTS_TABLE_ALIAS_COLUMNS = False - UNPIVOT_ALIASES_ARE_IDENTIFIERS = False - JSON_KEY_VALUE_PAIR_SEP = "," - NULL_ORDERING_SUPPORTED = False - IGNORE_NULLS_IN_FUNC = True - JSON_PATH_SINGLE_QUOTE_ESCAPE = True - CAN_IMPLEMENT_ARRAY_ANY = True - SUPPORTS_TO_NUMBER = False - NAMED_PLACEHOLDER_TOKEN = "@" - HEX_FUNC = "TO_HEX" - WITH_PROPERTIES_PREFIX = "OPTIONS" - SUPPORTS_EXPLODING_PROJECTIONS = False - EXCEPT_INTERSECT_SUPPORT_ALL_CLAUSE = False - SUPPORTS_UNIX_SECONDS = True - - SAFE_JSON_PATH_KEY_RE = re.compile(r"^[_\-a-zA-Z][\-\w]*$") - - TS_OR_DS_TYPES = ( - exp.TsOrDsToDatetime, - exp.TsOrDsToTimestamp, - exp.TsOrDsToTime, - exp.TsOrDsToDate, - ) - - TRANSFORMS = { - **generator.Generator.TRANSFORMS, - exp.ApproxTopK: rename_func("APPROX_TOP_COUNT"), - exp.ApproxDistinct: rename_func("APPROX_COUNT_DISTINCT"), - exp.ArgMax: arg_max_or_min_no_count("MAX_BY"), - exp.ArgMin: arg_max_or_min_no_count("MIN_BY"), - exp.Array: inline_array_unless_query, - exp.ArrayContains: _array_contains_sql, - exp.ArrayFilter: filter_array_using_unnest, - exp.ArrayRemove: filter_array_using_unnest, - exp.BitwiseAndAgg: rename_func("BIT_AND"), - exp.BitwiseOrAgg: rename_func("BIT_OR"), - exp.BitwiseXorAgg: rename_func("BIT_XOR"), - exp.BitwiseCount: rename_func("BIT_COUNT"), - exp.ByteLength: rename_func("BYTE_LENGTH"), - exp.Cast: transforms.preprocess( - [transforms.remove_precision_parameterized_types] - ), - exp.CollateProperty: lambda self, e: ( - f"DEFAULT COLLATE {self.sql(e, 'this')}" - if e.args.get("default") - else f"COLLATE {self.sql(e, 'this')}" - ), - exp.Commit: lambda *_: "COMMIT TRANSACTION", - exp.CountIf: rename_func("COUNTIF"), - exp.Create: _create_sql, - exp.CTE: transforms.preprocess([_pushdown_cte_column_names]), - exp.DateAdd: date_add_interval_sql("DATE", "ADD"), - exp.DateDiff: lambda self, e: self.func( - "DATE_DIFF", e.this, e.expression, unit_to_var(e) - ), - exp.DateFromParts: rename_func("DATE"), - exp.DateStrToDate: datestrtodate_sql, - exp.DateSub: date_add_interval_sql("DATE", "SUB"), - exp.DatetimeAdd: date_add_interval_sql("DATETIME", "ADD"), - exp.DatetimeSub: date_add_interval_sql("DATETIME", "SUB"), - exp.DateFromUnixDate: rename_func("DATE_FROM_UNIX_DATE"), - exp.FromTimeZone: lambda self, e: self.func( - "DATETIME", self.func("TIMESTAMP", e.this, e.args.get("zone")), "'UTC'" - ), - exp.GenerateSeries: rename_func("GENERATE_ARRAY"), - exp.GroupConcat: lambda self, e: groupconcat_sql( - self, e, func_name="STRING_AGG", within_group=False, sep=None - ), - exp.Hex: lambda self, e: self.func( - "UPPER", self.func("TO_HEX", self.sql(e, "this")) - ), - exp.HexString: lambda self, e: self.hexstring_sql( - e, binary_function_repr="FROM_HEX" - ), - exp.If: if_sql(false_value="NULL"), - exp.ILike: no_ilike_sql, - exp.IntDiv: rename_func("DIV"), - exp.Int64: rename_func("INT64"), - exp.JSONBool: rename_func("BOOL"), - exp.JSONExtract: _json_extract_sql, - exp.JSONExtractArray: _json_extract_sql, - exp.JSONExtractScalar: _json_extract_sql, - exp.JSONFormat: lambda self, e: self.func( - "TO_JSON" if e.args.get("to_json") else "TO_JSON_STRING", - e.this, - e.args.get("options"), - ), - exp.JSONKeysAtDepth: rename_func("JSON_KEYS"), - exp.JSONValueArray: rename_func("JSON_VALUE_ARRAY"), - exp.Levenshtein: _levenshtein_sql, - exp.Max: max_or_greatest, - exp.MD5: lambda self, e: self.func("TO_HEX", self.func("MD5", e.this)), - exp.MD5Digest: rename_func("MD5"), - exp.Min: min_or_least, - exp.Normalize: lambda self, e: self.func( - "NORMALIZE_AND_CASEFOLD" if e.args.get("is_casefold") else "NORMALIZE", - e.this, - e.args.get("form"), - ), - exp.PartitionedByProperty: lambda self, - e: f"PARTITION BY {self.sql(e, 'this')}", - exp.RegexpExtract: lambda self, e: self.func( - "REGEXP_EXTRACT", - e.this, - e.expression, - e.args.get("position"), - e.args.get("occurrence"), - ), - exp.RegexpExtractAll: lambda self, e: self.func( - "REGEXP_EXTRACT_ALL", e.this, e.expression - ), - exp.RegexpReplace: regexp_replace_sql, - exp.RegexpLike: rename_func("REGEXP_CONTAINS"), - exp.ReturnsProperty: _returnsproperty_sql, - exp.Rollback: lambda *_: "ROLLBACK TRANSACTION", - exp.ParseTime: lambda self, e: self.func( - "PARSE_TIME", self.format_time(e), e.this - ), - exp.ParseDatetime: lambda self, e: self.func( - "PARSE_DATETIME", self.format_time(e), e.this - ), - exp.Select: transforms.preprocess( - [ - transforms.explode_projection_to_unnest(), - transforms.unqualify_unnest, - transforms.eliminate_distinct_on, - _alias_ordered_group, - transforms.eliminate_semi_and_anti_joins, - ] - ), - exp.SHA: rename_func("SHA1"), - exp.SHA2: sha256_sql, - exp.SHA1Digest: rename_func("SHA1"), - exp.SHA2Digest: sha2_digest_sql, - exp.StabilityProperty: lambda self, e: ( - "DETERMINISTIC" if e.name == "IMMUTABLE" else "NOT DETERMINISTIC" - ), - exp.String: rename_func("STRING"), - exp.StrPosition: lambda self, e: ( - strposition_sql( - self, - e, - func_name="INSTR", - supports_position=True, - supports_occurrence=True, - ) - ), - exp.StrToDate: _str_to_datetime_sql, - exp.StrToTime: _str_to_datetime_sql, - exp.SessionUser: lambda *_: "SESSION_USER()", - exp.TimeAdd: date_add_interval_sql("TIME", "ADD"), - exp.TimeFromParts: rename_func("TIME"), - exp.TimestampFromParts: rename_func("DATETIME"), - exp.TimeSub: date_add_interval_sql("TIME", "SUB"), - exp.TimestampAdd: date_add_interval_sql("TIMESTAMP", "ADD"), - exp.TimestampDiff: rename_func("TIMESTAMP_DIFF"), - exp.TimestampSub: date_add_interval_sql("TIMESTAMP", "SUB"), - exp.TimeStrToTime: timestrtotime_sql, - exp.Transaction: lambda *_: "BEGIN TRANSACTION", - exp.TsOrDsAdd: _ts_or_ds_add_sql, - exp.TsOrDsDiff: _ts_or_ds_diff_sql, - exp.TsOrDsToTime: rename_func("TIME"), - exp.TsOrDsToDatetime: rename_func("DATETIME"), - exp.TsOrDsToTimestamp: rename_func("TIMESTAMP"), - exp.Unhex: rename_func("FROM_HEX"), - exp.UnixDate: rename_func("UNIX_DATE"), - exp.UnixToTime: _unix_to_time_sql, - exp.Uuid: lambda *_: "GENERATE_UUID()", - exp.Values: _derived_table_values_to_unnest, - exp.VariancePop: rename_func("VAR_POP"), - exp.SafeDivide: rename_func("SAFE_DIVIDE"), - } - - SUPPORTED_JSON_PATH_PARTS = { - exp.JSONPathKey, - exp.JSONPathRoot, - exp.JSONPathSubscript, - } - - TYPE_MAPPING = { - **generator.Generator.TYPE_MAPPING, - exp.DataType.Type.BIGDECIMAL: "BIGNUMERIC", - exp.DataType.Type.BIGINT: "INT64", - exp.DataType.Type.BINARY: "BYTES", - exp.DataType.Type.BLOB: "BYTES", - exp.DataType.Type.BOOLEAN: "BOOL", - exp.DataType.Type.CHAR: "STRING", - exp.DataType.Type.DECIMAL: "NUMERIC", - exp.DataType.Type.DOUBLE: "FLOAT64", - exp.DataType.Type.FLOAT: "FLOAT64", - exp.DataType.Type.INT: "INT64", - exp.DataType.Type.NCHAR: "STRING", - exp.DataType.Type.NVARCHAR: "STRING", - exp.DataType.Type.SMALLINT: "INT64", - exp.DataType.Type.TEXT: "STRING", - exp.DataType.Type.TIMESTAMP: "DATETIME", - exp.DataType.Type.TIMESTAMPNTZ: "DATETIME", - exp.DataType.Type.TIMESTAMPTZ: "TIMESTAMP", - exp.DataType.Type.TIMESTAMPLTZ: "TIMESTAMP", - exp.DataType.Type.TINYINT: "INT64", - exp.DataType.Type.ROWVERSION: "BYTES", - exp.DataType.Type.UUID: "STRING", - exp.DataType.Type.VARBINARY: "BYTES", - exp.DataType.Type.VARCHAR: "STRING", - exp.DataType.Type.VARIANT: "ANY TYPE", - } - - PROPERTIES_LOCATION = { - **generator.Generator.PROPERTIES_LOCATION, - exp.PartitionedByProperty: exp.Properties.Location.POST_SCHEMA, - exp.VolatileProperty: exp.Properties.Location.UNSUPPORTED, - } - - # WINDOW comes after QUALIFY - # https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#window_clause - AFTER_HAVING_MODIFIER_TRANSFORMS = { - "qualify": generator.Generator.AFTER_HAVING_MODIFIER_TRANSFORMS["qualify"], - "windows": generator.Generator.AFTER_HAVING_MODIFIER_TRANSFORMS["windows"], - } - - # from: https://cloud.google.com/bigquery/docs/reference/standard-sql/lexical#reserved_keywords - RESERVED_KEYWORDS = { - "all", - "and", - "any", - "array", - "as", - "asc", - "assert_rows_modified", - "at", - "between", - "by", - "case", - "cast", - "collate", - "contains", - "create", - "cross", - "cube", - "current", - "default", - "define", - "desc", - "distinct", - "else", - "end", - "enum", - "escape", - "except", - "exclude", - "exists", - "extract", - "false", - "fetch", - "following", - "for", - "from", - "full", - "group", - "grouping", - "groups", - "hash", - "having", - "if", - "ignore", - "in", - "inner", - "intersect", - "interval", - "into", - "is", - "join", - "lateral", - "left", - "like", - "limit", - "lookup", - "merge", - "natural", - "new", - "no", - "not", - "null", - "nulls", - "of", - "on", - "or", - "order", - "outer", - "over", - "partition", - "preceding", - "proto", - "qualify", - "range", - "recursive", - "respect", - "right", - "rollup", - "rows", - "select", - "set", - "some", - "struct", - "tablesample", - "then", - "to", - "treat", - "true", - "unbounded", - "union", - "unnest", - "using", - "when", - "where", - "window", - "with", - "within", - } - - def datetrunc_sql(self, expression: exp.DateTrunc) -> str: - unit = expression.unit - unit_sql = unit.name if unit.is_string else self.sql(unit) - return self.func( - "DATE_TRUNC", expression.this, unit_sql, expression.args.get("zone") - ) - - def mod_sql(self, expression: exp.Mod) -> str: - this = expression.this - expr = expression.expression - return self.func( - "MOD", - this.unnest() if isinstance(this, exp.Paren) else this, - expr.unnest() if isinstance(expr, exp.Paren) else expr, - ) - - def column_parts(self, expression: exp.Column) -> str: - if expression.meta.get("quoted_column"): - # If a column reference is of the form `dataset.table`.name, we need - # to preserve the quoted table path, otherwise the reference breaks - table_parts = ".".join(p.name for p in expression.parts[:-1]) - table_path = self.sql(exp.Identifier(this=table_parts, quoted=True)) - return f"{table_path}.{self.sql(expression, 'this')}" - - return super().column_parts(expression) - - def table_parts(self, expression: exp.Table) -> str: - # Depending on the context, `x.y` may not resolve to the same data source as `x`.`y`, so - # we need to make sure the correct quoting is used in each case. - # - # For example, if there is a CTE x that clashes with a schema name, then the former will - # return the table y in that schema, whereas the latter will return the CTE's y column: - # - # - WITH x AS (SELECT [1, 2] AS y) SELECT * FROM x, `x.y` -> cross join - # - WITH x AS (SELECT [1, 2] AS y) SELECT * FROM x, `x`.`y` -> implicit unnest - if expression.meta.get("quoted_table"): - table_parts = ".".join(p.name for p in expression.parts) - return self.sql(exp.Identifier(this=table_parts, quoted=True)) - - return super().table_parts(expression) - - def timetostr_sql(self, expression: exp.TimeToStr) -> str: - this = expression.this - if isinstance(this, exp.TsOrDsToDatetime): - func_name = "FORMAT_DATETIME" - elif isinstance(this, exp.TsOrDsToTimestamp): - func_name = "FORMAT_TIMESTAMP" - elif isinstance(this, exp.TsOrDsToTime): - func_name = "FORMAT_TIME" - else: - func_name = "FORMAT_DATE" - - time_expr = this if isinstance(this, self.TS_OR_DS_TYPES) else expression - return self.func( - func_name, - self.format_time(expression), - time_expr.this, - expression.args.get("zone"), - ) - - def eq_sql(self, expression: exp.EQ) -> str: - # Operands of = cannot be NULL in BigQuery - if isinstance(expression.left, exp.Null) or isinstance( - expression.right, exp.Null - ): - if not isinstance(expression.parent, exp.Update): - return "NULL" - - return self.binary(expression, "=") - - def attimezone_sql(self, expression: exp.AtTimeZone) -> str: - parent = expression.parent - - # BigQuery allows CAST(.. AS {STRING|TIMESTAMP} [FORMAT [AT TIME ZONE ]]). - # Only the TIMESTAMP one should use the below conversion, when AT TIME ZONE is included. - if not isinstance(parent, exp.Cast) or not parent.to.is_type("text"): - return self.func( - "TIMESTAMP", - self.func("DATETIME", expression.this, expression.args.get("zone")), - ) - - return super().attimezone_sql(expression) - - def trycast_sql(self, expression: exp.TryCast) -> str: - return self.cast_sql(expression, safe_prefix="SAFE_") - - def bracket_sql(self, expression: exp.Bracket) -> str: - this = expression.this - expressions = expression.expressions - - if ( - len(expressions) == 1 - and this - and this.is_type(exp.DataType.Type.STRUCT) - ): - arg = expressions[0] - if arg.type is None: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - arg = annotate_types(arg, dialect=self.dialect) - - if arg.type and arg.type.this in exp.DataType.TEXT_TYPES: - # BQ doesn't support bracket syntax with string values for structs - return f"{self.sql(this)}.{arg.name}" - - expressions_sql = self.expressions(expression, flat=True) - offset = expression.args.get("offset") - - if offset == 0: - expressions_sql = f"OFFSET({expressions_sql})" - elif offset == 1: - expressions_sql = f"ORDINAL({expressions_sql})" - elif offset is not None: - self.unsupported(f"Unsupported array offset: {offset}") - - if expression.args.get("safe"): - expressions_sql = f"SAFE_{expressions_sql}" - - return f"{self.sql(this)}[{expressions_sql}]" - - def in_unnest_op(self, expression: exp.Unnest) -> str: - return self.sql(expression) - - def version_sql(self, expression: exp.Version) -> str: - if expression.name == "TIMESTAMP": - expression.set("this", "SYSTEM_TIME") - return super().version_sql(expression) - - def contains_sql(self, expression: exp.Contains) -> str: - this = expression.this - expr = expression.expression - - if isinstance(this, exp.Lower) and isinstance(expr, exp.Lower): - this = this.this - expr = expr.this - - return self.func( - "CONTAINS_SUBSTR", this, expr, expression.args.get("json_scope") - ) - - def cast_sql( - self, expression: exp.Cast, safe_prefix: t.Optional[str] = None - ) -> str: - this = expression.this - - # This ensures that inline type-annotated ARRAY literals like ARRAY[1, 2, 3] - # are roundtripped unaffected. The inner check excludes ARRAY(SELECT ...) expressions, - # because they aren't literals and so the above syntax is invalid BigQuery. - if isinstance(this, exp.Array): - elem = seq_get(this.expressions, 0) - if not (elem and elem.find(exp.Query)): - return f"{self.sql(expression, 'to')}{self.sql(this)}" - - return super().cast_sql(expression, safe_prefix=safe_prefix) - - def declareitem_sql(self, expression: exp.DeclareItem) -> str: - variables = self.expressions(expression, "this") - default = self.sql(expression, "default") - default = f" DEFAULT {default}" if default else "" - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - - return f"{variables}{kind}{default}" - - def timestamp_sql(self, expression: exp.Timestamp) -> str: - prefix = "SAFE." if expression.args.get("safe") else "" - return self.func( - f"{prefix}TIMESTAMP", expression.this, expression.args.get("zone") - ) diff --git a/third_party/bigframes_vendored/sqlglot/dialects/dialect.py b/third_party/bigframes_vendored/sqlglot/dialects/dialect.py deleted file mode 100644 index 8e26b777abd..00000000000 --- a/third_party/bigframes_vendored/sqlglot/dialects/dialect.py +++ /dev/null @@ -1,2368 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/dialects/dialect.py - -from __future__ import annotations - -import importlib -import logging -import sys -import typing as t -from enum import Enum, auto -from functools import reduce - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects import DIALECT_MODULE_NAMES -from bigframes_vendored.sqlglot.errors import ParseError -from bigframes_vendored.sqlglot.generator import Generator, unsupported_args -from bigframes_vendored.sqlglot.helper import ( - AutoName, - flatten, - is_int, - seq_get, - suggest_closest_match_and_fail, - to_bool, -) -from bigframes_vendored.sqlglot.jsonpath import JSONPathTokenizer -from bigframes_vendored.sqlglot.jsonpath import parse as parse_json_path -from bigframes_vendored.sqlglot.parser import Parser -from bigframes_vendored.sqlglot.time import TIMEZONES, format_time, subsecond_precision -from bigframes_vendored.sqlglot.tokens import Token, Tokenizer, TokenType -from bigframes_vendored.sqlglot.trie import new_trie -from bigframes_vendored.sqlglot.typing import EXPRESSION_METADATA - -DATE_ADD_OR_DIFF = t.Union[ - exp.DateAdd, - exp.DateDiff, - exp.DateSub, - exp.TsOrDsAdd, - exp.TsOrDsDiff, -] -DATE_ADD_OR_SUB = t.Union[exp.DateAdd, exp.TsOrDsAdd, exp.DateSub] -JSON_EXTRACT_TYPE = t.Union[ - exp.JSONExtract, exp.JSONExtractScalar, exp.JSONBExtract, exp.JSONBExtractScalar -] -DATETIME_DELTA = t.Union[ - exp.DateAdd, - exp.DatetimeAdd, - exp.DatetimeSub, - exp.TimeAdd, - exp.TimeSub, - exp.TimestampAdd, - exp.TimestampSub, - exp.TsOrDsAdd, -] -DATETIME_ADD = ( - exp.DateAdd, - exp.TimeAdd, - exp.DatetimeAdd, - exp.TsOrDsAdd, - exp.TimestampAdd, -) - -if t.TYPE_CHECKING: - from sqlglot._typing import B, E, F - -logger = logging.getLogger("sqlglot") - -UNESCAPED_SEQUENCES = { - "\\a": "\a", - "\\b": "\b", - "\\f": "\f", - "\\n": "\n", - "\\r": "\r", - "\\t": "\t", - "\\v": "\v", - "\\\\": "\\", -} - - -class Dialects(str, Enum): - """Dialects supported by SQLGLot.""" - - DIALECT = "" - - ATHENA = "athena" - BIGQUERY = "bigquery" - CLICKHOUSE = "clickhouse" - DATABRICKS = "databricks" - DORIS = "doris" - DREMIO = "dremio" - DRILL = "drill" - DRUID = "druid" - DUCKDB = "duckdb" - DUNE = "dune" - FABRIC = "fabric" - HIVE = "hive" - MATERIALIZE = "materialize" - MYSQL = "mysql" - ORACLE = "oracle" - POSTGRES = "postgres" - PRESTO = "presto" - PRQL = "prql" - REDSHIFT = "redshift" - RISINGWAVE = "risingwave" - SNOWFLAKE = "snowflake" - SOLR = "solr" - SPARK = "spark" - SPARK2 = "spark2" - SQLITE = "sqlite" - STARROCKS = "starrocks" - TABLEAU = "tableau" - TERADATA = "teradata" - TRINO = "trino" - TSQL = "tsql" - EXASOL = "exasol" - - -class NormalizationStrategy(str, AutoName): - """Specifies the strategy according to which identifiers should be normalized.""" - - LOWERCASE = auto() - """Unquoted identifiers are lowercased.""" - - UPPERCASE = auto() - """Unquoted identifiers are uppercased.""" - - CASE_SENSITIVE = auto() - """Always case-sensitive, regardless of quotes.""" - - CASE_INSENSITIVE = auto() - """Always case-insensitive (lowercase), regardless of quotes.""" - - CASE_INSENSITIVE_UPPERCASE = auto() - """Always case-insensitive (uppercase), regardless of quotes.""" - - -class _Dialect(type): - _classes: t.Dict[str, t.Type[Dialect]] = {} - - def __eq__(cls, other: t.Any) -> bool: - if cls is other: - return True - if isinstance(other, str): - return cls is cls.get(other) - if isinstance(other, Dialect): - return cls is type(other) - - return False - - def __hash__(cls) -> int: - return hash(cls.__name__.lower()) - - @property - def classes(cls): - if len(DIALECT_MODULE_NAMES) != len(cls._classes): - for key in DIALECT_MODULE_NAMES: - cls._try_load(key) - - return cls._classes - - @classmethod - def _try_load(cls, key: str | Dialects) -> None: - if isinstance(key, Dialects): - key = key.value - - # This import will lead to a new dialect being loaded, and hence, registered. - # We check that the key is an actual sqlglot module to avoid blindly importing - # files. Custom user dialects need to be imported at the top-level package, in - # order for them to be registered as soon as possible. - if key in DIALECT_MODULE_NAMES: - importlib.import_module(f"bigframes_vendored.sqlglot.dialects.{key}") - - @classmethod - def __getitem__(cls, key: str) -> t.Type[Dialect]: - if key not in cls._classes: - cls._try_load(key) - - return cls._classes[key] - - @classmethod - def get( - cls, key: str, default: t.Optional[t.Type[Dialect]] = None - ) -> t.Optional[t.Type[Dialect]]: - if key not in cls._classes: - cls._try_load(key) - - return cls._classes.get(key, default) - - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - enum = Dialects.__members__.get(clsname.upper()) - cls._classes[enum.value if enum is not None else clsname.lower()] = klass - - klass.TIME_TRIE = new_trie(klass.TIME_MAPPING) - klass.FORMAT_TRIE = ( - new_trie(klass.FORMAT_MAPPING) if klass.FORMAT_MAPPING else klass.TIME_TRIE - ) - # Merge class-defined INVERSE_TIME_MAPPING with auto-generated mappings - # This allows dialects to define custom inverse mappings for roundtrip correctness - klass.INVERSE_TIME_MAPPING = {v: k for k, v in klass.TIME_MAPPING.items()} | ( - klass.__dict__.get("INVERSE_TIME_MAPPING") or {} - ) - klass.INVERSE_TIME_TRIE = new_trie(klass.INVERSE_TIME_MAPPING) - klass.INVERSE_FORMAT_MAPPING = {v: k for k, v in klass.FORMAT_MAPPING.items()} - klass.INVERSE_FORMAT_TRIE = new_trie(klass.INVERSE_FORMAT_MAPPING) - - klass.INVERSE_CREATABLE_KIND_MAPPING = { - v: k for k, v in klass.CREATABLE_KIND_MAPPING.items() - } - - base = seq_get(bases, 0) - base_tokenizer = (getattr(base, "tokenizer_class", Tokenizer),) - base_jsonpath_tokenizer = ( - getattr(base, "jsonpath_tokenizer_class", JSONPathTokenizer), - ) - base_parser = (getattr(base, "parser_class", Parser),) - base_generator = (getattr(base, "generator_class", Generator),) - - klass.tokenizer_class = klass.__dict__.get( - "Tokenizer", type("Tokenizer", base_tokenizer, {}) - ) - klass.jsonpath_tokenizer_class = klass.__dict__.get( - "JSONPathTokenizer", type("JSONPathTokenizer", base_jsonpath_tokenizer, {}) - ) - klass.parser_class = klass.__dict__.get( - "Parser", type("Parser", base_parser, {}) - ) - klass.generator_class = klass.__dict__.get( - "Generator", type("Generator", base_generator, {}) - ) - - klass.QUOTE_START, klass.QUOTE_END = list( - klass.tokenizer_class._QUOTES.items() - )[0] - klass.IDENTIFIER_START, klass.IDENTIFIER_END = list( - klass.tokenizer_class._IDENTIFIERS.items() - )[0] - - def get_start_end( - token_type: TokenType, - ) -> t.Tuple[t.Optional[str], t.Optional[str]]: - return next( - ( - (s, e) - for s, (e, t) in klass.tokenizer_class._FORMAT_STRINGS.items() - if t == token_type - ), - (None, None), - ) - - klass.BIT_START, klass.BIT_END = get_start_end(TokenType.BIT_STRING) - klass.HEX_START, klass.HEX_END = get_start_end(TokenType.HEX_STRING) - klass.BYTE_START, klass.BYTE_END = get_start_end(TokenType.BYTE_STRING) - klass.UNICODE_START, klass.UNICODE_END = get_start_end(TokenType.UNICODE_STRING) - - if "\\" in klass.tokenizer_class.STRING_ESCAPES: - klass.UNESCAPED_SEQUENCES = { - **UNESCAPED_SEQUENCES, - **klass.UNESCAPED_SEQUENCES, - } - - klass.ESCAPED_SEQUENCES = {v: k for k, v in klass.UNESCAPED_SEQUENCES.items()} - - klass.SUPPORTS_COLUMN_JOIN_MARKS = "(+)" in klass.tokenizer_class.KEYWORDS - - if enum not in ("", "bigquery", "snowflake"): - klass.INITCAP_SUPPORTS_CUSTOM_DELIMITERS = False - - if enum not in ("", "bigquery"): - klass.generator_class.SELECT_KINDS = () - - if enum not in ("", "athena", "presto", "trino", "duckdb"): - klass.generator_class.TRY_SUPPORTED = False - klass.generator_class.SUPPORTS_UESCAPE = False - - if enum not in ("", "databricks", "hive", "spark", "spark2"): - modifier_transforms = ( - klass.generator_class.AFTER_HAVING_MODIFIER_TRANSFORMS.copy() - ) - for modifier in ("cluster", "distribute", "sort"): - modifier_transforms.pop(modifier, None) - - klass.generator_class.AFTER_HAVING_MODIFIER_TRANSFORMS = modifier_transforms - - if enum not in ("", "doris", "mysql"): - klass.parser_class.ID_VAR_TOKENS = klass.parser_class.ID_VAR_TOKENS | { - TokenType.STRAIGHT_JOIN, - } - klass.parser_class.TABLE_ALIAS_TOKENS = ( - klass.parser_class.TABLE_ALIAS_TOKENS - | { - TokenType.STRAIGHT_JOIN, - } - ) - - if enum not in ("", "databricks", "oracle", "redshift", "snowflake", "spark"): - klass.generator_class.SUPPORTS_DECODE_CASE = False - - if not klass.SUPPORTS_SEMI_ANTI_JOIN: - klass.parser_class.TABLE_ALIAS_TOKENS = ( - klass.parser_class.TABLE_ALIAS_TOKENS - | { - TokenType.ANTI, - TokenType.SEMI, - } - ) - - if enum not in ( - "", - "postgres", - "duckdb", - "redshift", - "snowflake", - "presto", - "trino", - "mysql", - "singlestore", - ): - no_paren_functions = klass.parser_class.NO_PAREN_FUNCTIONS.copy() - no_paren_functions.pop(TokenType.LOCALTIME, None) - if enum != "oracle": - no_paren_functions.pop(TokenType.LOCALTIMESTAMP, None) - klass.parser_class.NO_PAREN_FUNCTIONS = no_paren_functions - - if enum in ( - "", - "postgres", - "duckdb", - "trino", - ): - no_paren_functions = klass.parser_class.NO_PAREN_FUNCTIONS.copy() - no_paren_functions[TokenType.CURRENT_CATALOG] = exp.CurrentCatalog - klass.parser_class.NO_PAREN_FUNCTIONS = no_paren_functions - else: - # For dialects that don't support this keyword, treat it as a regular identifier - # This fixes the "Unexpected token" error in BQ, Spark, etc. - klass.parser_class.ID_VAR_TOKENS = klass.parser_class.ID_VAR_TOKENS | { - TokenType.CURRENT_CATALOG, - } - - if enum in ( - "", - "duckdb", - "spark", - "postgres", - "tsql", - ): - no_paren_functions = klass.parser_class.NO_PAREN_FUNCTIONS.copy() - no_paren_functions[TokenType.SESSION_USER] = exp.SessionUser - klass.parser_class.NO_PAREN_FUNCTIONS = no_paren_functions - else: - klass.parser_class.ID_VAR_TOKENS = klass.parser_class.ID_VAR_TOKENS | { - TokenType.SESSION_USER, - } - - klass.VALID_INTERVAL_UNITS = { - *klass.VALID_INTERVAL_UNITS, - *klass.DATE_PART_MAPPING.keys(), - *klass.DATE_PART_MAPPING.values(), - } - - return klass - - -class Dialect(metaclass=_Dialect): - INDEX_OFFSET = 0 - """The base index offset for arrays.""" - - WEEK_OFFSET = 0 - """First day of the week in DATE_TRUNC(week). Defaults to 0 (Monday). -1 would be Sunday.""" - - UNNEST_COLUMN_ONLY = False - """Whether `UNNEST` table aliases are treated as column aliases.""" - - ALIAS_POST_TABLESAMPLE = False - """Whether the table alias comes after tablesample.""" - - TABLESAMPLE_SIZE_IS_PERCENT = False - """Whether a size in the table sample clause represents percentage.""" - - NORMALIZATION_STRATEGY = NormalizationStrategy.LOWERCASE - """Specifies the strategy according to which identifiers should be normalized.""" - - IDENTIFIERS_CAN_START_WITH_DIGIT = False - """Whether an unquoted identifier can start with a digit.""" - - DPIPE_IS_STRING_CONCAT = True - """Whether the DPIPE token (`||`) is a string concatenation operator.""" - - STRICT_STRING_CONCAT = False - """Whether `CONCAT`'s arguments must be strings.""" - - SUPPORTS_USER_DEFINED_TYPES = True - """Whether user-defined data types are supported.""" - - SUPPORTS_SEMI_ANTI_JOIN = True - """Whether `SEMI` or `ANTI` joins are supported.""" - - SUPPORTS_COLUMN_JOIN_MARKS = False - """Whether the old-style outer join (+) syntax is supported.""" - - COPY_PARAMS_ARE_CSV = True - """Separator of COPY statement parameters.""" - - NORMALIZE_FUNCTIONS: bool | str = "upper" - """ - Determines how function names are going to be normalized. - Possible values: - "upper" or True: Convert names to uppercase. - "lower": Convert names to lowercase. - False: Disables function name normalization. - """ - - PRESERVE_ORIGINAL_NAMES: bool = False - """ - Whether the name of the function should be preserved inside the node's metadata, - can be useful for roundtripping deprecated vs new functions that share an AST node - e.g JSON_VALUE vs JSON_EXTRACT_SCALAR in BigQuery - """ - - LOG_BASE_FIRST: t.Optional[bool] = True - """ - Whether the base comes first in the `LOG` function. - Possible values: `True`, `False`, `None` (two arguments are not supported by `LOG`) - """ - - NULL_ORDERING = "nulls_are_small" - """ - Default `NULL` ordering method to use if not explicitly set. - Possible values: `"nulls_are_small"`, `"nulls_are_large"`, `"nulls_are_last"` - """ - - TYPED_DIVISION = False - """ - Whether the behavior of `a / b` depends on the types of `a` and `b`. - False means `a / b` is always float division. - True means `a / b` is integer division if both `a` and `b` are integers. - """ - - SAFE_DIVISION = False - """Whether division by zero throws an error (`False`) or returns NULL (`True`).""" - - CONCAT_COALESCE = False - """A `NULL` arg in `CONCAT` yields `NULL` by default, but in some dialects it yields an empty string.""" - - HEX_LOWERCASE = False - """Whether the `HEX` function returns a lowercase hexadecimal string.""" - - DATE_FORMAT = "'%Y-%m-%d'" - DATEINT_FORMAT = "'%Y%m%d'" - TIME_FORMAT = "'%Y-%m-%d %H:%M:%S'" - - TIME_MAPPING: t.Dict[str, str] = {} - """Associates this dialect's time formats with their equivalent Python `strftime` formats.""" - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/format-elements#format_model_rules_date_time - # https://docs.teradata.com/r/Teradata-Database-SQL-Functions-Operators-Expressions-and-Predicates/March-2017/Data-Type-Conversions/Character-to-DATE-Conversion/Forcing-a-FORMAT-on-CAST-for-Converting-Character-to-DATE - FORMAT_MAPPING: t.Dict[str, str] = {} - """ - Helper which is used for parsing the special syntax `CAST(x AS DATE FORMAT 'yyyy')`. - If empty, the corresponding trie will be constructed off of `TIME_MAPPING`. - """ - - UNESCAPED_SEQUENCES: t.Dict[str, str] = {} - """Mapping of an escaped sequence (`\\n`) to its unescaped version (`\n`).""" - - PSEUDOCOLUMNS: t.Set[str] = set() - """ - Columns that are auto-generated by the engine corresponding to this dialect. - For example, such columns may be excluded from `SELECT *` queries. - """ - - PREFER_CTE_ALIAS_COLUMN = False - """ - Some dialects, such as Snowflake, allow you to reference a CTE column alias in the - HAVING clause of the CTE. This flag will cause the CTE alias columns to override - any projection aliases in the subquery. - - For example, - WITH y(c) AS ( - SELECT SUM(a) FROM (SELECT 1 a) AS x HAVING c > 0 - ) SELECT c FROM y; - - will be rewritten as - - WITH y(c) AS ( - SELECT SUM(a) AS c FROM (SELECT 1 AS a) AS x HAVING c > 0 - ) SELECT c FROM y; - """ - - COPY_PARAMS_ARE_CSV = True - """ - Whether COPY statement parameters are separated by comma or whitespace - """ - - FORCE_EARLY_ALIAS_REF_EXPANSION = False - """ - Whether alias reference expansion (_expand_alias_refs()) should run before column qualification (_qualify_columns()). - - For example: - WITH data AS ( - SELECT - 1 AS id, - 2 AS my_id - ) - SELECT - id AS my_id - FROM - data - WHERE - my_id = 1 - GROUP BY - my_id, - HAVING - my_id = 1 - - In most dialects, "my_id" would refer to "data.my_id" across the query, except: - - BigQuery, which will forward the alias to GROUP BY + HAVING clauses i.e - it resolves to "WHERE my_id = 1 GROUP BY id HAVING id = 1" - - Clickhouse, which will forward the alias across the query i.e it resolves - to "WHERE id = 1 GROUP BY id HAVING id = 1" - """ - - EXPAND_ONLY_GROUP_ALIAS_REF = False - """Whether alias reference expansion before qualification should only happen for the GROUP BY clause.""" - - ANNOTATE_ALL_SCOPES = False - """Whether to annotate all scopes during optimization. Used by BigQuery for UNNEST support.""" - - DISABLES_ALIAS_REF_EXPANSION = False - """ - Whether alias reference expansion is disabled for this dialect. - - Some dialects like Oracle do NOT support referencing aliases in projections or WHERE clauses. - The original expression must be repeated instead. - - For example, in Oracle: - SELECT y.foo AS bar, bar * 2 AS baz FROM y -- INVALID - SELECT y.foo AS bar, y.foo * 2 AS baz FROM y -- VALID - """ - - SUPPORTS_ALIAS_REFS_IN_JOIN_CONDITIONS = False - """ - Whether alias references are allowed in JOIN ... ON clauses. - - Most dialects do not support this, but Snowflake allows alias expansion in the JOIN ... ON - clause (and almost everywhere else) - - For example, in Snowflake: - SELECT a.id AS user_id FROM a JOIN b ON user_id = b.id -- VALID - - Reference: https://docs.snowflake.com/en/sql-reference/sql/select#usage-notes - """ - - SUPPORTS_ORDER_BY_ALL = False - """ - Whether ORDER BY ALL is supported (expands to all the selected columns) as in DuckDB, Spark3/Databricks - """ - - PROJECTION_ALIASES_SHADOW_SOURCE_NAMES = False - """ - Whether projection alias names can shadow table/source names in GROUP BY and HAVING clauses. - - In BigQuery, when a projection alias has the same name as a source table, the alias takes - precedence in GROUP BY and HAVING clauses, and the table becomes inaccessible by that name. - - For example, in BigQuery: - SELECT id, ARRAY_AGG(col) AS custom_fields - FROM custom_fields - GROUP BY id - HAVING id >= 1 - - The "custom_fields" source is shadowed by the projection alias, so we cannot qualify "id" - with "custom_fields" in GROUP BY/HAVING. - """ - - TABLES_REFERENCEABLE_AS_COLUMNS = False - """ - Whether table names can be referenced as columns (treated as structs). - - BigQuery allows tables to be referenced as columns in queries, automatically treating - them as struct values containing all the table's columns. - - For example, in BigQuery: - SELECT t FROM my_table AS t -- Returns entire row as a struct - """ - - SUPPORTS_STRUCT_STAR_EXPANSION = False - """ - Whether the dialect supports expanding struct fields using star notation (e.g., struct_col.*). - - BigQuery allows struct fields to be expanded with the star operator: - SELECT t.struct_col.* FROM table t - RisingWave also allows struct field expansion with the star operator using parentheses: - SELECT (t.struct_col).* FROM table t - - This expands to all fields within the struct. - """ - - EXCLUDES_PSEUDOCOLUMNS_FROM_STAR = False - """ - Whether pseudocolumns should be excluded from star expansion (SELECT *). - - Pseudocolumns are special dialect-specific columns (e.g., Oracle's ROWNUM, ROWID, LEVEL, - or BigQuery's _PARTITIONTIME, _PARTITIONDATE) that are implicitly available but not part - of the table schema. When this is True, SELECT * will not include these pseudocolumns; - they must be explicitly selected. - """ - - QUERY_RESULTS_ARE_STRUCTS = False - """ - Whether query results are typed as structs in metadata for type inference. - - In BigQuery, subqueries store their column types as a STRUCT in metadata, - enabling special type inference for ARRAY(SELECT ...) expressions: - ARRAY(SELECT x, y FROM t) → ARRAY> - - For single column subqueries, BigQuery unwraps the struct: - ARRAY(SELECT x FROM t) → ARRAY - - This is metadata-only for type inference. - """ - - REQUIRES_PARENTHESIZED_STRUCT_ACCESS = False - """ - Whether struct field access requires parentheses around the expression. - - RisingWave requires parentheses for struct field access in certain contexts: - SELECT (col.field).subfield FROM table -- Parentheses required - - Without parentheses, the parser may not correctly interpret nested struct access. - - Reference: https://docs.risingwave.com/sql/data-types/struct#retrieve-data-in-a-struct - """ - - SUPPORTS_NULL_TYPE = False - """ - Whether NULL/VOID is supported as a valid data type (not just a value). - - Databricks and Spark v3+ support NULL as an actual type, allowing expressions like: - SELECT NULL AS col -- Has type NULL, not just value NULL - CAST(x AS VOID) -- Valid type cast - """ - - COALESCE_COMPARISON_NON_STANDARD = False - """ - Whether COALESCE in comparisons has non-standard NULL semantics. - - We can't convert `COALESCE(x, 1) = 2` into `NOT x IS NULL AND x = 2` for redshift, - because they are not always equivalent. For example, if `x` is `NULL` and it comes - from a table, then the result is `NULL`, despite `FALSE AND NULL` evaluating to `FALSE`. - - In standard SQL and most dialects, these expressions are equivalent, but Redshift treats - table NULLs differently in this context. - """ - - HAS_DISTINCT_ARRAY_CONSTRUCTORS = False - """ - Whether the ARRAY constructor is context-sensitive, i.e in Redshift ARRAY[1, 2, 3] != ARRAY(1, 2, 3) - as the former is of type INT[] vs the latter which is SUPER - """ - - SUPPORTS_FIXED_SIZE_ARRAYS = False - """ - Whether expressions such as x::INT[5] should be parsed as fixed-size array defs/casts e.g. - in DuckDB. In dialects which don't support fixed size arrays such as Snowflake, this should - be interpreted as a subscript/index operator. - """ - - STRICT_JSON_PATH_SYNTAX = True - """Whether failing to parse a JSON path expression using the JSONPath dialect will log a warning.""" - - ON_CONDITION_EMPTY_BEFORE_ERROR = True - """Whether "X ON EMPTY" should come before "X ON ERROR" (for dialects like T-SQL, MySQL, Oracle).""" - - ARRAY_AGG_INCLUDES_NULLS: t.Optional[bool] = True - """Whether ArrayAgg needs to filter NULL values.""" - - PROMOTE_TO_INFERRED_DATETIME_TYPE = False - """ - This flag is used in the optimizer's canonicalize rule and determines whether x will be promoted - to the literal's type in x::DATE < '2020-01-01 12:05:03' (i.e., DATETIME). When false, the literal - is cast to x's type to match it instead. - """ - - SUPPORTS_VALUES_DEFAULT = True - """Whether the DEFAULT keyword is supported in the VALUES clause.""" - - NUMBERS_CAN_BE_UNDERSCORE_SEPARATED = False - """Whether number literals can include underscores for better readability""" - - HEX_STRING_IS_INTEGER_TYPE: bool = False - """Whether hex strings such as x'CC' evaluate to integer or binary/blob type""" - - REGEXP_EXTRACT_DEFAULT_GROUP = 0 - """The default value for the capturing group.""" - - REGEXP_EXTRACT_POSITION_OVERFLOW_RETURNS_NULL = True - """Whether REGEXP_EXTRACT returns NULL when the position arg exceeds the string length.""" - - SET_OP_DISTINCT_BY_DEFAULT: t.Dict[t.Type[exp.Expression], t.Optional[bool]] = { - exp.Except: True, - exp.Intersect: True, - exp.Union: True, - } - """ - Whether a set operation uses DISTINCT by default. This is `None` when either `DISTINCT` or `ALL` - must be explicitly specified. - """ - - CREATABLE_KIND_MAPPING: dict[str, str] = {} - """ - Helper for dialects that use a different name for the same creatable kind. For example, the Clickhouse - equivalent of CREATE SCHEMA is CREATE DATABASE. - """ - - ALTER_TABLE_SUPPORTS_CASCADE = False - """ - Hive by default does not update the schema of existing partitions when a column is changed. - the CASCADE clause is used to indicate that the change should be propagated to all existing partitions. - the Spark dialect, while derived from Hive, does not support the CASCADE clause. - """ - - # Whether ADD is present for each column added by ALTER TABLE - ALTER_TABLE_ADD_REQUIRED_FOR_EACH_COLUMN = True - - # Whether the value/LHS of the TRY_CAST( AS ) should strictly be a - # STRING type (Snowflake's case) or can be of any type - TRY_CAST_REQUIRES_STRING: t.Optional[bool] = None - - # Whether the double negation can be applied - # Not safe with MySQL and SQLite due to type coercion (may not return boolean) - SAFE_TO_ELIMINATE_DOUBLE_NEGATION = True - - # Whether the INITCAP function supports custom delimiter characters as the second argument - # Default delimiter characters for INITCAP function: whitespace and non-alphanumeric characters - INITCAP_SUPPORTS_CUSTOM_DELIMITERS = True - INITCAP_DEFAULT_DELIMITER_CHARS = ( - " \t\n\r\f\v!\"#$%&'()*+,\\-./:;<=>?@\\[\\]^_`{|}~" - ) - - BYTE_STRING_IS_BYTES_TYPE: bool = False - """ - Whether byte string literals (ex: BigQuery's b'...') are typed as BYTES/BINARY - """ - - UUID_IS_STRING_TYPE: bool = False - """ - Whether a UUID is considered a string or a UUID type. - """ - - JSON_EXTRACT_SCALAR_SCALAR_ONLY = False - """ - Whether JSON_EXTRACT_SCALAR returns null if a non-scalar value is selected. - """ - - DEFAULT_FUNCTIONS_COLUMN_NAMES: t.Dict[ - t.Type[exp.Func], t.Union[str, t.Tuple[str, ...]] - ] = {} - """ - Maps function expressions to their default output column name(s). - - For example, in Postgres, generate_series function outputs a column named "generate_series" by default, - so we map the ExplodingGenerateSeries expression to "generate_series" string. - """ - - DEFAULT_NULL_TYPE = exp.DataType.Type.UNKNOWN - """ - The default type of NULL for producing the correct projection type. - - For example, in BigQuery the default type of the NULL value is INT64. - """ - - LEAST_GREATEST_IGNORES_NULLS = True - """ - Whether LEAST/GREATEST functions ignore NULL values, e.g: - - BigQuery, Snowflake, MySQL, Presto/Trino: LEAST(1, NULL, 2) -> NULL - - Spark, Postgres, DuckDB, TSQL: LEAST(1, NULL, 2) -> 1 - """ - - PRIORITIZE_NON_LITERAL_TYPES = False - """ - Whether to prioritize non-literal types over literals during type annotation. - """ - - # --- Autofilled --- - - tokenizer_class = Tokenizer - jsonpath_tokenizer_class = JSONPathTokenizer - parser_class = Parser - generator_class = Generator - - # A trie of the time_mapping keys - TIME_TRIE: t.Dict = {} - FORMAT_TRIE: t.Dict = {} - - INVERSE_TIME_MAPPING: t.Dict[str, str] = {} - INVERSE_TIME_TRIE: t.Dict = {} - INVERSE_FORMAT_MAPPING: t.Dict[str, str] = {} - INVERSE_FORMAT_TRIE: t.Dict = {} - - INVERSE_CREATABLE_KIND_MAPPING: dict[str, str] = {} - - ESCAPED_SEQUENCES: t.Dict[str, str] = {} - - # Delimiters for string literals and identifiers - QUOTE_START = "'" - QUOTE_END = "'" - IDENTIFIER_START = '"' - IDENTIFIER_END = '"' - - VALID_INTERVAL_UNITS: t.Set[str] = set() - - # Delimiters for bit, hex, byte and unicode literals - BIT_START: t.Optional[str] = None - BIT_END: t.Optional[str] = None - HEX_START: t.Optional[str] = None - HEX_END: t.Optional[str] = None - BYTE_START: t.Optional[str] = None - BYTE_END: t.Optional[str] = None - UNICODE_START: t.Optional[str] = None - UNICODE_END: t.Optional[str] = None - - DATE_PART_MAPPING = { - "Y": "YEAR", - "YY": "YEAR", - "YYY": "YEAR", - "YYYY": "YEAR", - "YR": "YEAR", - "YEARS": "YEAR", - "YRS": "YEAR", - "MM": "MONTH", - "MON": "MONTH", - "MONS": "MONTH", - "MONTHS": "MONTH", - "D": "DAY", - "DD": "DAY", - "DAYS": "DAY", - "DAYOFMONTH": "DAY", - "DAY OF WEEK": "DAYOFWEEK", - "WEEKDAY": "DAYOFWEEK", - "DOW": "DAYOFWEEK", - "DW": "DAYOFWEEK", - "WEEKDAY_ISO": "DAYOFWEEKISO", - "DOW_ISO": "DAYOFWEEKISO", - "DW_ISO": "DAYOFWEEKISO", - "DAYOFWEEK_ISO": "DAYOFWEEKISO", - "DAY OF YEAR": "DAYOFYEAR", - "DOY": "DAYOFYEAR", - "DY": "DAYOFYEAR", - "W": "WEEK", - "WK": "WEEK", - "WEEKOFYEAR": "WEEK", - "WOY": "WEEK", - "WY": "WEEK", - "WEEK_ISO": "WEEKISO", - "WEEKOFYEARISO": "WEEKISO", - "WEEKOFYEAR_ISO": "WEEKISO", - "Q": "QUARTER", - "QTR": "QUARTER", - "QTRS": "QUARTER", - "QUARTERS": "QUARTER", - "H": "HOUR", - "HH": "HOUR", - "HR": "HOUR", - "HOURS": "HOUR", - "HRS": "HOUR", - "M": "MINUTE", - "MI": "MINUTE", - "MIN": "MINUTE", - "MINUTES": "MINUTE", - "MINS": "MINUTE", - "S": "SECOND", - "SEC": "SECOND", - "SECONDS": "SECOND", - "SECS": "SECOND", - "MS": "MILLISECOND", - "MSEC": "MILLISECOND", - "MSECS": "MILLISECOND", - "MSECOND": "MILLISECOND", - "MSECONDS": "MILLISECOND", - "MILLISEC": "MILLISECOND", - "MILLISECS": "MILLISECOND", - "MILLISECON": "MILLISECOND", - "MILLISECONDS": "MILLISECOND", - "US": "MICROSECOND", - "USEC": "MICROSECOND", - "USECS": "MICROSECOND", - "MICROSEC": "MICROSECOND", - "MICROSECS": "MICROSECOND", - "USECOND": "MICROSECOND", - "USECONDS": "MICROSECOND", - "MICROSECONDS": "MICROSECOND", - "NS": "NANOSECOND", - "NSEC": "NANOSECOND", - "NANOSEC": "NANOSECOND", - "NSECOND": "NANOSECOND", - "NSECONDS": "NANOSECOND", - "NANOSECS": "NANOSECOND", - "EPOCH_SECOND": "EPOCH", - "EPOCH_SECONDS": "EPOCH", - "EPOCH_MILLISECONDS": "EPOCH_MILLISECOND", - "EPOCH_MICROSECONDS": "EPOCH_MICROSECOND", - "EPOCH_NANOSECONDS": "EPOCH_NANOSECOND", - "TZH": "TIMEZONE_HOUR", - "TZM": "TIMEZONE_MINUTE", - "DEC": "DECADE", - "DECS": "DECADE", - "DECADES": "DECADE", - "MIL": "MILLENNIUM", - "MILS": "MILLENNIUM", - "MILLENIA": "MILLENNIUM", - "C": "CENTURY", - "CENT": "CENTURY", - "CENTS": "CENTURY", - "CENTURIES": "CENTURY", - } - - # Specifies what types a given type can be coerced into - COERCES_TO: t.Dict[exp.DataType.Type, t.Set[exp.DataType.Type]] = {} - - # Specifies type inference & validation rules for expressions - EXPRESSION_METADATA = EXPRESSION_METADATA.copy() - - # Determines the supported Dialect instance settings - SUPPORTED_SETTINGS = { - "normalization_strategy", - "version", - } - - @classmethod - def get_or_raise(cls, dialect: DialectType) -> Dialect: - """ - Look up a dialect in the global dialect registry and return it if it exists. - - Args: - dialect: The target dialect. If this is a string, it can be optionally followed by - additional key-value pairs that are separated by commas and are used to specify - dialect settings, such as whether the dialect's identifiers are case-sensitive. - - Example: - >>> dialect = dialect_class = get_or_raise("duckdb") - >>> dialect = get_or_raise("mysql, normalization_strategy = case_sensitive") - - Returns: - The corresponding Dialect instance. - """ - - if not dialect: - return cls() - if isinstance(dialect, _Dialect): - return dialect() - if isinstance(dialect, Dialect): - return dialect - if isinstance(dialect, str): - try: - dialect_name, *kv_strings = dialect.split(",") - kv_pairs = (kv.split("=") for kv in kv_strings) - kwargs = {} - for pair in kv_pairs: - key = pair[0].strip() - value: t.Union[bool | str | None] = None - - if len(pair) == 1: - # Default initialize standalone settings to True - value = True - elif len(pair) == 2: - value = pair[1].strip() - - kwargs[key] = to_bool(value) - - except ValueError: - raise ValueError( - f"Invalid dialect format: '{dialect}'. " - "Please use the correct format: 'dialect [, k1 = v2 [, ...]]'." - ) - - result = cls.get(dialect_name.strip()) - if not result: - suggest_closest_match_and_fail( - "dialect", dialect_name, list(DIALECT_MODULE_NAMES) - ) - - assert result is not None - return result(**kwargs) - - raise ValueError(f"Invalid dialect type for '{dialect}': '{type(dialect)}'.") - - @classmethod - def format_time( - cls, expression: t.Optional[str | exp.Expression] - ) -> t.Optional[exp.Expression]: - """Converts a time format in this dialect to its equivalent Python `strftime` format.""" - if isinstance(expression, str): - return exp.Literal.string( - # the time formats are quoted - format_time(expression[1:-1], cls.TIME_MAPPING, cls.TIME_TRIE) - ) - - if expression and expression.is_string: - return exp.Literal.string( - format_time(expression.this, cls.TIME_MAPPING, cls.TIME_TRIE) - ) - - return expression - - def __init__(self, **kwargs) -> None: - parts = str(kwargs.pop("version", sys.maxsize)).split(".") - parts.extend(["0"] * (3 - len(parts))) - self.version = tuple(int(p) for p in parts[:3]) - - normalization_strategy = kwargs.pop("normalization_strategy", None) - if normalization_strategy is None: - self.normalization_strategy = self.NORMALIZATION_STRATEGY - else: - self.normalization_strategy = NormalizationStrategy( - normalization_strategy.upper() - ) - - self.settings = kwargs - - for unsupported_setting in kwargs.keys() - self.SUPPORTED_SETTINGS: - suggest_closest_match_and_fail( - "setting", unsupported_setting, self.SUPPORTED_SETTINGS - ) - - def __eq__(self, other: t.Any) -> bool: - # Does not currently take dialect state into account - return isinstance(self, other.__class__) - - def __hash__(self) -> int: - # Does not currently take dialect state into account - return hash(type(self)) - - def normalize_identifier(self, expression: E) -> E: - """ - Transforms an identifier in a way that resembles how it'd be resolved by this dialect. - - For example, an identifier like `FoO` would be resolved as `foo` in Postgres, because it - lowercases all unquoted identifiers. On the other hand, Snowflake uppercases them, so - it would resolve it as `FOO`. If it was quoted, it'd need to be treated as case-sensitive, - and so any normalization would be prohibited in order to avoid "breaking" the identifier. - - There are also dialects like Spark, which are case-insensitive even when quotes are - present, and dialects like MySQL, whose resolution rules match those employed by the - underlying operating system, for example they may always be case-sensitive in Linux. - - Finally, the normalization behavior of some engines can even be controlled through flags, - like in Redshift's case, where users can explicitly set enable_case_sensitive_identifier. - - SQLGlot aims to understand and handle all of these different behaviors gracefully, so - that it can analyze queries in the optimizer and successfully capture their semantics. - """ - if ( - isinstance(expression, exp.Identifier) - and self.normalization_strategy is not NormalizationStrategy.CASE_SENSITIVE - and ( - not expression.quoted - or self.normalization_strategy - in ( - NormalizationStrategy.CASE_INSENSITIVE, - NormalizationStrategy.CASE_INSENSITIVE_UPPERCASE, - ) - ) - ): - normalized = ( - expression.this.upper() - if self.normalization_strategy - in ( - NormalizationStrategy.UPPERCASE, - NormalizationStrategy.CASE_INSENSITIVE_UPPERCASE, - ) - else expression.this.lower() - ) - expression.set("this", normalized) - - return expression - - def case_sensitive(self, text: str) -> bool: - """Checks if text contains any case sensitive characters, based on the dialect's rules.""" - if self.normalization_strategy is NormalizationStrategy.CASE_INSENSITIVE: - return False - - unsafe = ( - str.islower - if self.normalization_strategy is NormalizationStrategy.UPPERCASE - else str.isupper - ) - return any(unsafe(char) for char in text) - - def can_quote( - self, identifier: exp.Identifier, identify: str | bool = "safe" - ) -> bool: - """Checks if an identifier can be quoted - - Args: - identifier: The identifier to check. - identify: - `True`: Always returns `True` except for certain cases. - `"safe"`: Only returns `True` if the identifier is case-insensitive. - `"unsafe"`: Only returns `True` if the identifier is case-sensitive. - - Returns: - Whether the given text can be identified. - """ - if identifier.quoted: - return True - if not identify: - return False - if isinstance(identifier.parent, exp.Func): - return False - if identify is True: - return True - - is_safe = not self.case_sensitive(identifier.this) and bool( - exp.SAFE_IDENTIFIER_RE.match(identifier.this) - ) - - if identify == "safe": - return is_safe - if identify == "unsafe": - return not is_safe - - raise ValueError(f"Unexpected argument for identify: '{identify}'") - - def quote_identifier(self, expression: E, identify: bool = True) -> E: - """ - Adds quotes to a given expression if it is an identifier. - - Args: - expression: The expression of interest. If it's not an `Identifier`, this method is a no-op. - identify: If set to `False`, the quotes will only be added if the identifier is deemed - "unsafe", with respect to its characters and this dialect's normalization strategy. - """ - if isinstance(expression, exp.Identifier): - expression.set("quoted", self.can_quote(expression, identify or "unsafe")) - return expression - - def to_json_path( - self, path: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if isinstance(path, exp.Literal): - path_text = path.name - if path.is_number: - path_text = f"[{path_text}]" - try: - return parse_json_path(path_text, self) - except ParseError as e: - if self.STRICT_JSON_PATH_SYNTAX and not path_text.lstrip().startswith( - ("lax", "strict") - ): - logger.warning(f"Invalid JSON path syntax. {str(e)}") - - return path - - def parse(self, sql: str, **opts) -> t.List[t.Optional[exp.Expression]]: - return self.parser(**opts).parse(self.tokenize(sql), sql) - - def parse_into( - self, expression_type: exp.IntoType, sql: str, **opts - ) -> t.List[t.Optional[exp.Expression]]: - return self.parser(**opts).parse_into(expression_type, self.tokenize(sql), sql) - - def generate(self, expression: exp.Expression, copy: bool = True, **opts) -> str: - return self.generator(**opts).generate(expression, copy=copy) - - def transpile(self, sql: str, **opts) -> t.List[str]: - return [ - self.generate(expression, copy=False, **opts) if expression else "" - for expression in self.parse(sql) - ] - - def tokenize(self, sql: str, **opts) -> t.List[Token]: - return self.tokenizer(**opts).tokenize(sql) - - def tokenizer(self, **opts) -> Tokenizer: - return self.tokenizer_class(**{"dialect": self, **opts}) - - def jsonpath_tokenizer(self, **opts) -> JSONPathTokenizer: - return self.jsonpath_tokenizer_class(**{"dialect": self, **opts}) - - def parser(self, **opts) -> Parser: - return self.parser_class(**{"dialect": self, **opts}) - - def generator(self, **opts) -> Generator: - return self.generator_class(**{"dialect": self, **opts}) - - def generate_values_aliases(self, expression: exp.Values) -> t.List[exp.Identifier]: - return [ - exp.to_identifier(f"_col_{i}") - for i, _ in enumerate(expression.expressions[0].expressions) - ] - - -DialectType = t.Union[str, Dialect, t.Type[Dialect], None] - - -def rename_func(name: str) -> t.Callable[[Generator, exp.Expression], str]: - return lambda self, expression: self.func(name, *flatten(expression.args.values())) - - -@unsupported_args("accuracy") -def approx_count_distinct_sql(self: Generator, expression: exp.ApproxDistinct) -> str: - return self.func("APPROX_COUNT_DISTINCT", expression.this) - - -def if_sql( - name: str = "IF", false_value: t.Optional[exp.Expression | str] = None -) -> t.Callable[[Generator, exp.If], str]: - def _if_sql(self: Generator, expression: exp.If) -> str: - return self.func( - name, - expression.this, - expression.args.get("true"), - expression.args.get("false") or false_value, - ) - - return _if_sql - - -def arrow_json_extract_sql(self: Generator, expression: JSON_EXTRACT_TYPE) -> str: - this = expression.this - if ( - self.JSON_TYPE_REQUIRED_FOR_EXTRACTION - and isinstance(this, exp.Literal) - and this.is_string - ): - this.replace(exp.cast(this, exp.DataType.Type.JSON)) - - return self.binary( - expression, "->" if isinstance(expression, exp.JSONExtract) else "->>" - ) - - -def inline_array_sql(self: Generator, expression: exp.Expression) -> str: - return f"[{self.expressions(expression, dynamic=True, new_line=True, skip_first=True, skip_last=True)}]" - - -def inline_array_unless_query(self: Generator, expression: exp.Expression) -> str: - elem = seq_get(expression.expressions, 0) - if ( - len(expression.expressions) == 1 - and isinstance(elem, exp.Expression) - and ( - isinstance(elem, exp.Query) - or (isinstance(elem, exp.Subquery) and isinstance(elem.this, exp.Query)) - ) - ): - return self.func("ARRAY", elem) - return inline_array_sql(self, expression) - - -def no_ilike_sql(self: Generator, expression: exp.ILike) -> str: - return self.like_sql( - exp.Like( - this=exp.Lower(this=expression.this), - expression=exp.Lower(this=expression.expression), - ) - ) - - -def no_paren_current_date_sql(self: Generator, expression: exp.CurrentDate) -> str: - zone = self.sql(expression, "this") - return f"CURRENT_DATE AT TIME ZONE {zone}" if zone else "CURRENT_DATE" - - -def no_recursive_cte_sql(self: Generator, expression: exp.With) -> str: - if expression.args.get("recursive"): - self.unsupported("Recursive CTEs are unsupported") - expression.set("recursive", False) - return self.with_sql(expression) - - -def no_tablesample_sql(self: Generator, expression: exp.TableSample) -> str: - self.unsupported("TABLESAMPLE unsupported") - return self.sql(expression.this) - - -def no_pivot_sql(self: Generator, expression: exp.Pivot) -> str: - self.unsupported("PIVOT unsupported") - return "" - - -def no_trycast_sql(self: Generator, expression: exp.TryCast) -> str: - return self.cast_sql(expression) - - -def no_comment_column_constraint_sql( - self: Generator, expression: exp.CommentColumnConstraint -) -> str: - self.unsupported("CommentColumnConstraint unsupported") - return "" - - -def no_map_from_entries_sql(self: Generator, expression: exp.MapFromEntries) -> str: - self.unsupported("MAP_FROM_ENTRIES unsupported") - return "" - - -def property_sql(self: Generator, expression: exp.Property) -> str: - return f"{self.property_name(expression, string_key=True)}={self.sql(expression, 'value')}" - - -def strposition_sql( - self: Generator, - expression: exp.StrPosition, - func_name: str = "STRPOS", - supports_position: bool = False, - supports_occurrence: bool = False, - use_ansi_position: bool = True, -) -> str: - string = expression.this - substr = expression.args.get("substr") - position = expression.args.get("position") - occurrence = expression.args.get("occurrence") - zero = exp.Literal.number(0) - one = exp.Literal.number(1) - - if supports_occurrence and occurrence and supports_position and not position: - position = one - - transpile_position = position and not supports_position - if transpile_position: - string = exp.Substring(this=string, start=position) - - if func_name == "POSITION" and use_ansi_position: - func = exp.Anonymous( - this=func_name, expressions=[exp.In(this=substr, field=string)] - ) - else: - args = ( - [substr, string] - if func_name in ("LOCATE", "CHARINDEX") - else [string, substr] - ) - if supports_position: - args.append(position) - if occurrence: - if supports_occurrence: - args.append(occurrence) - else: - self.unsupported( - f"{func_name} does not support the occurrence parameter." - ) - func = exp.Anonymous(this=func_name, expressions=args) - - if transpile_position: - func_with_offset = exp.Sub(this=func + position, expression=one) - func_wrapped = exp.If(this=func.eq(zero), true=zero, false=func_with_offset) - return self.sql(func_wrapped) - - return self.sql(func) - - -def struct_extract_sql(self: Generator, expression: exp.StructExtract) -> str: - return f"{self.sql(expression, 'this')}.{self.sql(exp.to_identifier(expression.expression.name))}" - - -def var_map_sql( - self: Generator, expression: exp.Map | exp.VarMap, map_func_name: str = "MAP" -) -> str: - keys = expression.args.get("keys") - values = expression.args.get("values") - - if not isinstance(keys, exp.Array) or not isinstance(values, exp.Array): - self.unsupported("Cannot convert array columns into map.") - return self.func(map_func_name, keys, values) - - args = [] - for key, value in zip(keys.expressions, values.expressions): - args.append(self.sql(key)) - args.append(self.sql(value)) - - return self.func(map_func_name, *args) - - -def months_between_sql(self: Generator, expression: exp.MonthsBetween) -> str: - """ - Transpile MONTHS_BETWEEN to dialects that don't have native support. - - Snowflake's MONTHS_BETWEEN returns whole months + fractional part where: - - Fractional part = (DAY(date1) - DAY(date2)) / 31 - - Special case: If both dates are last day of month, fractional part = 0 - - Formula: DATEDIFF('month', date2, date1) + (DAY(date1) - DAY(date2)) / 31.0 - """ - date1 = expression.this - date2 = expression.expression - - # Cast to DATE to ensure consistent behavior - date1_cast = exp.cast(date1, exp.DataType.Type.DATE, copy=False) - date2_cast = exp.cast(date2, exp.DataType.Type.DATE, copy=False) - - # Whole months: DATEDIFF('month', date2, date1) - whole_months = exp.DateDiff( - this=date1_cast, expression=date2_cast, unit=exp.var("month") - ) - - # Day components - day1 = exp.Day(this=date1_cast.copy()) - day2 = exp.Day(this=date2_cast.copy()) - - # Last day of month components - last_day_of_month1 = exp.LastDay(this=date1_cast.copy()) - last_day_of_month2 = exp.LastDay(this=date2_cast.copy()) - - day_of_last_day1 = exp.Day(this=last_day_of_month1) - day_of_last_day2 = exp.Day(this=last_day_of_month2) - - # Check if both are last day of month - last_day1 = exp.EQ(this=day1.copy(), expression=day_of_last_day1) - last_day2 = exp.EQ(this=day2.copy(), expression=day_of_last_day2) - both_last_day = exp.And(this=last_day1, expression=last_day2) - - # Fractional part: (DAY(date1) - DAY(date2)) / 31.0 - fractional = exp.Div( - this=exp.Paren(this=exp.Sub(this=day1.copy(), expression=day2.copy())), - expression=exp.Literal.number("31.0"), - ) - - # If both are last day of month, fractional = 0, else calculate fractional - fractional_with_check = exp.If( - this=both_last_day, true=exp.Literal.number("0"), false=fractional - ) - - # Final result: whole_months + fractional - result = exp.Add(this=whole_months, expression=fractional_with_check) - - return self.sql(result) - - -def build_formatted_time( - exp_class: t.Type[E], dialect: str, default: t.Optional[bool | str] = None -) -> t.Callable[[t.List], E]: - """Helper used for time expressions. - - Args: - exp_class: the expression class to instantiate. - dialect: target sql dialect. - default: the default format, True being time. - - Returns: - A callable that can be used to return the appropriately formatted time expression. - """ - - def _builder(args: t.List): - return exp_class( - this=seq_get(args, 0), - format=Dialect[dialect].format_time( - seq_get(args, 1) - or ( - Dialect[dialect].TIME_FORMAT if default is True else default or None - ) - ), - ) - - return _builder - - -def time_format( - dialect: DialectType = None, -) -> t.Callable[[Generator, exp.UnixToStr | exp.StrToUnix], t.Optional[str]]: - def _time_format( - self: Generator, expression: exp.UnixToStr | exp.StrToUnix - ) -> t.Optional[str]: - """ - Returns the time format for a given expression, unless it's equivalent - to the default time format of the dialect of interest. - """ - time_format = self.format_time(expression) - return ( - time_format - if time_format != Dialect.get_or_raise(dialect).TIME_FORMAT - else None - ) - - return _time_format - - -def build_date_delta( - exp_class: t.Type[E], - unit_mapping: t.Optional[t.Dict[str, str]] = None, - default_unit: t.Optional[str] = "DAY", - supports_timezone: bool = False, -) -> t.Callable[[t.List], E]: - def _builder(args: t.List) -> E: - unit_based = len(args) >= 3 - has_timezone = len(args) == 4 - this = args[2] if unit_based else seq_get(args, 0) - unit = None - if unit_based or default_unit: - unit = args[0] if unit_based else exp.Literal.string(default_unit) - unit = ( - exp.var(unit_mapping.get(unit.name.lower(), unit.name)) - if unit_mapping - else unit - ) - expression = exp_class(this=this, expression=seq_get(args, 1), unit=unit) - if supports_timezone and has_timezone: - expression.set("zone", args[-1]) - return expression - - return _builder - - -def build_date_delta_with_interval( - expression_class: t.Type[E], -) -> t.Callable[[t.List], t.Optional[E]]: - def _builder(args: t.List) -> t.Optional[E]: - if len(args) < 2: - return None - - interval = args[1] - - if not isinstance(interval, exp.Interval): - raise ParseError(f"INTERVAL expression expected but got '{interval}'") - - return expression_class( - this=args[0], expression=interval.this, unit=unit_to_str(interval) - ) - - return _builder - - -def date_trunc_to_time(args: t.List) -> exp.DateTrunc | exp.TimestampTrunc: - unit = seq_get(args, 0) - this = seq_get(args, 1) - - if isinstance(this, exp.Cast) and this.is_type("date"): - return exp.DateTrunc(unit=unit, this=this) - return exp.TimestampTrunc(this=this, unit=unit) - - -def date_add_interval_sql( - data_type: str, kind: str -) -> t.Callable[[Generator, exp.Expression], str]: - def func(self: Generator, expression: exp.Expression) -> str: - this = self.sql(expression, "this") - interval = exp.Interval( - this=expression.expression, unit=unit_to_var(expression) - ) - return f"{data_type}_{kind}({this}, {self.sql(interval)})" - - return func - - -def timestamptrunc_sql( - func: str = "DATE_TRUNC", zone: bool = False -) -> t.Callable[[Generator, exp.TimestampTrunc], str]: - def _timestamptrunc_sql(self: Generator, expression: exp.TimestampTrunc) -> str: - args = [unit_to_str(expression), expression.this] - if zone: - args.append(expression.args.get("zone")) - return self.func(func, *args) - - return _timestamptrunc_sql - - -def no_timestamp_sql(self: Generator, expression: exp.Timestamp) -> str: - zone = expression.args.get("zone") - if not zone: - from sqlglot.optimizer.annotate_types import annotate_types - - target_type = ( - annotate_types(expression, dialect=self.dialect).type - or exp.DataType.Type.TIMESTAMP - ) - return self.sql(exp.cast(expression.this, target_type)) - if zone.name.lower() in TIMEZONES: - return self.sql( - exp.AtTimeZone( - this=exp.cast(expression.this, exp.DataType.Type.TIMESTAMP), - zone=zone, - ) - ) - return self.func("TIMESTAMP", expression.this, zone) - - -def no_time_sql(self: Generator, expression: exp.Time) -> str: - # Transpile BQ's TIME(timestamp, zone) to CAST(TIMESTAMPTZ AT TIME ZONE AS TIME) - this = exp.cast(expression.this, exp.DataType.Type.TIMESTAMPTZ) - expr = exp.cast( - exp.AtTimeZone(this=this, zone=expression.args.get("zone")), - exp.DataType.Type.TIME, - ) - return self.sql(expr) - - -def no_datetime_sql(self: Generator, expression: exp.Datetime) -> str: - this = expression.this - expr = expression.expression - - if expr.name.lower() in TIMEZONES: - # Transpile BQ's DATETIME(timestamp, zone) to CAST(TIMESTAMPTZ AT TIME ZONE AS TIMESTAMP) - this = exp.cast(this, exp.DataType.Type.TIMESTAMPTZ) - this = exp.cast( - exp.AtTimeZone(this=this, zone=expr), exp.DataType.Type.TIMESTAMP - ) - return self.sql(this) - - this = exp.cast(this, exp.DataType.Type.DATE) - expr = exp.cast(expr, exp.DataType.Type.TIME) - - return self.sql( - exp.cast(exp.Add(this=this, expression=expr), exp.DataType.Type.TIMESTAMP) - ) - - -def left_to_substring_sql(self: Generator, expression: exp.Left) -> str: - return self.sql( - exp.Substring( - this=expression.this, - start=exp.Literal.number(1), - length=expression.expression, - ) - ) - - -def right_to_substring_sql(self: Generator, expression: exp.Left) -> str: - return self.sql( - exp.Substring( - this=expression.this, - start=exp.Length(this=expression.this) - - exp.paren(expression.expression - 1), - ) - ) - - -def timestrtotime_sql( - self: Generator, - expression: exp.TimeStrToTime, - include_precision: bool = False, -) -> str: - datatype = exp.DataType.build( - exp.DataType.Type.TIMESTAMPTZ - if expression.args.get("zone") - else exp.DataType.Type.TIMESTAMP - ) - - if isinstance(expression.this, exp.Literal) and include_precision: - precision = subsecond_precision(expression.this.name) - if precision > 0: - datatype = exp.DataType.build( - datatype.this, - expressions=[exp.DataTypeParam(this=exp.Literal.number(precision))], - ) - - return self.sql(exp.cast(expression.this, datatype, dialect=self.dialect)) - - -def datestrtodate_sql(self: Generator, expression: exp.DateStrToDate) -> str: - return self.sql(exp.cast(expression.this, exp.DataType.Type.DATE)) - - -# Used for Presto and Duckdb which use functions that don't support charset, and assume utf-8 -def encode_decode_sql( - self: Generator, expression: exp.Expression, name: str, replace: bool = True -) -> str: - charset = expression.args.get("charset") - if charset and charset.name.lower() != "utf-8": - self.unsupported(f"Expected utf-8 character set, got {charset}.") - - return self.func( - name, expression.this, expression.args.get("replace") if replace else None - ) - - -def min_or_least(self: Generator, expression: exp.Min) -> str: - name = "LEAST" if expression.expressions else "MIN" - return rename_func(name)(self, expression) - - -def max_or_greatest(self: Generator, expression: exp.Max) -> str: - name = "GREATEST" if expression.expressions else "MAX" - return rename_func(name)(self, expression) - - -def count_if_to_sum(self: Generator, expression: exp.CountIf) -> str: - cond = expression.this - - if isinstance(expression.this, exp.Distinct): - cond = expression.this.expressions[0] - self.unsupported("DISTINCT is not supported when converting COUNT_IF to SUM") - - return self.func("sum", exp.func("if", cond, 1, 0)) - - -def trim_sql(self: Generator, expression: exp.Trim, default_trim_type: str = "") -> str: - target = self.sql(expression, "this") - trim_type = self.sql(expression, "position") or default_trim_type - remove_chars = self.sql(expression, "expression") - collation = self.sql(expression, "collation") - - # Use TRIM/LTRIM/RTRIM syntax if the expression isn't database-specific - if not remove_chars: - return self.trim_sql(expression) - - trim_type = f"{trim_type} " if trim_type else "" - remove_chars = f"{remove_chars} " if remove_chars else "" - from_part = "FROM " if trim_type or remove_chars else "" - collation = f" COLLATE {collation}" if collation else "" - return f"TRIM({trim_type}{remove_chars}{from_part}{target}{collation})" - - -def str_to_time_sql(self: Generator, expression: exp.Expression) -> str: - return self.func("STRPTIME", expression.this, self.format_time(expression)) - - -def concat_to_dpipe_sql(self: Generator, expression: exp.Concat) -> str: - return self.sql( - reduce(lambda x, y: exp.DPipe(this=x, expression=y), expression.expressions) - ) - - -def concat_ws_to_dpipe_sql(self: Generator, expression: exp.ConcatWs) -> str: - delim, *rest_args = expression.expressions - return self.sql( - reduce( - lambda x, y: exp.DPipe( - this=x, expression=exp.DPipe(this=delim, expression=y) - ), - rest_args, - ) - ) - - -@unsupported_args("position", "occurrence", "parameters") -def regexp_extract_sql( - self: Generator, expression: exp.RegexpExtract | exp.RegexpExtractAll -) -> str: - group = expression.args.get("group") - - # Do not render group if it's the default value for this dialect - if group and group.name == str(self.dialect.REGEXP_EXTRACT_DEFAULT_GROUP): - group = None - - return self.func( - expression.sql_name(), expression.this, expression.expression, group - ) - - -@unsupported_args("position", "occurrence", "modifiers") -def regexp_replace_sql(self: Generator, expression: exp.RegexpReplace) -> str: - return self.func( - "REGEXP_REPLACE", - expression.this, - expression.expression, - expression.args["replacement"], - ) - - -def pivot_column_names( - aggregations: t.List[exp.Expression], dialect: DialectType -) -> t.List[str]: - names = [] - for agg in aggregations: - if isinstance(agg, exp.Alias): - names.append(agg.alias) - else: - """ - This case corresponds to aggregations without aliases being used as suffixes - (e.g. col_avg(foo)). We need to unquote identifiers because they're going to - be quoted in the base parser's `_parse_pivot` method, due to `to_identifier`. - Otherwise, we'd end up with `col_avg(`foo`)` (notice the double quotes). - """ - agg_all_unquoted = agg.transform( - lambda node: ( - exp.Identifier(this=node.name, quoted=False) - if isinstance(node, exp.Identifier) - else node - ) - ) - names.append( - agg_all_unquoted.sql(dialect=dialect, normalize_functions="lower") - ) - - return names - - -def binary_from_function(expr_type: t.Type[B]) -> t.Callable[[t.List], B]: - return lambda args: expr_type(this=seq_get(args, 0), expression=seq_get(args, 1)) - - -# Used to represent DATE_TRUNC in Doris, Postgres and Starrocks dialects -def build_timestamp_trunc(args: t.List) -> exp.TimestampTrunc: - return exp.TimestampTrunc(this=seq_get(args, 1), unit=seq_get(args, 0)) - - -def any_value_to_max_sql(self: Generator, expression: exp.AnyValue) -> str: - return self.func("MAX", expression.this) - - -def bool_xor_sql(self: Generator, expression: exp.Xor) -> str: - a = self.sql(expression.left) - b = self.sql(expression.right) - return f"({a} AND (NOT {b})) OR ((NOT {a}) AND {b})" - - -def is_parse_json(expression: exp.Expression) -> bool: - return isinstance(expression, exp.ParseJSON) or ( - isinstance(expression, exp.Cast) and expression.is_type("json") - ) - - -def isnull_to_is_null(args: t.List) -> exp.Expression: - return exp.Paren(this=exp.Is(this=seq_get(args, 0), expression=exp.null())) - - -def generatedasidentitycolumnconstraint_sql( - self: Generator, expression: exp.GeneratedAsIdentityColumnConstraint -) -> str: - start = self.sql(expression, "start") or "1" - increment = self.sql(expression, "increment") or "1" - return f"IDENTITY({start}, {increment})" - - -def arg_max_or_min_no_count( - name: str, -) -> t.Callable[[Generator, exp.ArgMax | exp.ArgMin], str]: - @unsupported_args("count") - def _arg_max_or_min_sql( - self: Generator, expression: exp.ArgMax | exp.ArgMin - ) -> str: - return self.func(name, expression.this, expression.expression) - - return _arg_max_or_min_sql - - -def ts_or_ds_add_cast(expression: exp.TsOrDsAdd) -> exp.TsOrDsAdd: - this = expression.this.copy() - - return_type = expression.return_type - if return_type.is_type(exp.DataType.Type.DATE): - # If we need to cast to a DATE, we cast to TIMESTAMP first to make sure we - # can truncate timestamp strings, because some dialects can't cast them to DATE - this = exp.cast(this, exp.DataType.Type.TIMESTAMP) - - expression.this.replace(exp.cast(this, return_type)) - return expression - - -def date_delta_sql( - name: str, cast: bool = False -) -> t.Callable[[Generator, DATE_ADD_OR_DIFF], str]: - def _delta_sql(self: Generator, expression: DATE_ADD_OR_DIFF) -> str: - if cast and isinstance(expression, exp.TsOrDsAdd): - expression = ts_or_ds_add_cast(expression) - - return self.func( - name, - unit_to_var(expression), - expression.expression, - expression.this, - ) - - return _delta_sql - - -def date_delta_to_binary_interval_op( - cast: bool = True, -) -> t.Callable[[Generator, DATETIME_DELTA], str]: - def date_delta_to_binary_interval_op_sql( - self: Generator, expression: DATETIME_DELTA - ) -> str: - this = expression.this - unit = unit_to_var(expression) - op = "+" if isinstance(expression, DATETIME_ADD) else "-" - - to_type: t.Optional[exp.DATA_TYPE] = None - if cast: - if isinstance(expression, exp.TsOrDsAdd): - to_type = expression.return_type - elif this.is_string: - # Cast string literals (i.e function parameters) to the appropriate type for +/- interval to work - to_type = ( - exp.DataType.Type.DATETIME - if isinstance(expression, (exp.DatetimeAdd, exp.DatetimeSub)) - else exp.DataType.Type.DATE - ) - - this = exp.cast(this, to_type) if to_type else this - - expr = expression.expression - interval = ( - expr - if isinstance(expr, exp.Interval) - else exp.Interval(this=expr, unit=unit) - ) - - return f"{self.sql(this)} {op} {self.sql(interval)}" - - return date_delta_to_binary_interval_op_sql - - -def unit_to_str( - expression: exp.Expression, default: str = "DAY" -) -> t.Optional[exp.Expression]: - unit = expression.args.get("unit") - if not unit: - return exp.Literal.string(default) if default else None - - if isinstance(unit, exp.Placeholder) or type(unit) not in (exp.Var, exp.Literal): - return unit - - return exp.Literal.string(unit.name) - - -def unit_to_var( - expression: exp.Expression, default: str = "DAY" -) -> t.Optional[exp.Expression]: - unit = expression.args.get("unit") - - if isinstance(unit, (exp.Var, exp.Placeholder, exp.WeekStart, exp.Column)): - return unit - - value = unit.name if unit else default - return exp.Var(this=value) if value else None - - -@t.overload -def map_date_part(part: exp.Expression, dialect: DialectType = Dialect) -> exp.Var: - pass - - -@t.overload -def map_date_part( - part: t.Optional[exp.Expression], dialect: DialectType = Dialect -) -> t.Optional[exp.Expression]: - pass - - -def map_date_part(part, dialect: DialectType = Dialect): - mapped = ( - Dialect.get_or_raise(dialect).DATE_PART_MAPPING.get(part.name.upper()) - if part and not (isinstance(part, exp.Column) and len(part.parts) != 1) - else None - ) - if mapped: - return exp.Literal.string(mapped) if part.is_string else exp.var(mapped) - - return part - - -def no_last_day_sql(self: Generator, expression: exp.LastDay) -> str: - trunc_curr_date = exp.func("date_trunc", "month", expression.this) - plus_one_month = exp.func("date_add", trunc_curr_date, 1, "month") - minus_one_day = exp.func("date_sub", plus_one_month, 1, "day") - - return self.sql(exp.cast(minus_one_day, exp.DataType.Type.DATE)) - - -def merge_without_target_sql(self: Generator, expression: exp.Merge) -> str: - """Remove table refs from columns in when statements.""" - alias = expression.this.args.get("alias") - - def normalize(identifier: t.Optional[exp.Identifier]) -> t.Optional[str]: - return ( - self.dialect.normalize_identifier(identifier).name if identifier else None - ) - - targets = {normalize(expression.this.this)} - - if alias: - targets.add(normalize(alias.this)) - - for when in expression.args["whens"].expressions: - # only remove the target table names from certain parts of WHEN MATCHED / WHEN NOT MATCHED - # they are still valid in the , the right hand side of each UPDATE and the VALUES part - # (not the column list) of the INSERT - then: exp.Insert | exp.Update | None = when.args.get("then") - if then: - if isinstance(then, exp.Update): - for equals in then.find_all(exp.EQ): - equal_lhs = equals.this - if ( - isinstance(equal_lhs, exp.Column) - and normalize(equal_lhs.args.get("table")) in targets - ): - equal_lhs.replace(exp.column(equal_lhs.this)) - if isinstance(then, exp.Insert): - column_list = then.this - if isinstance(column_list, exp.Tuple): - for column in column_list.expressions: - if normalize(column.args.get("table")) in targets: - column.replace(exp.column(column.this)) - - return self.merge_sql(expression) - - -def build_json_extract_path( - expr_type: t.Type[F], - zero_based_indexing: bool = True, - arrow_req_json_type: bool = False, - json_type: t.Optional[str] = None, -) -> t.Callable[[t.List], F]: - def _builder(args: t.List) -> F: - segments: t.List[exp.JSONPathPart] = [exp.JSONPathRoot()] - for arg in args[1:]: - if not isinstance(arg, exp.Literal): - # We use the fallback parser because we can't really transpile non-literals safely - return expr_type.from_arg_list(args) - - text = arg.name - if is_int(text) and (not arrow_req_json_type or not arg.is_string): - index = int(text) - segments.append( - exp.JSONPathSubscript( - this=index if zero_based_indexing else index - 1 - ) - ) - else: - segments.append(exp.JSONPathKey(this=text)) - - # This is done to avoid failing in the expression validator due to the arg count - del args[2:] - kwargs = { - "this": seq_get(args, 0), - "expression": exp.JSONPath(expressions=segments), - } - - is_jsonb = issubclass(expr_type, (exp.JSONBExtract, exp.JSONBExtractScalar)) - if not is_jsonb: - kwargs["only_json_types"] = arrow_req_json_type - - if json_type is not None: - kwargs["json_type"] = json_type - - return expr_type(**kwargs) - - return _builder - - -def json_extract_segments( - name: str, quoted_index: bool = True, op: t.Optional[str] = None -) -> t.Callable[[Generator, JSON_EXTRACT_TYPE], str]: - def _json_extract_segments(self: Generator, expression: JSON_EXTRACT_TYPE) -> str: - path = expression.expression - if not isinstance(path, exp.JSONPath): - return rename_func(name)(self, expression) - - escape = path.args.get("escape") - - segments = [] - for segment in path.expressions: - path = self.sql(segment) - if path: - if isinstance(segment, exp.JSONPathPart) and ( - quoted_index or not isinstance(segment, exp.JSONPathSubscript) - ): - if escape: - path = self.escape_str(path) - - path = f"{self.dialect.QUOTE_START}{path}{self.dialect.QUOTE_END}" - - segments.append(path) - - if op: - return f" {op} ".join([self.sql(expression.this), *segments]) - return self.func(name, expression.this, *segments) - - return _json_extract_segments - - -def json_path_key_only_name(self: Generator, expression: exp.JSONPathKey) -> str: - if isinstance(expression.this, exp.JSONPathWildcard): - self.unsupported("Unsupported wildcard in JSONPathKey expression") - - return expression.name - - -def filter_array_using_unnest( - self: Generator, expression: exp.ArrayFilter | exp.ArrayRemove -) -> str: - cond = expression.expression - if isinstance(cond, exp.Lambda) and len(cond.expressions) == 1: - alias = cond.expressions[0] - cond = cond.this - elif isinstance(cond, exp.Predicate): - alias = "_u" - elif isinstance(expression, exp.ArrayRemove): - alias = "_u" - cond = exp.NEQ(this=alias, expression=expression.expression) - else: - self.unsupported("Unsupported filter condition") - return "" - - unnest = exp.Unnest(expressions=[expression.this]) - filtered = ( - exp.select(alias).from_(exp.alias_(unnest, None, table=[alias])).where(cond) - ) - return self.sql(exp.Array(expressions=[filtered])) - - -def remove_from_array_using_filter(self: Generator, expression: exp.ArrayRemove) -> str: - lambda_id = exp.to_identifier("_u") - cond = exp.NEQ(this=lambda_id, expression=expression.expression) - return self.sql( - exp.ArrayFilter( - this=expression.this, - expression=exp.Lambda(this=cond, expressions=[lambda_id]), - ) - ) - - -def to_number_with_nls_param(self: Generator, expression: exp.ToNumber) -> str: - return self.func( - "TO_NUMBER", - expression.this, - expression.args.get("format"), - expression.args.get("nlsparam"), - ) - - -def build_default_decimal_type( - precision: t.Optional[int] = None, scale: t.Optional[int] = None -) -> t.Callable[[exp.DataType], exp.DataType]: - def _builder(dtype: exp.DataType) -> exp.DataType: - if dtype.expressions or precision is None: - return dtype - - params = f"{precision}{f', {scale}' if scale is not None else ''}" - return exp.DataType.build(f"DECIMAL({params})") - - return _builder - - -def build_timestamp_from_parts(args: t.List) -> exp.Func: - if len(args) == 2: - # Other dialects don't have the TIMESTAMP_FROM_PARTS(date, time) concept, - # so we parse this into Anonymous for now instead of introducing complexity - return exp.Anonymous(this="TIMESTAMP_FROM_PARTS", expressions=args) - - return exp.TimestampFromParts.from_arg_list(args) - - -def sha256_sql(self: Generator, expression: exp.SHA2) -> str: - return self.func(f"SHA{expression.text('length') or '256'}", expression.this) - - -def sha2_digest_sql(self: Generator, expression: exp.SHA2Digest) -> str: - return self.func(f"SHA{expression.text('length') or '256'}", expression.this) - - -def sequence_sql( - self: Generator, expression: exp.GenerateSeries | exp.GenerateDateArray -) -> str: - start = expression.args.get("start") - end = expression.args.get("end") - step = expression.args.get("step") - - if isinstance(start, exp.Cast): - target_type = start.to - elif isinstance(end, exp.Cast): - target_type = end.to - else: - target_type = None - - if start and end: - if target_type and target_type.is_type("date", "timestamp"): - if isinstance(start, exp.Cast) and target_type is start.to: - end = exp.cast(end, target_type) - else: - start = exp.cast(start, target_type) - - if expression.args.get("is_end_exclusive"): - step_value = step or exp.Literal.number(1) - end = exp.paren(exp.Sub(this=end, expression=step_value), copy=False) - - sequence_call = exp.Anonymous( - this="SEQUENCE", expressions=[e for e in (start, end, step) if e] - ) - zero = exp.Literal.number(0) - should_return_empty = exp.or_( - exp.EQ(this=step_value.copy(), expression=zero.copy()), - exp.and_( - exp.GT(this=step_value.copy(), expression=zero.copy()), - exp.GTE(this=start.copy(), expression=end.copy()), - ), - exp.and_( - exp.LT(this=step_value.copy(), expression=zero.copy()), - exp.LTE(this=start.copy(), expression=end.copy()), - ), - ) - empty_array_or_sequence = exp.If( - this=should_return_empty, - true=exp.Array(expressions=[]), - false=sequence_call, - ) - return self.sql(self._simplify_unless_literal(empty_array_or_sequence)) - - return self.func("SEQUENCE", start, end, step) - - -def build_like( - expr_type: t.Type[E], not_like: bool = False -) -> t.Callable[[t.List], exp.Expression]: - def _builder(args: t.List) -> exp.Expression: - like_expr: exp.Expression = expr_type( - this=seq_get(args, 0), expression=seq_get(args, 1) - ) - - if escape := seq_get(args, 2): - like_expr = exp.Escape(this=like_expr, expression=escape) - - if not_like: - like_expr = exp.Not(this=like_expr) - - return like_expr - - return _builder - - -def build_regexp_extract(expr_type: t.Type[E]) -> t.Callable[[t.List, Dialect], E]: - def _builder(args: t.List, dialect: Dialect) -> E: - # The "position" argument specifies the index of the string character to start matching from. - # `null_if_pos_overflow` reflects the dialect's behavior when position is greater than the string - # length. If true, returns NULL. If false, returns an empty string. `null_if_pos_overflow` is - # only needed for exp.RegexpExtract - exp.RegexpExtractAll always returns an empty array if - # position overflows. - return expr_type( - this=seq_get(args, 0), - expression=seq_get(args, 1), - group=seq_get(args, 2) - or exp.Literal.number(dialect.REGEXP_EXTRACT_DEFAULT_GROUP), - parameters=seq_get(args, 3), - **( - { - "null_if_pos_overflow": dialect.REGEXP_EXTRACT_POSITION_OVERFLOW_RETURNS_NULL - } - if expr_type is exp.RegexpExtract - else {} - ), - ) - - return _builder - - -def explode_to_unnest_sql(self: Generator, expression: exp.Lateral) -> str: - if isinstance(expression.this, exp.Explode): - return self.sql( - exp.Join( - this=exp.Unnest( - expressions=[expression.this.this], - alias=expression.args.get("alias"), - offset=isinstance(expression.this, exp.Posexplode), - ), - kind="cross", - ) - ) - return self.lateral_sql(expression) - - -def timestampdiff_sql( - self: Generator, expression: exp.DatetimeDiff | exp.TimestampDiff -) -> str: - return self.func( - "TIMESTAMPDIFF", expression.unit, expression.expression, expression.this - ) - - -def no_make_interval_sql( - self: Generator, expression: exp.MakeInterval, sep: str = ", " -) -> str: - args = [] - for unit, value in expression.args.items(): - if isinstance(value, exp.Kwarg): - value = value.expression - - args.append(f"{value} {unit}") - - return f"INTERVAL '{self.format_args(*args, sep=sep)}'" - - -def length_or_char_length_sql(self: Generator, expression: exp.Length) -> str: - length_func = "LENGTH" if expression.args.get("binary") else "CHAR_LENGTH" - return self.func(length_func, expression.this) - - -def groupconcat_sql( - self: Generator, - expression: exp.GroupConcat, - func_name="LISTAGG", - sep: t.Optional[str] = ",", - within_group: bool = True, - on_overflow: bool = False, -) -> str: - this = expression.this - separator = self.sql( - expression.args.get("separator") or (exp.Literal.string(sep) if sep else None) - ) - - on_overflow_sql = self.sql(expression, "on_overflow") - on_overflow_sql = ( - f" ON OVERFLOW {on_overflow_sql}" if (on_overflow and on_overflow_sql) else "" - ) - - if isinstance(this, exp.Limit) and this.this: - limit = this - this = limit.this.pop() - else: - limit = None - - order = this.find(exp.Order) - - if order and order.this: - this = order.this.pop() - - args = self.format_args( - this, f"{separator}{on_overflow_sql}" if separator or on_overflow_sql else None - ) - - listagg: exp.Expression = exp.Anonymous(this=func_name, expressions=[args]) - - modifiers = self.sql(limit) - - if order: - if within_group: - listagg = exp.WithinGroup(this=listagg, expression=order) - else: - modifiers = f"{self.sql(order)}{modifiers}" - - if modifiers: - listagg.set("expressions", [f"{args}{modifiers}"]) - - return self.sql(listagg) - - -def build_timetostr_or_tochar( - args: t.List, dialect: DialectType -) -> exp.TimeToStr | exp.ToChar: - if len(args) == 2: - this = args[0] - if not this.type: - from sqlglot.optimizer.annotate_types import annotate_types - - annotate_types(this, dialect=dialect) - - if this.is_type(*exp.DataType.TEMPORAL_TYPES): - dialect_name = dialect.__class__.__name__.lower() - return build_formatted_time(exp.TimeToStr, dialect_name, default=True)(args) - - return exp.ToChar.from_arg_list(args) - - -def build_replace_with_optional_replacement(args: t.List) -> exp.Replace: - return exp.Replace( - this=seq_get(args, 0), - expression=seq_get(args, 1), - replacement=seq_get(args, 2) or exp.Literal.string(""), - ) - - -def regexp_replace_global_modifier( - expression: exp.RegexpReplace, -) -> exp.Expression | None: - modifiers = expression.args.get("modifiers") - single_replace = expression.args.get("single_replace") - occurrence = expression.args.get("occurrence") - - if not single_replace and ( - not occurrence or (occurrence.is_int and occurrence.to_py() == 0) - ): - if not modifiers or modifiers.is_string: - # Append 'g' to the modifiers if they are not provided since - # the semantics of REGEXP_REPLACE from the input dialect - # is to replace all occurrences of the pattern. - value = "" if not modifiers else modifiers.name - modifiers = exp.Literal.string(value + "g") - - return modifiers diff --git a/third_party/bigframes_vendored/sqlglot/diff.py b/third_party/bigframes_vendored/sqlglot/diff.py deleted file mode 100644 index e0e4eb4b0be..00000000000 --- a/third_party/bigframes_vendored/sqlglot/diff.py +++ /dev/null @@ -1,513 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/diff.py - -""" -.. include:: ../posts/sql_diff.md - ----- -""" - -from __future__ import annotations - -import typing as t -from collections import defaultdict -from dataclasses import dataclass -from heapq import heappop, heappush -from itertools import chain - -from bigframes_vendored.sqlglot import Dialect -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.helper import seq_get - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - -@dataclass(frozen=True) -class Insert: - """Indicates that a new node has been inserted""" - - expression: exp.Expression - - -@dataclass(frozen=True) -class Remove: - """Indicates that an existing node has been removed""" - - expression: exp.Expression - - -@dataclass(frozen=True) -class Move: - """Indicates that an existing node's position within the tree has changed""" - - source: exp.Expression - target: exp.Expression - - -@dataclass(frozen=True) -class Update: - """Indicates that an existing node has been updated""" - - source: exp.Expression - target: exp.Expression - - -@dataclass(frozen=True) -class Keep: - """Indicates that an existing node hasn't been changed""" - - source: exp.Expression - target: exp.Expression - - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import T - - Edit = t.Union[Insert, Remove, Move, Update, Keep] - - -def diff( - source: exp.Expression, - target: exp.Expression, - matchings: t.List[t.Tuple[exp.Expression, exp.Expression]] | None = None, - delta_only: bool = False, - **kwargs: t.Any, -) -> t.List[Edit]: - """ - Returns the list of changes between the source and the target expressions. - - Examples: - >>> diff(parse_one("a + b"), parse_one("a + c")) - [ - Remove(expression=(COLUMN this: (IDENTIFIER this: b, quoted: False))), - Insert(expression=(COLUMN this: (IDENTIFIER this: c, quoted: False))), - Keep( - source=(ADD this: ...), - target=(ADD this: ...) - ), - Keep( - source=(COLUMN this: (IDENTIFIER this: a, quoted: False)), - target=(COLUMN this: (IDENTIFIER this: a, quoted: False)) - ), - ] - - Args: - source: the source expression. - target: the target expression against which the diff should be calculated. - matchings: the list of pre-matched node pairs which is used to help the algorithm's - heuristics produce better results for subtrees that are known by a caller to be matching. - Note: expression references in this list must refer to the same node objects that are - referenced in the source / target trees. - delta_only: excludes all `Keep` nodes from the diff. - kwargs: additional arguments to pass to the ChangeDistiller instance. - - Returns: - the list of Insert, Remove, Move, Update and Keep objects for each node in the source and the - target expression trees. This list represents a sequence of steps needed to transform the source - expression tree into the target one. - """ - matchings = matchings or [] - - def compute_node_mappings( - old_nodes: tuple[exp.Expression, ...], new_nodes: tuple[exp.Expression, ...] - ) -> t.Dict[int, exp.Expression]: - node_mapping = {} - for old_node, new_node in zip(reversed(old_nodes), reversed(new_nodes)): - new_node._hash = hash(new_node) - node_mapping[id(old_node)] = new_node - - return node_mapping - - # if the source and target have any shared objects, that means there's an issue with the ast - # the algorithm won't work because the parent / hierarchies will be inaccurate - source_nodes = tuple(source.walk()) - target_nodes = tuple(target.walk()) - source_ids = {id(n) for n in source_nodes} - target_ids = {id(n) for n in target_nodes} - - copy = ( - len(source_nodes) != len(source_ids) - or len(target_nodes) != len(target_ids) - or source_ids & target_ids - ) - - source_copy = source.copy() if copy else source - target_copy = target.copy() if copy else target - - try: - # We cache the hash of each new node here to speed up equality comparisons. If the input - # trees aren't copied, these hashes will be evicted before returning the edit script. - if copy and matchings: - source_mapping = compute_node_mappings( - source_nodes, tuple(source_copy.walk()) - ) - target_mapping = compute_node_mappings( - target_nodes, tuple(target_copy.walk()) - ) - matchings = [ - (source_mapping[id(s)], target_mapping[id(t)]) for s, t in matchings - ] - else: - for node in chain(reversed(source_nodes), reversed(target_nodes)): - node._hash = hash(node) - - edit_script = ChangeDistiller(**kwargs).diff( - source_copy, - target_copy, - matchings=matchings, - delta_only=delta_only, - ) - finally: - if not copy: - for node in chain(source_nodes, target_nodes): - node._hash = None - - return edit_script - - -# The expression types for which Update edits are allowed. -UPDATABLE_EXPRESSION_TYPES = ( - exp.Alias, - exp.Boolean, - exp.Column, - exp.DataType, - exp.Lambda, - exp.Literal, - exp.Table, - exp.Window, -) - -IGNORED_LEAF_EXPRESSION_TYPES = (exp.Identifier,) - - -class ChangeDistiller: - """ - The implementation of the Change Distiller algorithm described by Beat Fluri and Martin Pinzger in - their paper https://ieeexplore.ieee.org/document/4339230, which in turn is based on the algorithm by - Chawathe et al. described in http://ilpubs.stanford.edu:8090/115/1/1995-46.pdf. - """ - - def __init__( - self, f: float = 0.6, t: float = 0.6, dialect: DialectType = None - ) -> None: - self.f = f - self.t = t - self._sql_generator = Dialect.get_or_raise(dialect).generator() - - def diff( - self, - source: exp.Expression, - target: exp.Expression, - matchings: t.List[t.Tuple[exp.Expression, exp.Expression]] | None = None, - delta_only: bool = False, - ) -> t.List[Edit]: - matchings = matchings or [] - pre_matched_nodes = {id(s): id(t) for s, t in matchings} - - self._source = source - self._target = target - self._source_index = { - id(n): n - for n in self._source.bfs() - if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) - } - self._target_index = { - id(n): n - for n in self._target.bfs() - if not isinstance(n, IGNORED_LEAF_EXPRESSION_TYPES) - } - self._unmatched_source_nodes = set(self._source_index) - set(pre_matched_nodes) - self._unmatched_target_nodes = set(self._target_index) - set( - pre_matched_nodes.values() - ) - self._bigram_histo_cache: t.Dict[int, t.DefaultDict[str, int]] = {} - - matching_set = self._compute_matching_set() | set(pre_matched_nodes.items()) - return self._generate_edit_script(dict(matching_set), delta_only) - - def _generate_edit_script( - self, matchings: t.Dict[int, int], delta_only: bool - ) -> t.List[Edit]: - edit_script: t.List[Edit] = [] - for removed_node_id in self._unmatched_source_nodes: - edit_script.append(Remove(self._source_index[removed_node_id])) - for inserted_node_id in self._unmatched_target_nodes: - edit_script.append(Insert(self._target_index[inserted_node_id])) - for kept_source_node_id, kept_target_node_id in matchings.items(): - source_node = self._source_index[kept_source_node_id] - target_node = self._target_index[kept_target_node_id] - - identical_nodes = source_node == target_node - - if ( - not isinstance(source_node, UPDATABLE_EXPRESSION_TYPES) - or identical_nodes - ): - if identical_nodes: - source_parent = source_node.parent - target_parent = target_node.parent - - if ( - (source_parent and not target_parent) - or (not source_parent and target_parent) - or ( - source_parent - and target_parent - and matchings.get(id(source_parent)) != id(target_parent) - ) - ): - edit_script.append(Move(source=source_node, target=target_node)) - else: - edit_script.extend( - self._generate_move_edits(source_node, target_node, matchings) - ) - - source_non_expression_leaves = dict( - _get_non_expression_leaves(source_node) - ) - target_non_expression_leaves = dict( - _get_non_expression_leaves(target_node) - ) - - if source_non_expression_leaves != target_non_expression_leaves: - edit_script.append(Update(source_node, target_node)) - elif not delta_only: - edit_script.append(Keep(source_node, target_node)) - else: - edit_script.append(Update(source_node, target_node)) - - return edit_script - - def _generate_move_edits( - self, - source: exp.Expression, - target: exp.Expression, - matchings: t.Dict[int, int], - ) -> t.List[Move]: - source_args = [id(e) for e in _expression_only_args(source)] - target_args = [id(e) for e in _expression_only_args(target)] - - args_lcs = set( - _lcs( - source_args, - target_args, - lambda ll, r: matchings.get(t.cast(int, ll)) == r, - ) - ) - - move_edits = [] - for a in source_args: - if a not in args_lcs and a not in self._unmatched_source_nodes: - move_edits.append( - Move( - source=self._source_index[a], - target=self._target_index[matchings[a]], - ) - ) - - return move_edits - - def _compute_matching_set(self) -> t.Set[t.Tuple[int, int]]: - leaves_matching_set = self._compute_leaf_matching_set() - matching_set = leaves_matching_set.copy() - - ordered_unmatched_source_nodes = { - id(n): None - for n in self._source.bfs() - if id(n) in self._unmatched_source_nodes - } - ordered_unmatched_target_nodes = { - id(n): None - for n in self._target.bfs() - if id(n) in self._unmatched_target_nodes - } - - for source_node_id in ordered_unmatched_source_nodes: - for target_node_id in ordered_unmatched_target_nodes: - source_node = self._source_index[source_node_id] - target_node = self._target_index[target_node_id] - if _is_same_type(source_node, target_node): - source_leaf_ids = { - id(ll) for ll in _get_expression_leaves(source_node) - } - target_leaf_ids = { - id(ll) for ll in _get_expression_leaves(target_node) - } - - max_leaves_num = max(len(source_leaf_ids), len(target_leaf_ids)) - if max_leaves_num: - common_leaves_num = sum( - 1 if s in source_leaf_ids and t in target_leaf_ids else 0 - for s, t in leaves_matching_set - ) - leaf_similarity_score = common_leaves_num / max_leaves_num - else: - leaf_similarity_score = 0.0 - - adjusted_t = ( - self.t - if min(len(source_leaf_ids), len(target_leaf_ids)) > 4 - else 0.4 - ) - - if leaf_similarity_score >= 0.8 or ( - leaf_similarity_score >= adjusted_t - and self._dice_coefficient(source_node, target_node) >= self.f - ): - matching_set.add((source_node_id, target_node_id)) - self._unmatched_source_nodes.remove(source_node_id) - self._unmatched_target_nodes.remove(target_node_id) - ordered_unmatched_target_nodes.pop(target_node_id, None) - break - - return matching_set - - def _compute_leaf_matching_set(self) -> t.Set[t.Tuple[int, int]]: - candidate_matchings: t.List[ - t.Tuple[float, int, int, exp.Expression, exp.Expression] - ] = [] - source_expression_leaves = list(_get_expression_leaves(self._source)) - target_expression_leaves = list(_get_expression_leaves(self._target)) - for source_leaf in source_expression_leaves: - for target_leaf in target_expression_leaves: - if _is_same_type(source_leaf, target_leaf): - similarity_score = self._dice_coefficient(source_leaf, target_leaf) - if similarity_score >= self.f: - heappush( - candidate_matchings, - ( - -similarity_score, - -_parent_similarity_score(source_leaf, target_leaf), - len(candidate_matchings), - source_leaf, - target_leaf, - ), - ) - - # Pick best matchings based on the highest score - matching_set = set() - while candidate_matchings: - _, _, _, source_leaf, target_leaf = heappop(candidate_matchings) - if ( - id(source_leaf) in self._unmatched_source_nodes - and id(target_leaf) in self._unmatched_target_nodes - ): - matching_set.add((id(source_leaf), id(target_leaf))) - self._unmatched_source_nodes.remove(id(source_leaf)) - self._unmatched_target_nodes.remove(id(target_leaf)) - - return matching_set - - def _dice_coefficient( - self, source: exp.Expression, target: exp.Expression - ) -> float: - source_histo = self._bigram_histo(source) - target_histo = self._bigram_histo(target) - - total_grams = sum(source_histo.values()) + sum(target_histo.values()) - if not total_grams: - return 1.0 if source == target else 0.0 - - overlap_len = 0 - overlapping_grams = set(source_histo) & set(target_histo) - for g in overlapping_grams: - overlap_len += min(source_histo[g], target_histo[g]) - - return 2 * overlap_len / total_grams - - def _bigram_histo(self, expression: exp.Expression) -> t.DefaultDict[str, int]: - if id(expression) in self._bigram_histo_cache: - return self._bigram_histo_cache[id(expression)] - - expression_str = self._sql_generator.generate(expression) - count = max(0, len(expression_str) - 1) - bigram_histo: t.DefaultDict[str, int] = defaultdict(int) - for i in range(count): - bigram_histo[expression_str[i : i + 2]] += 1 - - self._bigram_histo_cache[id(expression)] = bigram_histo - return bigram_histo - - -def _get_expression_leaves(expression: exp.Expression) -> t.Iterator[exp.Expression]: - has_child_exprs = False - - for node in expression.iter_expressions(): - if not isinstance(node, IGNORED_LEAF_EXPRESSION_TYPES): - has_child_exprs = True - yield from _get_expression_leaves(node) - - if not has_child_exprs: - yield expression - - -def _get_non_expression_leaves( - expression: exp.Expression, -) -> t.Iterator[t.Tuple[str, t.Any]]: - for arg, value in expression.args.items(): - if ( - value is None - or isinstance(value, exp.Expression) - or ( - isinstance(value, list) - and isinstance(seq_get(value, 0), exp.Expression) - ) - ): - continue - - yield (arg, value) - - -def _is_same_type(source: exp.Expression, target: exp.Expression) -> bool: - if type(source) is type(target): - if isinstance(source, exp.Join): - return source.args.get("side") == target.args.get("side") - - if isinstance(source, exp.Anonymous): - return source.this == target.this - - return True - - return False - - -def _parent_similarity_score( - source: t.Optional[exp.Expression], target: t.Optional[exp.Expression] -) -> int: - if source is None or target is None or type(source) is not type(target): - return 0 - - return 1 + _parent_similarity_score(source.parent, target.parent) - - -def _expression_only_args(expression: exp.Expression) -> t.Iterator[exp.Expression]: - yield from ( - arg - for arg in expression.iter_expressions() - if not isinstance(arg, IGNORED_LEAF_EXPRESSION_TYPES) - ) - - -def _lcs( - seq_a: t.Sequence[T], seq_b: t.Sequence[T], equal: t.Callable[[T, T], bool] -) -> t.Sequence[t.Optional[T]]: - """Calculates the longest common subsequence""" - - len_a = len(seq_a) - len_b = len(seq_b) - lcs_result = [[None] * (len_b + 1) for i in range(len_a + 1)] - - for i in range(len_a + 1): - for j in range(len_b + 1): - if i == 0 or j == 0: - lcs_result[i][j] = [] # type: ignore - elif equal(seq_a[i - 1], seq_b[j - 1]): - lcs_result[i][j] = lcs_result[i - 1][j - 1] + [seq_a[i - 1]] # type: ignore - else: - lcs_result[i][j] = ( - lcs_result[i - 1][j] - if len(lcs_result[i - 1][j]) > len(lcs_result[i][j - 1]) # type: ignore - else lcs_result[i][j - 1] - ) - - return lcs_result[len_a][len_b] # type: ignore diff --git a/third_party/bigframes_vendored/sqlglot/errors.py b/third_party/bigframes_vendored/sqlglot/errors.py deleted file mode 100644 index fe8e31d1960..00000000000 --- a/third_party/bigframes_vendored/sqlglot/errors.py +++ /dev/null @@ -1,167 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/errors.py - -from __future__ import annotations - -import typing as t -from enum import auto - -from bigframes_vendored.sqlglot.helper import AutoName - -# ANSI escape codes for error formatting -ANSI_UNDERLINE = "\033[4m" -ANSI_RESET = "\033[0m" -ERROR_MESSAGE_CONTEXT_DEFAULT = 100 - - -class ErrorLevel(AutoName): - IGNORE = auto() - """Ignore all errors.""" - - WARN = auto() - """Log all errors.""" - - RAISE = auto() - """Collect all errors and raise a single exception.""" - - IMMEDIATE = auto() - """Immediately raise an exception on the first error found.""" - - -class SqlglotError(Exception): - pass - - -class UnsupportedError(SqlglotError): - pass - - -class ParseError(SqlglotError): - def __init__( - self, - message: str, - errors: t.Optional[t.List[t.Dict[str, t.Any]]] = None, - ): - super().__init__(message) - self.errors = errors or [] - - @classmethod - def new( - cls, - message: str, - description: t.Optional[str] = None, - line: t.Optional[int] = None, - col: t.Optional[int] = None, - start_context: t.Optional[str] = None, - highlight: t.Optional[str] = None, - end_context: t.Optional[str] = None, - into_expression: t.Optional[str] = None, - ) -> ParseError: - return cls( - message, - [ - { - "description": description, - "line": line, - "col": col, - "start_context": start_context, - "highlight": highlight, - "end_context": end_context, - "into_expression": into_expression, - } - ], - ) - - -class TokenError(SqlglotError): - pass - - -class OptimizeError(SqlglotError): - pass - - -class SchemaError(SqlglotError): - pass - - -class ExecuteError(SqlglotError): - pass - - -def highlight_sql( - sql: str, - positions: t.List[t.Tuple[int, int]], - context_length: int = ERROR_MESSAGE_CONTEXT_DEFAULT, -) -> t.Tuple[str, str, str, str]: - """ - Highlight a SQL string using ANSI codes at the given positions. - - Args: - sql: The complete SQL string. - positions: List of (start, end) tuples where both start and end are inclusive 0-based - indexes. For example, to highlight "foo" in "SELECT foo", use (7, 9). - The positions will be sorted and de-duplicated if they overlap. - context_length: Number of characters to show before the first highlight and after - the last highlight. - - Returns: - A tuple of (formatted_sql, start_context, highlight, end_context) where: - - formatted_sql: The SQL with ANSI underline codes applied to highlighted sections - - start_context: Plain text before the first highlight - - highlight: Plain text from the first highlight start to the last highlight end, - including any non-highlighted text in between (no ANSI) - - end_context: Plain text after the last highlight - - Note: - If positions is empty, raises a ValueError. - """ - if not positions: - raise ValueError("positions must contain at least one (start, end) tuple") - - start_context = "" - end_context = "" - first_highlight_start = 0 - formatted_parts = [] - previous_part_end = 0 - sorted_positions = sorted(positions, key=lambda pos: pos[0]) - - if sorted_positions[0][0] > 0: - first_highlight_start = sorted_positions[0][0] - start_context = sql[ - max(0, first_highlight_start - context_length) : first_highlight_start - ] - formatted_parts.append(start_context) - previous_part_end = first_highlight_start - - for start, end in sorted_positions: - highlight_start = max(start, previous_part_end) - highlight_end = end + 1 - if highlight_start >= highlight_end: - continue # Skip invalid or overlapping highlights - if highlight_start > previous_part_end: - formatted_parts.append(sql[previous_part_end:highlight_start]) - formatted_parts.append( - f"{ANSI_UNDERLINE}{sql[highlight_start:highlight_end]}{ANSI_RESET}" - ) - previous_part_end = highlight_end - - if previous_part_end < len(sql): - end_context = sql[previous_part_end : previous_part_end + context_length] - formatted_parts.append(end_context) - - formatted_sql = "".join(formatted_parts) - highlight = sql[first_highlight_start:previous_part_end] - - return formatted_sql, start_context, highlight, end_context - - -def concat_messages(errors: t.Sequence[t.Any], maximum: int) -> str: - msg = [str(e) for e in errors[:maximum]] - remaining = len(errors) - maximum - if remaining > 0: - msg.append(f"... and {remaining} more") - return "\n\n".join(msg) - - -def merge_errors(errors: t.Sequence[ParseError]) -> t.List[t.Dict[str, t.Any]]: - return [e_dict for error in errors for e_dict in error.errors] diff --git a/third_party/bigframes_vendored/sqlglot/expressions.py b/third_party/bigframes_vendored/sqlglot/expressions.py deleted file mode 100644 index e8e4cc8e10d..00000000000 --- a/third_party/bigframes_vendored/sqlglot/expressions.py +++ /dev/null @@ -1,10471 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/expressions.py - -""" -## Expressions - -Every AST node in SQLGlot is represented by a subclass of `Expression`. - -This module contains the implementation of all supported `Expression` types. Additionally, -it exposes a number of helper functions, which are mainly used to programmatically build -SQL expressions, such as `sqlglot.expressions.select`. - ----- -""" - -from __future__ import annotations - -import datetime -import math -import numbers -import re -import sys -import textwrap -import typing as t -from collections import deque -from copy import deepcopy -from decimal import Decimal -from enum import auto -from functools import reduce - -from bigframes_vendored.sqlglot.errors import ErrorLevel, ParseError -from bigframes_vendored.sqlglot.helper import ( - AutoName, - camel_to_snake_case, - ensure_collection, - ensure_list, - seq_get, - split_num_words, - subclasses, - to_bool, -) -from bigframes_vendored.sqlglot.tokens import Token, TokenError - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E, Lit - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - from typing_extensions import Self - - Q = t.TypeVar("Q", bound="Query") - S = t.TypeVar("S", bound="SetOperation") - - -class _Expression(type): - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - - # When an Expression class is created, its key is automatically set - # to be the lowercase version of the class' name. - klass.key = clsname.lower() - klass.required_args = {k for k, v in klass.arg_types.items() if v} - - # This is so that docstrings are not inherited in pdoc - klass.__doc__ = klass.__doc__ or "" - - return klass - - -SQLGLOT_META = "sqlglot.meta" -SQLGLOT_ANONYMOUS = "sqlglot.anonymous" -TABLE_PARTS = ("this", "db", "catalog") -COLUMN_PARTS = ("this", "table", "db", "catalog") -POSITION_META_KEYS = ("line", "col", "start", "end") -UNITTEST = "unittest" in sys.modules or "pytest" in sys.modules - - -class Expression(metaclass=_Expression): - """ - The base class for all expressions in a syntax tree. Each Expression encapsulates any necessary - context, such as its child expressions, their names (arg keys), and whether a given child expression - is optional or not. - - Attributes: - key: a unique key for each class in the Expression hierarchy. This is useful for hashing - and representing expressions as strings. - arg_types: determines the arguments (child nodes) supported by an expression. It maps - arg keys to booleans that indicate whether the corresponding args are optional. - parent: a reference to the parent expression (or None, in case of root expressions). - arg_key: the arg key an expression is associated with, i.e. the name its parent expression - uses to refer to it. - index: the index of an expression if it is inside of a list argument in its parent. - comments: a list of comments that are associated with a given expression. This is used in - order to preserve comments when transpiling SQL code. - type: the `sqlglot.expressions.DataType` type of an expression. This is inferred by the - optimizer, in order to enable some transformations that require type information. - meta: a dictionary that can be used to store useful metadata for a given expression. - - Example: - >>> class Foo(Expression): - ... arg_types = {"this": True, "expression": False} - - The above definition informs us that Foo is an Expression that requires an argument called - "this" and may also optionally receive an argument called "expression". - - Args: - args: a mapping used for retrieving the arguments of an expression, given their arg keys. - """ - - key = "expression" - arg_types = {"this": True} - required_args = {"this"} - __slots__ = ( - "args", - "parent", - "arg_key", - "index", - "comments", - "_type", - "_meta", - "_hash", - ) - - def __init__(self, **args: t.Any): - self.args: t.Dict[str, t.Any] = args - self.parent: t.Optional[Expression] = None - self.arg_key: t.Optional[str] = None - self.index: t.Optional[int] = None - self.comments: t.Optional[t.List[str]] = None - self._type: t.Optional[DataType] = None - self._meta: t.Optional[t.Dict[str, t.Any]] = None - self._hash: t.Optional[int] = None - - for arg_key, value in self.args.items(): - self._set_parent(arg_key, value) - - def __eq__(self, other) -> bool: - return self is other or ( - type(self) is type(other) and hash(self) == hash(other) - ) - - def __hash__(self) -> int: - if self._hash is None: - nodes = [] - queue = deque([self]) - - while queue: - node = queue.popleft() - nodes.append(node) - - for v in node.iter_expressions(): - if v._hash is None: - queue.append(v) - - for node in reversed(nodes): - hash_ = hash(node.key) - t = type(node) - - if t is Literal or t is Identifier: - for k, v in sorted(node.args.items()): - if v: - hash_ = hash((hash_, k, v)) - else: - for k, v in sorted(node.args.items()): - t = type(v) - - if t is list: - for x in v: - if x is not None and x is not False: - hash_ = hash( - (hash_, k, x.lower() if type(x) is str else x) - ) - else: - hash_ = hash((hash_, k)) - elif v is not None and v is not False: - hash_ = hash((hash_, k, v.lower() if t is str else v)) - - node._hash = hash_ - assert self._hash - return self._hash - - def __reduce__(self) -> t.Tuple[t.Callable, t.Tuple[t.List[t.Dict[str, t.Any]]]]: - from bigframes_vendored.sqlglot.serde import dump, load - - return (load, (dump(self),)) - - @property - def this(self) -> t.Any: - """ - Retrieves the argument with key "this". - """ - return self.args.get("this") - - @property - def expression(self) -> t.Any: - """ - Retrieves the argument with key "expression". - """ - return self.args.get("expression") - - @property - def expressions(self) -> t.List[t.Any]: - """ - Retrieves the argument with key "expressions". - """ - return self.args.get("expressions") or [] - - def text(self, key) -> str: - """ - Returns a textual representation of the argument corresponding to "key". This can only be used - for args that are strings or leaf Expression instances, such as identifiers and literals. - """ - field = self.args.get(key) - if isinstance(field, str): - return field - if isinstance(field, (Identifier, Literal, Var)): - return field.this - if isinstance(field, (Star, Null)): - return field.name - return "" - - @property - def is_string(self) -> bool: - """ - Checks whether a Literal expression is a string. - """ - return isinstance(self, Literal) and self.args["is_string"] - - @property - def is_number(self) -> bool: - """ - Checks whether a Literal expression is a number. - """ - return (isinstance(self, Literal) and not self.args["is_string"]) or ( - isinstance(self, Neg) and self.this.is_number - ) - - def to_py(self) -> t.Any: - """ - Returns a Python object equivalent of the SQL node. - """ - raise ValueError(f"{self} cannot be converted to a Python object.") - - @property - def is_int(self) -> bool: - """ - Checks whether an expression is an integer. - """ - return self.is_number and isinstance(self.to_py(), int) - - @property - def is_star(self) -> bool: - """Checks whether an expression is a star.""" - return isinstance(self, Star) or ( - isinstance(self, Column) and isinstance(self.this, Star) - ) - - @property - def alias(self) -> str: - """ - Returns the alias of the expression, or an empty string if it's not aliased. - """ - if isinstance(self.args.get("alias"), TableAlias): - return self.args["alias"].name - return self.text("alias") - - @property - def alias_column_names(self) -> t.List[str]: - table_alias = self.args.get("alias") - if not table_alias: - return [] - return [c.name for c in table_alias.args.get("columns") or []] - - @property - def name(self) -> str: - return self.text("this") - - @property - def alias_or_name(self) -> str: - return self.alias or self.name - - @property - def output_name(self) -> str: - """ - Name of the output column if this expression is a selection. - - If the Expression has no output name, an empty string is returned. - - Example: - >>> from sqlglot import parse_one - >>> parse_one("SELECT a").expressions[0].output_name - 'a' - >>> parse_one("SELECT b AS c").expressions[0].output_name - 'c' - >>> parse_one("SELECT 1 + 2").expressions[0].output_name - '' - """ - return "" - - @property - def type(self) -> t.Optional[DataType]: - return self._type - - @type.setter - def type(self, dtype: t.Optional[DataType | DataType.Type | str]) -> None: - if dtype and not isinstance(dtype, DataType): - dtype = DataType.build(dtype) - self._type = dtype # type: ignore - - def is_type(self, *dtypes) -> bool: - return self.type is not None and self.type.is_type(*dtypes) - - def is_leaf(self) -> bool: - return not any( - isinstance(v, (Expression, list)) and v for v in self.args.values() - ) - - @property - def meta(self) -> t.Dict[str, t.Any]: - if self._meta is None: - self._meta = {} - return self._meta - - def __deepcopy__(self, memo): - root = self.__class__() - stack = [(self, root)] - - while stack: - node, copy = stack.pop() - - if node.comments is not None: - copy.comments = deepcopy(node.comments) - if node._type is not None: - copy._type = deepcopy(node._type) - if node._meta is not None: - copy._meta = deepcopy(node._meta) - if node._hash is not None: - copy._hash = node._hash - - for k, vs in node.args.items(): - if hasattr(vs, "parent"): - stack.append((vs, vs.__class__())) - copy.set(k, stack[-1][-1]) - elif type(vs) is list: - copy.args[k] = [] - - for v in vs: - if hasattr(v, "parent"): - stack.append((v, v.__class__())) - copy.append(k, stack[-1][-1]) - else: - copy.append(k, v) - else: - copy.args[k] = vs - - return root - - def copy(self) -> Self: - """ - Returns a deep copy of the expression. - """ - return deepcopy(self) - - def add_comments( - self, comments: t.Optional[t.List[str]] = None, prepend: bool = False - ) -> None: - if self.comments is None: - self.comments = [] - - if comments: - for comment in comments: - _, *meta = comment.split(SQLGLOT_META) - if meta: - for kv in "".join(meta).split(","): - k, *v = kv.split("=") - value = v[0].strip() if v else True - self.meta[k.strip()] = to_bool(value) - - if not prepend: - self.comments.append(comment) - - if prepend: - self.comments = comments + self.comments - - def pop_comments(self) -> t.List[str]: - comments = self.comments or [] - self.comments = None - return comments - - def append(self, arg_key: str, value: t.Any) -> None: - """ - Appends value to arg_key if it's a list or sets it as a new list. - - Args: - arg_key (str): name of the list expression arg - value (Any): value to append to the list - """ - if type(self.args.get(arg_key)) is not list: - self.args[arg_key] = [] - self._set_parent(arg_key, value) - values = self.args[arg_key] - if hasattr(value, "parent"): - value.index = len(values) - values.append(value) - - def set( - self, - arg_key: str, - value: t.Any, - index: t.Optional[int] = None, - overwrite: bool = True, - ) -> None: - """ - Sets arg_key to value. - - Args: - arg_key: name of the expression arg. - value: value to set the arg to. - index: if the arg is a list, this specifies what position to add the value in it. - overwrite: assuming an index is given, this determines whether to overwrite the - list entry instead of only inserting a new value (i.e., like list.insert). - """ - expression: t.Optional[Expression] = self - - while expression and expression._hash is not None: - expression._hash = None - expression = expression.parent - - if index is not None: - expressions = self.args.get(arg_key) or [] - - if seq_get(expressions, index) is None: - return - if value is None: - expressions.pop(index) - for v in expressions[index:]: - v.index = v.index - 1 - return - - if isinstance(value, list): - expressions.pop(index) - expressions[index:index] = value - elif overwrite: - expressions[index] = value - else: - expressions.insert(index, value) - - value = expressions - elif value is None: - self.args.pop(arg_key, None) - return - - self.args[arg_key] = value - self._set_parent(arg_key, value, index) - - def _set_parent( - self, arg_key: str, value: t.Any, index: t.Optional[int] = None - ) -> None: - if hasattr(value, "parent"): - value.parent = self - value.arg_key = arg_key - value.index = index - elif type(value) is list: - for index, v in enumerate(value): - if hasattr(v, "parent"): - v.parent = self - v.arg_key = arg_key - v.index = index - - @property - def depth(self) -> int: - """ - Returns the depth of this tree. - """ - if self.parent: - return self.parent.depth + 1 - return 0 - - def iter_expressions(self, reverse: bool = False) -> t.Iterator[Expression]: - """Yields the key and expression for all arguments, exploding list args.""" - for vs in reversed(self.args.values()) if reverse else self.args.values(): # type: ignore - if type(vs) is list: - for v in reversed(vs) if reverse else vs: # type: ignore - if hasattr(v, "parent"): - yield v - elif hasattr(vs, "parent"): - yield vs - - def find(self, *expression_types: t.Type[E], bfs: bool = True) -> t.Optional[E]: - """ - Returns the first node in this tree which matches at least one of - the specified types. - - Args: - expression_types: the expression type(s) to match. - bfs: whether to search the AST using the BFS algorithm (DFS is used if false). - - Returns: - The node which matches the criteria or None if no such node was found. - """ - return next(self.find_all(*expression_types, bfs=bfs), None) - - def find_all(self, *expression_types: t.Type[E], bfs: bool = True) -> t.Iterator[E]: - """ - Returns a generator object which visits all nodes in this tree and only - yields those that match at least one of the specified expression types. - - Args: - expression_types: the expression type(s) to match. - bfs: whether to search the AST using the BFS algorithm (DFS is used if false). - - Returns: - The generator object. - """ - for expression in self.walk(bfs=bfs): - if isinstance(expression, expression_types): - yield expression - - def find_ancestor(self, *expression_types: t.Type[E]) -> t.Optional[E]: - """ - Returns a nearest parent matching expression_types. - - Args: - expression_types: the expression type(s) to match. - - Returns: - The parent node. - """ - ancestor = self.parent - while ancestor and not isinstance(ancestor, expression_types): - ancestor = ancestor.parent - return ancestor # type: ignore - - @property - def parent_select(self) -> t.Optional[Select]: - """ - Returns the parent select statement. - """ - return self.find_ancestor(Select) - - @property - def same_parent(self) -> bool: - """Returns if the parent is the same class as itself.""" - return type(self.parent) is self.__class__ - - def root(self) -> Expression: - """ - Returns the root expression of this tree. - """ - expression = self - while expression.parent: - expression = expression.parent - return expression - - def walk( - self, bfs: bool = True, prune: t.Optional[t.Callable[[Expression], bool]] = None - ) -> t.Iterator[Expression]: - """ - Returns a generator object which visits all nodes in this tree. - - Args: - bfs: if set to True the BFS traversal order will be applied, - otherwise the DFS traversal will be used instead. - prune: callable that returns True if the generator should stop traversing - this branch of the tree. - - Returns: - the generator object. - """ - if bfs: - yield from self.bfs(prune=prune) - else: - yield from self.dfs(prune=prune) - - def dfs( - self, prune: t.Optional[t.Callable[[Expression], bool]] = None - ) -> t.Iterator[Expression]: - """ - Returns a generator object which visits all nodes in this tree in - the DFS (Depth-first) order. - - Returns: - The generator object. - """ - stack = [self] - - while stack: - node = stack.pop() - - yield node - - if prune and prune(node): - continue - - for v in node.iter_expressions(reverse=True): - stack.append(v) - - def bfs( - self, prune: t.Optional[t.Callable[[Expression], bool]] = None - ) -> t.Iterator[Expression]: - """ - Returns a generator object which visits all nodes in this tree in - the BFS (Breadth-first) order. - - Returns: - The generator object. - """ - queue = deque([self]) - - while queue: - node = queue.popleft() - - yield node - - if prune and prune(node): - continue - - for v in node.iter_expressions(): - queue.append(v) - - def unnest(self): - """ - Returns the first non parenthesis child or self. - """ - expression = self - while type(expression) is Paren: - expression = expression.this - return expression - - def unalias(self): - """ - Returns the inner expression if this is an Alias. - """ - if isinstance(self, Alias): - return self.this - return self - - def unnest_operands(self): - """ - Returns unnested operands as a tuple. - """ - return tuple(arg.unnest() for arg in self.iter_expressions()) - - def flatten(self, unnest=True): - """ - Returns a generator which yields child nodes whose parents are the same class. - - A AND B AND C -> [A, B, C] - """ - for node in self.dfs( - prune=lambda n: n.parent and type(n) is not self.__class__ - ): - if type(node) is not self.__class__: - yield ( - node.unnest() if unnest and not isinstance(node, Subquery) else node - ) - - def __str__(self) -> str: - return self.sql() - - def __repr__(self) -> str: - return _to_s(self) - - def to_s(self) -> str: - """ - Same as __repr__, but includes additional information which can be useful - for debugging, like empty or missing args and the AST nodes' object IDs. - """ - return _to_s(self, verbose=True) - - def sql(self, dialect: DialectType = None, **opts) -> str: - """ - Returns SQL string representation of this tree. - - Args: - dialect: the dialect of the output SQL string (eg. "spark", "hive", "presto", "mysql"). - opts: other `sqlglot.generator.Generator` options. - - Returns: - The SQL string. - """ - from bigframes_vendored.sqlglot.dialects import Dialect - - return Dialect.get_or_raise(dialect).generate(self, **opts) - - def transform( - self, fun: t.Callable, *args: t.Any, copy: bool = True, **kwargs - ) -> Expression: - """ - Visits all tree nodes (excluding already transformed ones) - and applies the given transformation function to each node. - - Args: - fun: a function which takes a node as an argument and returns a - new transformed node or the same node without modifications. If the function - returns None, then the corresponding node will be removed from the syntax tree. - copy: if set to True a new tree instance is constructed, otherwise the tree is - modified in place. - - Returns: - The transformed tree. - """ - root = None - new_node = None - - for node in (self.copy() if copy else self).dfs( - prune=lambda n: n is not new_node - ): - parent, arg_key, index = node.parent, node.arg_key, node.index - new_node = fun(node, *args, **kwargs) - - if not root: - root = new_node - elif parent and arg_key and new_node is not node: - parent.set(arg_key, new_node, index) - - assert root - return root.assert_is(Expression) - - @t.overload - def replace(self, expression: E) -> E: ... - - @t.overload - def replace(self, expression: None) -> None: ... - - def replace(self, expression): - """ - Swap out this expression with a new expression. - - For example:: - - >>> tree = Select().select("x").from_("tbl") - >>> tree.find(Column).replace(column("y")) - Column( - this=Identifier(this=y, quoted=False)) - >>> tree.sql() - 'SELECT y FROM tbl' - - Args: - expression: new node - - Returns: - The new expression or expressions. - """ - parent = self.parent - - if not parent or parent is expression: - return expression - - key = self.arg_key - value = parent.args.get(key) - - if type(expression) is list and isinstance(value, Expression): - # We are trying to replace an Expression with a list, so it's assumed that - # the intention was to really replace the parent of this expression. - value.parent.replace(expression) - else: - parent.set(key, expression, self.index) - - if expression is not self: - self.parent = None - self.arg_key = None - self.index = None - - return expression - - def pop(self: E) -> E: - """ - Remove this expression from its AST. - - Returns: - The popped expression. - """ - self.replace(None) - return self - - def assert_is(self, type_: t.Type[E]) -> E: - """ - Assert that this `Expression` is an instance of `type_`. - - If it is NOT an instance of `type_`, this raises an assertion error. - Otherwise, this returns this expression. - - Examples: - This is useful for type security in chained expressions: - - >>> import sqlglot - >>> sqlglot.parse_one("SELECT x from y").assert_is(Select).select("z").sql() - 'SELECT x, z FROM y' - """ - if not isinstance(self, type_): - raise AssertionError(f"{self} is not {type_}.") - return self - - def error_messages(self, args: t.Optional[t.Sequence] = None) -> t.List[str]: - """ - Checks if this expression is valid (e.g. all mandatory args are set). - - Args: - args: a sequence of values that were used to instantiate a Func expression. This is used - to check that the provided arguments don't exceed the function argument limit. - - Returns: - A list of error messages for all possible errors that were found. - """ - errors: t.List[str] = [] - - if UNITTEST: - for k in self.args: - if k not in self.arg_types: - raise TypeError(f"Unexpected keyword: '{k}' for {self.__class__}") - - for k in self.required_args: - v = self.args.get(k) - if v is None or (type(v) is list and not v): - errors.append(f"Required keyword: '{k}' missing for {self.__class__}") - - if ( - args - and isinstance(self, Func) - and len(args) > len(self.arg_types) - and not self.is_var_len_args - ): - errors.append( - f"The number of provided arguments ({len(args)}) is greater than " - f"the maximum number of supported arguments ({len(self.arg_types)})" - ) - - return errors - - def dump(self): - """ - Dump this Expression to a JSON-serializable dict. - """ - from bigframes_vendored.sqlglot.serde import dump - - return dump(self) - - @classmethod - def load(cls, obj): - """ - Load a dict (as returned by `Expression.dump`) into an Expression instance. - """ - from bigframes_vendored.sqlglot.serde import load - - return load(obj) - - def and_( - self, - *expressions: t.Optional[ExpOrStr], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, - ) -> Condition: - """ - AND this condition with one or multiple expressions. - - Example: - >>> condition("x=1").and_("y=1").sql() - 'x = 1 AND y = 1' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy the involved expressions (only applies to Expressions). - wrap: whether to wrap the operands in `Paren`s. This is true by default to avoid - precedence issues, but can be turned off when the produced AST is too deep and - causes recursion-related issues. - opts: other options to use to parse the input expressions. - - Returns: - The new And condition. - """ - return and_(self, *expressions, dialect=dialect, copy=copy, wrap=wrap, **opts) - - def or_( - self, - *expressions: t.Optional[ExpOrStr], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, - ) -> Condition: - """ - OR this condition with one or multiple expressions. - - Example: - >>> condition("x=1").or_("y=1").sql() - 'x = 1 OR y = 1' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy the involved expressions (only applies to Expressions). - wrap: whether to wrap the operands in `Paren`s. This is true by default to avoid - precedence issues, but can be turned off when the produced AST is too deep and - causes recursion-related issues. - opts: other options to use to parse the input expressions. - - Returns: - The new Or condition. - """ - return or_(self, *expressions, dialect=dialect, copy=copy, wrap=wrap, **opts) - - def not_(self, copy: bool = True): - """ - Wrap this condition with NOT. - - Example: - >>> condition("x=1").not_().sql() - 'NOT x = 1' - - Args: - copy: whether to copy this object. - - Returns: - The new Not instance. - """ - return not_(self, copy=copy) - - def update_positions( - self: E, - other: t.Optional[Token | Expression] = None, - line: t.Optional[int] = None, - col: t.Optional[int] = None, - start: t.Optional[int] = None, - end: t.Optional[int] = None, - ) -> E: - """ - Update this expression with positions from a token or other expression. - - Args: - other: a token or expression to update this expression with. - line: the line number to use if other is None - col: column number - start: start char index - end: end char index - - Returns: - The updated expression. - """ - if other is None: - self.meta["line"] = line - self.meta["col"] = col - self.meta["start"] = start - self.meta["end"] = end - elif hasattr(other, "meta"): - for k in POSITION_META_KEYS: - self.meta[k] = other.meta[k] - else: - self.meta["line"] = other.line - self.meta["col"] = other.col - self.meta["start"] = other.start - self.meta["end"] = other.end - return self - - def as_( - self, - alias: str | Identifier, - quoted: t.Optional[bool] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Alias: - return alias_(self, alias, quoted=quoted, dialect=dialect, copy=copy, **opts) - - def _binop(self, klass: t.Type[E], other: t.Any, reverse: bool = False) -> E: - this = self.copy() - other = convert(other, copy=True) - if not isinstance(this, klass) and not isinstance(other, klass): - this = _wrap(this, Binary) - other = _wrap(other, Binary) - if reverse: - return klass(this=other, expression=this) - return klass(this=this, expression=other) - - def __getitem__(self, other: ExpOrStr | t.Tuple[ExpOrStr]) -> Bracket: - return Bracket( - this=self.copy(), - expressions=[convert(e, copy=True) for e in ensure_list(other)], - ) - - def __iter__(self) -> t.Iterator: - if "expressions" in self.arg_types: - return iter(self.args.get("expressions") or []) - # We define this because __getitem__ converts Expression into an iterable, which is - # problematic because one can hit infinite loops if they do "for x in some_expr: ..." - # See: https://peps.python.org/pep-0234/ - raise TypeError(f"'{self.__class__.__name__}' object is not iterable") - - def isin( - self, - *expressions: t.Any, - query: t.Optional[ExpOrStr] = None, - unnest: t.Optional[ExpOrStr] | t.Collection[ExpOrStr] = None, - copy: bool = True, - **opts, - ) -> In: - subquery = maybe_parse(query, copy=copy, **opts) if query else None - if subquery and not isinstance(subquery, Subquery): - subquery = subquery.subquery(copy=False) - - return In( - this=maybe_copy(self, copy), - expressions=[convert(e, copy=copy) for e in expressions], - query=subquery, - unnest=( - Unnest( - expressions=[ - maybe_parse(t.cast(ExpOrStr, e), copy=copy, **opts) - for e in ensure_list(unnest) - ] - ) - if unnest - else None - ), - ) - - def between( - self, - low: t.Any, - high: t.Any, - copy: bool = True, - symmetric: t.Optional[bool] = None, - **opts, - ) -> Between: - between = Between( - this=maybe_copy(self, copy), - low=convert(low, copy=copy, **opts), - high=convert(high, copy=copy, **opts), - ) - if symmetric is not None: - between.set("symmetric", symmetric) - - return between - - def is_(self, other: ExpOrStr) -> Is: - return self._binop(Is, other) - - def like(self, other: ExpOrStr) -> Like: - return self._binop(Like, other) - - def ilike(self, other: ExpOrStr) -> ILike: - return self._binop(ILike, other) - - def eq(self, other: t.Any) -> EQ: - return self._binop(EQ, other) - - def neq(self, other: t.Any) -> NEQ: - return self._binop(NEQ, other) - - def rlike(self, other: ExpOrStr) -> RegexpLike: - return self._binop(RegexpLike, other) - - def div(self, other: ExpOrStr, typed: bool = False, safe: bool = False) -> Div: - div = self._binop(Div, other) - div.set("typed", typed) - div.set("safe", safe) - return div - - def asc(self, nulls_first: bool = True) -> Ordered: - return Ordered(this=self.copy(), nulls_first=nulls_first) - - def desc(self, nulls_first: bool = False) -> Ordered: - return Ordered(this=self.copy(), desc=True, nulls_first=nulls_first) - - def __lt__(self, other: t.Any) -> LT: - return self._binop(LT, other) - - def __le__(self, other: t.Any) -> LTE: - return self._binop(LTE, other) - - def __gt__(self, other: t.Any) -> GT: - return self._binop(GT, other) - - def __ge__(self, other: t.Any) -> GTE: - return self._binop(GTE, other) - - def __add__(self, other: t.Any) -> Add: - return self._binop(Add, other) - - def __radd__(self, other: t.Any) -> Add: - return self._binop(Add, other, reverse=True) - - def __sub__(self, other: t.Any) -> Sub: - return self._binop(Sub, other) - - def __rsub__(self, other: t.Any) -> Sub: - return self._binop(Sub, other, reverse=True) - - def __mul__(self, other: t.Any) -> Mul: - return self._binop(Mul, other) - - def __rmul__(self, other: t.Any) -> Mul: - return self._binop(Mul, other, reverse=True) - - def __truediv__(self, other: t.Any) -> Div: - return self._binop(Div, other) - - def __rtruediv__(self, other: t.Any) -> Div: - return self._binop(Div, other, reverse=True) - - def __floordiv__(self, other: t.Any) -> IntDiv: - return self._binop(IntDiv, other) - - def __rfloordiv__(self, other: t.Any) -> IntDiv: - return self._binop(IntDiv, other, reverse=True) - - def __mod__(self, other: t.Any) -> Mod: - return self._binop(Mod, other) - - def __rmod__(self, other: t.Any) -> Mod: - return self._binop(Mod, other, reverse=True) - - def __pow__(self, other: t.Any) -> Pow: - return self._binop(Pow, other) - - def __rpow__(self, other: t.Any) -> Pow: - return self._binop(Pow, other, reverse=True) - - def __and__(self, other: t.Any) -> And: - return self._binop(And, other) - - def __rand__(self, other: t.Any) -> And: - return self._binop(And, other, reverse=True) - - def __or__(self, other: t.Any) -> Or: - return self._binop(Or, other) - - def __ror__(self, other: t.Any) -> Or: - return self._binop(Or, other, reverse=True) - - def __neg__(self) -> Neg: - return Neg(this=_wrap(self.copy(), Binary)) - - def __invert__(self) -> Not: - return not_(self.copy()) - - -IntoType = t.Union[ - str, - t.Type[Expression], - t.Collection[t.Union[str, t.Type[Expression]]], -] -ExpOrStr = t.Union[str, Expression] - - -class Condition(Expression): - """Logical conditions like x AND y, or simply x""" - - -class Predicate(Condition): - """Relationships like x = y, x > 1, x >= y.""" - - -class DerivedTable(Expression): - @property - def selects(self) -> t.List[Expression]: - return self.this.selects if isinstance(self.this, Query) else [] - - @property - def named_selects(self) -> t.List[str]: - return [select.output_name for select in self.selects] - - -class Query(Expression): - def subquery( - self, alias: t.Optional[ExpOrStr] = None, copy: bool = True - ) -> Subquery: - """ - Returns a `Subquery` that wraps around this query. - - Example: - >>> subquery = Select().select("x").from_("tbl").subquery() - >>> Select().select("x").from_(subquery).sql() - 'SELECT x FROM (SELECT x FROM tbl)' - - Args: - alias: an optional alias for the subquery. - copy: if `False`, modify this expression instance in-place. - """ - instance = maybe_copy(self, copy) - if not isinstance(alias, Expression): - alias = TableAlias(this=to_identifier(alias)) if alias else None - - return Subquery(this=instance, alias=alias) - - def limit( - self: Q, - expression: ExpOrStr | int, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Q: - """ - Adds a LIMIT clause to this query. - - Example: - >>> select("1").union(select("1")).limit(1).sql() - 'SELECT 1 UNION SELECT 1 LIMIT 1' - - Args: - expression: the SQL code string to parse. - This can also be an integer. - If a `Limit` instance is passed, it will be used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Limit`. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - A limited Select expression. - """ - return _apply_builder( - expression=expression, - instance=self, - arg="limit", - into=Limit, - prefix="LIMIT", - dialect=dialect, - copy=copy, - into_arg="expression", - **opts, - ) - - def offset( - self: Q, - expression: ExpOrStr | int, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Q: - """ - Set the OFFSET expression. - - Example: - >>> Select().from_("tbl").select("x").offset(10).sql() - 'SELECT x FROM tbl OFFSET 10' - - Args: - expression: the SQL code string to parse. - This can also be an integer. - If a `Offset` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Offset`. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_builder( - expression=expression, - instance=self, - arg="offset", - into=Offset, - prefix="OFFSET", - dialect=dialect, - copy=copy, - into_arg="expression", - **opts, - ) - - def order_by( - self: Q, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Q: - """ - Set the ORDER BY expression. - - Example: - >>> Select().from_("tbl").select("x").order_by("x DESC").sql() - 'SELECT x FROM tbl ORDER BY x DESC' - - Args: - *expressions: the SQL code strings to parse. - If a `Group` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Order`. - append: if `True`, add to any existing expressions. - Otherwise, this flattens all the `Order` expression into a single expression. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_child_list_builder( - *expressions, - instance=self, - arg="order", - append=append, - copy=copy, - prefix="ORDER BY", - into=Order, - dialect=dialect, - **opts, - ) - - @property - def ctes(self) -> t.List[CTE]: - """Returns a list of all the CTEs attached to this query.""" - with_ = self.args.get("with_") - return with_.expressions if with_ else [] - - @property - def selects(self) -> t.List[Expression]: - """Returns the query's projections.""" - raise NotImplementedError("Query objects must implement `selects`") - - @property - def named_selects(self) -> t.List[str]: - """Returns the output names of the query's projections.""" - raise NotImplementedError("Query objects must implement `named_selects`") - - def select( - self: Q, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Q: - """ - Append to or set the SELECT expressions. - - Example: - >>> Select().select("x", "y").sql() - 'SELECT x, y' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Query expression. - """ - raise NotImplementedError("Query objects must implement `select`") - - def where( - self: Q, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Q: - """ - Append to or set the WHERE expressions. - - Examples: - >>> Select().select("x").from_("tbl").where("x = 'a' OR x < 'b'").sql() - "SELECT x FROM tbl WHERE x = 'a' OR x < 'b'" - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - Multiple expressions are combined with an AND operator. - append: if `True`, AND the new expressions to any existing expression. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified expression. - """ - return _apply_conjunction_builder( - *[expr.this if isinstance(expr, Where) else expr for expr in expressions], - instance=self, - arg="where", - append=append, - into=Where, - dialect=dialect, - copy=copy, - **opts, - ) - - def with_( - self: Q, - alias: ExpOrStr, - as_: ExpOrStr, - recursive: t.Optional[bool] = None, - materialized: t.Optional[bool] = None, - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - scalar: t.Optional[bool] = None, - **opts, - ) -> Q: - """ - Append to or set the common table expressions. - - Example: - >>> Select().with_("tbl2", as_="SELECT * FROM tbl").select("x").from_("tbl2").sql() - 'WITH tbl2 AS (SELECT * FROM tbl) SELECT x FROM tbl2' - - Args: - alias: the SQL code string to parse as the table name. - If an `Expression` instance is passed, this is used as-is. - as_: the SQL code string to parse as the table expression. - If an `Expression` instance is passed, it will be used as-is. - recursive: set the RECURSIVE part of the expression. Defaults to `False`. - materialized: set the MATERIALIZED part of the expression. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - scalar: if `True`, this is a scalar common table expression. - opts: other options to use to parse the input expressions. - - Returns: - The modified expression. - """ - return _apply_cte_builder( - self, - alias, - as_, - recursive=recursive, - materialized=materialized, - append=append, - dialect=dialect, - copy=copy, - scalar=scalar, - **opts, - ) - - def union( - self, - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - **opts, - ) -> Union: - """ - Builds a UNION expression. - - Example: - >>> import sqlglot - >>> sqlglot.parse_one("SELECT * FROM foo").union("SELECT * FROM bla").sql() - 'SELECT * FROM foo UNION SELECT * FROM bla' - - Args: - expressions: the SQL code strings. - If `Expression` instances are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Union expression. - """ - return union(self, *expressions, distinct=distinct, dialect=dialect, **opts) - - def intersect( - self, - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - **opts, - ) -> Intersect: - """ - Builds an INTERSECT expression. - - Example: - >>> import sqlglot - >>> sqlglot.parse_one("SELECT * FROM foo").intersect("SELECT * FROM bla").sql() - 'SELECT * FROM foo INTERSECT SELECT * FROM bla' - - Args: - expressions: the SQL code strings. - If `Expression` instances are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Intersect expression. - """ - return intersect(self, *expressions, distinct=distinct, dialect=dialect, **opts) - - def except_( - self, - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - **opts, - ) -> Except: - """ - Builds an EXCEPT expression. - - Example: - >>> import sqlglot - >>> sqlglot.parse_one("SELECT * FROM foo").except_("SELECT * FROM bla").sql() - 'SELECT * FROM foo EXCEPT SELECT * FROM bla' - - Args: - expressions: the SQL code strings. - If `Expression` instance are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Except expression. - """ - return except_(self, *expressions, distinct=distinct, dialect=dialect, **opts) - - -class UDTF(DerivedTable): - @property - def selects(self) -> t.List[Expression]: - alias = self.args.get("alias") - return alias.columns if alias else [] - - -class Cache(Expression): - arg_types = { - "this": True, - "lazy": False, - "options": False, - "expression": False, - } - - -class Uncache(Expression): - arg_types = {"this": True, "exists": False} - - -class Refresh(Expression): - arg_types = {"this": True, "kind": True} - - -class DDL(Expression): - @property - def ctes(self) -> t.List[CTE]: - """Returns a list of all the CTEs attached to this statement.""" - with_ = self.args.get("with_") - return with_.expressions if with_ else [] - - @property - def selects(self) -> t.List[Expression]: - """If this statement contains a query (e.g. a CTAS), this returns the query's projections.""" - return self.expression.selects if isinstance(self.expression, Query) else [] - - @property - def named_selects(self) -> t.List[str]: - """ - If this statement contains a query (e.g. a CTAS), this returns the output - names of the query's projections. - """ - return ( - self.expression.named_selects if isinstance(self.expression, Query) else [] - ) - - -# https://docs.teradata.com/r/Enterprise_IntelliFlex_VMware/SQL-Data-Manipulation-Language/Statement-Syntax/LOCKING-Request-Modifier/LOCKING-Request-Modifier-Syntax -class LockingStatement(Expression): - arg_types = {"this": True, "expression": True} - - -class DML(Expression): - def returning( - self, - expression: ExpOrStr, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> "Self": - """ - Set the RETURNING expression. Not supported by all dialects. - - Example: - >>> delete("tbl").returning("*", dialect="postgres").sql() - 'DELETE FROM tbl RETURNING *' - - Args: - expression: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - Delete: the modified expression. - """ - return _apply_builder( - expression=expression, - instance=self, - arg="returning", - prefix="RETURNING", - dialect=dialect, - copy=copy, - into=Returning, - **opts, - ) - - -class Create(DDL): - arg_types = { - "with_": False, - "this": True, - "kind": True, - "expression": False, - "exists": False, - "properties": False, - "replace": False, - "refresh": False, - "unique": False, - "indexes": False, - "no_schema_binding": False, - "begin": False, - "end": False, - "clone": False, - "concurrently": False, - "clustered": False, - } - - @property - def kind(self) -> t.Optional[str]: - kind = self.args.get("kind") - return kind and kind.upper() - - -class SequenceProperties(Expression): - arg_types = { - "increment": False, - "minvalue": False, - "maxvalue": False, - "cache": False, - "start": False, - "owned": False, - "options": False, - } - - -class TruncateTable(Expression): - arg_types = { - "expressions": True, - "is_database": False, - "exists": False, - "only": False, - "cluster": False, - "identity": False, - "option": False, - "partition": False, - } - - -# https://docs.snowflake.com/en/sql-reference/sql/create-clone -# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_table_clone_statement -# https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_table_copy -class Clone(Expression): - arg_types = {"this": True, "shallow": False, "copy": False} - - -class Describe(Expression): - arg_types = { - "this": True, - "style": False, - "kind": False, - "expressions": False, - "partition": False, - "format": False, - } - - -# https://duckdb.org/docs/sql/statements/attach.html#attach -class Attach(Expression): - arg_types = {"this": True, "exists": False, "expressions": False} - - -# https://duckdb.org/docs/sql/statements/attach.html#detach -class Detach(Expression): - arg_types = {"this": True, "exists": False} - - -# https://duckdb.org/docs/sql/statements/load_and_install.html -class Install(Expression): - arg_types = {"this": True, "from_": False, "force": False} - - -# https://duckdb.org/docs/guides/meta/summarize.html -class Summarize(Expression): - arg_types = {"this": True, "table": False} - - -class Kill(Expression): - arg_types = {"this": True, "kind": False} - - -class Pragma(Expression): - pass - - -class Declare(Expression): - arg_types = {"expressions": True} - - -class DeclareItem(Expression): - arg_types = {"this": True, "kind": False, "default": False} - - -class Set(Expression): - arg_types = {"expressions": False, "unset": False, "tag": False} - - -class Heredoc(Expression): - arg_types = {"this": True, "tag": False} - - -class SetItem(Expression): - arg_types = { - "this": False, - "expressions": False, - "kind": False, - "collate": False, # MySQL SET NAMES statement - "global_": False, - } - - -class QueryBand(Expression): - arg_types = {"this": True, "scope": False, "update": False} - - -class Show(Expression): - arg_types = { - "this": True, - "history": False, - "terse": False, - "target": False, - "offset": False, - "starts_with": False, - "limit": False, - "from_": False, - "like": False, - "where": False, - "db": False, - "scope": False, - "scope_kind": False, - "full": False, - "mutex": False, - "query": False, - "channel": False, - "global_": False, - "log": False, - "position": False, - "types": False, - "privileges": False, - "for_table": False, - "for_group": False, - "for_user": False, - "for_role": False, - "into_outfile": False, - "json": False, - } - - -class UserDefinedFunction(Expression): - arg_types = {"this": True, "expressions": False, "wrapped": False} - - -class CharacterSet(Expression): - arg_types = {"this": True, "default": False} - - -class RecursiveWithSearch(Expression): - arg_types = {"kind": True, "this": True, "expression": True, "using": False} - - -class With(Expression): - arg_types = {"expressions": True, "recursive": False, "search": False} - - @property - def recursive(self) -> bool: - return bool(self.args.get("recursive")) - - -class WithinGroup(Expression): - arg_types = {"this": True, "expression": False} - - -# clickhouse supports scalar ctes -# https://clickhouse.com/docs/en/sql-reference/statements/select/with -class CTE(DerivedTable): - arg_types = { - "this": True, - "alias": True, - "scalar": False, - "materialized": False, - "key_expressions": False, - } - - -class ProjectionDef(Expression): - arg_types = {"this": True, "expression": True} - - -class TableAlias(Expression): - arg_types = {"this": False, "columns": False} - - @property - def columns(self): - return self.args.get("columns") or [] - - -class BitString(Condition): - pass - - -class HexString(Condition): - arg_types = {"this": True, "is_integer": False} - - -class ByteString(Condition): - arg_types = {"this": True, "is_bytes": False} - - -class RawString(Condition): - pass - - -class UnicodeString(Condition): - arg_types = {"this": True, "escape": False} - - -class Column(Condition): - arg_types = { - "this": True, - "table": False, - "db": False, - "catalog": False, - "join_mark": False, - } - - @property - def table(self) -> str: - return self.text("table") - - @property - def db(self) -> str: - return self.text("db") - - @property - def catalog(self) -> str: - return self.text("catalog") - - @property - def output_name(self) -> str: - return self.name - - @property - def parts(self) -> t.List[Identifier]: - """Return the parts of a column in order catalog, db, table, name.""" - return [ - t.cast(Identifier, self.args[part]) - for part in ("catalog", "db", "table", "this") - if self.args.get(part) - ] - - def to_dot(self, include_dots: bool = True) -> Dot | Identifier: - """Converts the column into a dot expression.""" - parts = self.parts - parent = self.parent - - if include_dots: - while isinstance(parent, Dot): - parts.append(parent.expression) - parent = parent.parent - - return Dot.build(deepcopy(parts)) if len(parts) > 1 else parts[0] - - -class Pseudocolumn(Column): - pass - - -class ColumnPosition(Expression): - arg_types = {"this": False, "position": True} - - -class ColumnDef(Expression): - arg_types = { - "this": True, - "kind": False, - "constraints": False, - "exists": False, - "position": False, - "default": False, - "output": False, - } - - @property - def constraints(self) -> t.List[ColumnConstraint]: - return self.args.get("constraints") or [] - - @property - def kind(self) -> t.Optional[DataType]: - return self.args.get("kind") - - -class AlterColumn(Expression): - arg_types = { - "this": True, - "dtype": False, - "collate": False, - "using": False, - "default": False, - "drop": False, - "comment": False, - "allow_null": False, - "visible": False, - "rename_to": False, - } - - -# https://dev.mysql.com/doc/refman/8.0/en/invisible-indexes.html -class AlterIndex(Expression): - arg_types = {"this": True, "visible": True} - - -# https://docs.aws.amazon.com/redshift/latest/dg/r_ALTER_TABLE.html -class AlterDistStyle(Expression): - pass - - -class AlterSortKey(Expression): - arg_types = {"this": False, "expressions": False, "compound": False} - - -class AlterSet(Expression): - arg_types = { - "expressions": False, - "option": False, - "tablespace": False, - "access_method": False, - "file_format": False, - "copy_options": False, - "tag": False, - "location": False, - "serde": False, - } - - -class RenameColumn(Expression): - arg_types = {"this": True, "to": True, "exists": False} - - -class AlterRename(Expression): - pass - - -class SwapTable(Expression): - pass - - -class Comment(Expression): - arg_types = { - "this": True, - "kind": True, - "expression": True, - "exists": False, - "materialized": False, - } - - -class Comprehension(Expression): - arg_types = { - "this": True, - "expression": True, - "position": False, - "iterator": True, - "condition": False, - } - - -# https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree#mergetree-table-ttl -class MergeTreeTTLAction(Expression): - arg_types = { - "this": True, - "delete": False, - "recompress": False, - "to_disk": False, - "to_volume": False, - } - - -# https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree#mergetree-table-ttl -class MergeTreeTTL(Expression): - arg_types = { - "expressions": True, - "where": False, - "group": False, - "aggregates": False, - } - - -# https://dev.mysql.com/doc/refman/8.0/en/create-table.html -class IndexConstraintOption(Expression): - arg_types = { - "key_block_size": False, - "using": False, - "parser": False, - "comment": False, - "visible": False, - "engine_attr": False, - "secondary_engine_attr": False, - } - - -class ColumnConstraint(Expression): - arg_types = {"this": False, "kind": True} - - @property - def kind(self) -> ColumnConstraintKind: - return self.args["kind"] - - -class ColumnConstraintKind(Expression): - pass - - -class AutoIncrementColumnConstraint(ColumnConstraintKind): - pass - - -class ZeroFillColumnConstraint(ColumnConstraint): - arg_types = {} - - -class PeriodForSystemTimeConstraint(ColumnConstraintKind): - arg_types = {"this": True, "expression": True} - - -class CaseSpecificColumnConstraint(ColumnConstraintKind): - arg_types = {"not_": True} - - -class CharacterSetColumnConstraint(ColumnConstraintKind): - arg_types = {"this": True} - - -class CheckColumnConstraint(ColumnConstraintKind): - arg_types = {"this": True, "enforced": False} - - -class ClusteredColumnConstraint(ColumnConstraintKind): - pass - - -class CollateColumnConstraint(ColumnConstraintKind): - pass - - -class CommentColumnConstraint(ColumnConstraintKind): - pass - - -class CompressColumnConstraint(ColumnConstraintKind): - arg_types = {"this": False} - - -class DateFormatColumnConstraint(ColumnConstraintKind): - arg_types = {"this": True} - - -class DefaultColumnConstraint(ColumnConstraintKind): - pass - - -class EncodeColumnConstraint(ColumnConstraintKind): - pass - - -# https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-EXCLUDE -class ExcludeColumnConstraint(ColumnConstraintKind): - pass - - -class EphemeralColumnConstraint(ColumnConstraintKind): - arg_types = {"this": False} - - -class WithOperator(Expression): - arg_types = {"this": True, "op": True} - - -class GeneratedAsIdentityColumnConstraint(ColumnConstraintKind): - # this: True -> ALWAYS, this: False -> BY DEFAULT - arg_types = { - "this": False, - "expression": False, - "on_null": False, - "start": False, - "increment": False, - "minvalue": False, - "maxvalue": False, - "cycle": False, - "order": False, - } - - -class GeneratedAsRowColumnConstraint(ColumnConstraintKind): - arg_types = {"start": False, "hidden": False} - - -# https://dev.mysql.com/doc/refman/8.0/en/create-table.html -# https://github.com/ClickHouse/ClickHouse/blob/master/src/Parsers/ParserCreateQuery.h#L646 -class IndexColumnConstraint(ColumnConstraintKind): - arg_types = { - "this": False, - "expressions": False, - "kind": False, - "index_type": False, - "options": False, - "expression": False, # Clickhouse - "granularity": False, - } - - -class InlineLengthColumnConstraint(ColumnConstraintKind): - pass - - -class NonClusteredColumnConstraint(ColumnConstraintKind): - pass - - -class NotForReplicationColumnConstraint(ColumnConstraintKind): - arg_types = {} - - -# https://docs.snowflake.com/en/sql-reference/sql/create-table -class MaskingPolicyColumnConstraint(ColumnConstraintKind): - arg_types = {"this": True, "expressions": False} - - -class NotNullColumnConstraint(ColumnConstraintKind): - arg_types = {"allow_null": False} - - -# https://dev.mysql.com/doc/refman/5.7/en/timestamp-initialization.html -class OnUpdateColumnConstraint(ColumnConstraintKind): - pass - - -class PrimaryKeyColumnConstraint(ColumnConstraintKind): - arg_types = {"desc": False, "options": False} - - -class TitleColumnConstraint(ColumnConstraintKind): - pass - - -class UniqueColumnConstraint(ColumnConstraintKind): - arg_types = { - "this": False, - "index_type": False, - "on_conflict": False, - "nulls": False, - "options": False, - } - - -class UppercaseColumnConstraint(ColumnConstraintKind): - arg_types: t.Dict[str, t.Any] = {} - - -# https://docs.risingwave.com/processing/watermarks#syntax -class WatermarkColumnConstraint(Expression): - arg_types = {"this": True, "expression": True} - - -class PathColumnConstraint(ColumnConstraintKind): - pass - - -# https://docs.snowflake.com/en/sql-reference/sql/create-table -class ProjectionPolicyColumnConstraint(ColumnConstraintKind): - pass - - -# computed column expression -# https://learn.microsoft.com/en-us/sql/t-sql/statements/create-table-transact-sql?view=sql-server-ver16 -class ComputedColumnConstraint(ColumnConstraintKind): - arg_types = { - "this": True, - "persisted": False, - "not_null": False, - "data_type": False, - } - - -class Constraint(Expression): - arg_types = {"this": True, "expressions": True} - - -class Delete(DML): - arg_types = { - "with_": False, - "this": False, - "using": False, - "where": False, - "returning": False, - "order": False, - "limit": False, - "tables": False, # Multiple-Table Syntax (MySQL) - "cluster": False, # Clickhouse - } - - def delete( - self, - table: ExpOrStr, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Delete: - """ - Create a DELETE expression or replace the table on an existing DELETE expression. - - Example: - >>> delete("tbl").sql() - 'DELETE FROM tbl' - - Args: - table: the table from which to delete. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - Delete: the modified expression. - """ - return _apply_builder( - expression=table, - instance=self, - arg="this", - dialect=dialect, - into=Table, - copy=copy, - **opts, - ) - - def where( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Delete: - """ - Append to or set the WHERE expressions. - - Example: - >>> delete("tbl").where("x = 'a' OR x < 'b'").sql() - "DELETE FROM tbl WHERE x = 'a' OR x < 'b'" - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - Multiple expressions are combined with an AND operator. - append: if `True`, AND the new expressions to any existing expression. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - Delete: the modified expression. - """ - return _apply_conjunction_builder( - *expressions, - instance=self, - arg="where", - append=append, - into=Where, - dialect=dialect, - copy=copy, - **opts, - ) - - -class Drop(Expression): - arg_types = { - "this": False, - "kind": False, - "expressions": False, - "exists": False, - "temporary": False, - "materialized": False, - "cascade": False, - "constraints": False, - "purge": False, - "cluster": False, - "concurrently": False, - } - - @property - def kind(self) -> t.Optional[str]: - kind = self.args.get("kind") - return kind and kind.upper() - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/export-statements -class Export(Expression): - arg_types = {"this": True, "connection": False, "options": True} - - -class Filter(Expression): - arg_types = {"this": True, "expression": True} - - -class Check(Expression): - pass - - -class Changes(Expression): - arg_types = {"information": True, "at_before": False, "end": False} - - -# https://docs.snowflake.com/en/sql-reference/constructs/connect-by -class Connect(Expression): - arg_types = {"start": False, "connect": True, "nocycle": False} - - -class CopyParameter(Expression): - arg_types = {"this": True, "expression": False, "expressions": False} - - -class Copy(DML): - arg_types = { - "this": True, - "kind": True, - "files": False, - "credentials": False, - "format": False, - "params": False, - } - - -class Credentials(Expression): - arg_types = { - "credentials": False, - "encryption": False, - "storage": False, - "iam_role": False, - "region": False, - } - - -class Prior(Expression): - pass - - -class Directory(Expression): - arg_types = {"this": True, "local": False, "row_format": False} - - -# https://docs.snowflake.com/en/user-guide/data-load-dirtables-query -class DirectoryStage(Expression): - pass - - -class ForeignKey(Expression): - arg_types = { - "expressions": False, - "reference": False, - "delete": False, - "update": False, - "options": False, - } - - -class ColumnPrefix(Expression): - arg_types = {"this": True, "expression": True} - - -class PrimaryKey(Expression): - arg_types = {"this": False, "expressions": True, "options": False, "include": False} - - -# https://www.postgresql.org/docs/9.1/sql-selectinto.html -# https://docs.aws.amazon.com/redshift/latest/dg/r_SELECT_INTO.html#r_SELECT_INTO-examples -class Into(Expression): - arg_types = { - "this": False, - "temporary": False, - "unlogged": False, - "bulk_collect": False, - "expressions": False, - } - - -class From(Expression): - @property - def name(self) -> str: - return self.this.name - - @property - def alias_or_name(self) -> str: - return self.this.alias_or_name - - -class Having(Expression): - pass - - -class Hint(Expression): - arg_types = {"expressions": True} - - -class JoinHint(Expression): - arg_types = {"this": True, "expressions": True} - - -class Identifier(Expression): - arg_types = {"this": True, "quoted": False, "global_": False, "temporary": False} - - @property - def quoted(self) -> bool: - return bool(self.args.get("quoted")) - - @property - def output_name(self) -> str: - return self.name - - -# https://www.postgresql.org/docs/current/indexes-opclass.html -class Opclass(Expression): - arg_types = {"this": True, "expression": True} - - -class Index(Expression): - arg_types = { - "this": False, - "table": False, - "unique": False, - "primary": False, - "amp": False, # teradata - "params": False, - } - - -class IndexParameters(Expression): - arg_types = { - "using": False, - "include": False, - "columns": False, - "with_storage": False, - "partition_by": False, - "tablespace": False, - "where": False, - "on": False, - } - - -class Insert(DDL, DML): - arg_types = { - "hint": False, - "with_": False, - "is_function": False, - "this": False, - "expression": False, - "conflict": False, - "returning": False, - "overwrite": False, - "exists": False, - "alternative": False, - "where": False, - "ignore": False, - "by_name": False, - "stored": False, - "partition": False, - "settings": False, - "source": False, - "default": False, - } - - def with_( - self, - alias: ExpOrStr, - as_: ExpOrStr, - recursive: t.Optional[bool] = None, - materialized: t.Optional[bool] = None, - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Insert: - """ - Append to or set the common table expressions. - - Example: - >>> insert("SELECT x FROM cte", "t").with_("cte", as_="SELECT * FROM tbl").sql() - 'WITH cte AS (SELECT * FROM tbl) INSERT INTO t SELECT x FROM cte' - - Args: - alias: the SQL code string to parse as the table name. - If an `Expression` instance is passed, this is used as-is. - as_: the SQL code string to parse as the table expression. - If an `Expression` instance is passed, it will be used as-is. - recursive: set the RECURSIVE part of the expression. Defaults to `False`. - materialized: set the MATERIALIZED part of the expression. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified expression. - """ - return _apply_cte_builder( - self, - alias, - as_, - recursive=recursive, - materialized=materialized, - append=append, - dialect=dialect, - copy=copy, - **opts, - ) - - -class ConditionalInsert(Expression): - arg_types = {"this": True, "expression": False, "else_": False} - - -class MultitableInserts(Expression): - arg_types = {"expressions": True, "kind": True, "source": True} - - -class OnConflict(Expression): - arg_types = { - "duplicate": False, - "expressions": False, - "action": False, - "conflict_keys": False, - "constraint": False, - "where": False, - } - - -class OnCondition(Expression): - arg_types = {"error": False, "empty": False, "null": False} - - -class Returning(Expression): - arg_types = {"expressions": True, "into": False} - - -# https://dev.mysql.com/doc/refman/8.0/en/charset-introducer.html -class Introducer(Expression): - arg_types = {"this": True, "expression": True} - - -# national char, like n'utf8' -class National(Expression): - pass - - -class LoadData(Expression): - arg_types = { - "this": True, - "local": False, - "overwrite": False, - "inpath": True, - "partition": False, - "input_format": False, - "serde": False, - } - - -class Partition(Expression): - arg_types = {"expressions": True, "subpartition": False} - - -class PartitionRange(Expression): - arg_types = {"this": True, "expression": False, "expressions": False} - - -# https://clickhouse.com/docs/en/sql-reference/statements/alter/partition#how-to-set-partition-expression -class PartitionId(Expression): - pass - - -class Fetch(Expression): - arg_types = { - "direction": False, - "count": False, - "limit_options": False, - } - - -class Grant(Expression): - arg_types = { - "privileges": True, - "kind": False, - "securable": True, - "principals": True, - "grant_option": False, - } - - -class Revoke(Expression): - arg_types = {**Grant.arg_types, "cascade": False} - - -class Group(Expression): - arg_types = { - "expressions": False, - "grouping_sets": False, - "cube": False, - "rollup": False, - "totals": False, - "all": False, - } - - -class Cube(Expression): - arg_types = {"expressions": False} - - -class Rollup(Expression): - arg_types = {"expressions": False} - - -class GroupingSets(Expression): - arg_types = {"expressions": True} - - -class Lambda(Expression): - arg_types = {"this": True, "expressions": True, "colon": False} - - -class Limit(Expression): - arg_types = { - "this": False, - "expression": True, - "offset": False, - "limit_options": False, - "expressions": False, - } - - -class LimitOptions(Expression): - arg_types = { - "percent": False, - "rows": False, - "with_ties": False, - } - - -class Literal(Condition): - arg_types = {"this": True, "is_string": True} - - @classmethod - def number(cls, number) -> Literal: - return cls(this=str(number), is_string=False) - - @classmethod - def string(cls, string) -> Literal: - return cls(this=str(string), is_string=True) - - @property - def output_name(self) -> str: - return self.name - - def to_py(self) -> int | str | Decimal: - if self.is_number: - try: - return int(self.this) - except ValueError: - return Decimal(self.this) - return self.this - - -class Join(Expression): - arg_types = { - "this": True, - "on": False, - "side": False, - "kind": False, - "using": False, - "method": False, - "global_": False, - "hint": False, - "match_condition": False, # Snowflake - "expressions": False, - "pivots": False, - } - - @property - def method(self) -> str: - return self.text("method").upper() - - @property - def kind(self) -> str: - return self.text("kind").upper() - - @property - def side(self) -> str: - return self.text("side").upper() - - @property - def hint(self) -> str: - return self.text("hint").upper() - - @property - def alias_or_name(self) -> str: - return self.this.alias_or_name - - @property - def is_semi_or_anti_join(self) -> bool: - return self.kind in ("SEMI", "ANTI") - - def on( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Join: - """ - Append to or set the ON expressions. - - Example: - >>> import sqlglot - >>> sqlglot.parse_one("JOIN x", into=Join).on("y = 1").sql() - 'JOIN x ON y = 1' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - Multiple expressions are combined with an AND operator. - append: if `True`, AND the new expressions to any existing expression. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Join expression. - """ - join = _apply_conjunction_builder( - *expressions, - instance=self, - arg="on", - append=append, - dialect=dialect, - copy=copy, - **opts, - ) - - if join.kind == "CROSS": - join.set("kind", None) - - return join - - def using( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Join: - """ - Append to or set the USING expressions. - - Example: - >>> import sqlglot - >>> sqlglot.parse_one("JOIN x", into=Join).using("foo", "bla").sql() - 'JOIN x USING (foo, bla)' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - append: if `True`, concatenate the new expressions to the existing "using" list. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Join expression. - """ - join = _apply_list_builder( - *expressions, - instance=self, - arg="using", - append=append, - dialect=dialect, - copy=copy, - **opts, - ) - - if join.kind == "CROSS": - join.set("kind", None) - - return join - - -class Lateral(UDTF): - arg_types = { - "this": True, - "view": False, - "outer": False, - "alias": False, - "cross_apply": False, # True -> CROSS APPLY, False -> OUTER APPLY - "ordinality": False, - } - - -# https://docs.snowflake.com/sql-reference/literals-table -# https://docs.snowflake.com/en/sql-reference/functions-table#using-a-table-function -class TableFromRows(UDTF): - arg_types = { - "this": True, - "alias": False, - "joins": False, - "pivots": False, - "sample": False, - } - - -class MatchRecognizeMeasure(Expression): - arg_types = { - "this": True, - "window_frame": False, - } - - -class MatchRecognize(Expression): - arg_types = { - "partition_by": False, - "order": False, - "measures": False, - "rows": False, - "after": False, - "pattern": False, - "define": False, - "alias": False, - } - - -# Clickhouse FROM FINAL modifier -# https://clickhouse.com/docs/en/sql-reference/statements/select/from/#final-modifier -class Final(Expression): - pass - - -class Offset(Expression): - arg_types = {"this": False, "expression": True, "expressions": False} - - -class Order(Expression): - arg_types = {"this": False, "expressions": True, "siblings": False} - - -# https://clickhouse.com/docs/en/sql-reference/statements/select/order-by#order-by-expr-with-fill-modifier -class WithFill(Expression): - arg_types = { - "from_": False, - "to": False, - "step": False, - "interpolate": False, - } - - -# hive specific sorts -# https://cwiki.apache.org/confluence/display/Hive/LanguageManual+SortBy -class Cluster(Order): - pass - - -class Distribute(Order): - pass - - -class Sort(Order): - pass - - -class Ordered(Expression): - arg_types = {"this": True, "desc": False, "nulls_first": True, "with_fill": False} - - @property - def name(self) -> str: - return self.this.name - - -class Property(Expression): - arg_types = {"this": True, "value": True} - - -class GrantPrivilege(Expression): - arg_types = {"this": True, "expressions": False} - - -class GrantPrincipal(Expression): - arg_types = {"this": True, "kind": False} - - -class AllowedValuesProperty(Expression): - arg_types = {"expressions": True} - - -class AlgorithmProperty(Property): - arg_types = {"this": True} - - -class AutoIncrementProperty(Property): - arg_types = {"this": True} - - -# https://docs.aws.amazon.com/prescriptive-guidance/latest/materialized-views-redshift/refreshing-materialized-views.html -class AutoRefreshProperty(Property): - arg_types = {"this": True} - - -class BackupProperty(Property): - arg_types = {"this": True} - - -# https://doris.apache.org/docs/sql-manual/sql-statements/table-and-view/async-materialized-view/CREATE-ASYNC-MATERIALIZED-VIEW/ -class BuildProperty(Property): - arg_types = {"this": True} - - -class BlockCompressionProperty(Property): - arg_types = { - "autotemp": False, - "always": False, - "default": False, - "manual": False, - "never": False, - } - - -class CharacterSetProperty(Property): - arg_types = {"this": True, "default": True} - - -class ChecksumProperty(Property): - arg_types = {"on": False, "default": False} - - -class CollateProperty(Property): - arg_types = {"this": True, "default": False} - - -class CopyGrantsProperty(Property): - arg_types = {} - - -class DataBlocksizeProperty(Property): - arg_types = { - "size": False, - "units": False, - "minimum": False, - "maximum": False, - "default": False, - } - - -class DataDeletionProperty(Property): - arg_types = {"on": True, "filter_column": False, "retention_period": False} - - -class DefinerProperty(Property): - arg_types = {"this": True} - - -class DistKeyProperty(Property): - arg_types = {"this": True} - - -# https://docs.starrocks.io/docs/sql-reference/sql-statements/data-definition/CREATE_TABLE/#distribution_desc -# https://doris.apache.org/docs/sql-manual/sql-statements/Data-Definition-Statements/Create/CREATE-TABLE?_highlight=create&_highlight=table#distribution_desc -class DistributedByProperty(Property): - arg_types = {"expressions": False, "kind": True, "buckets": False, "order": False} - - -class DistStyleProperty(Property): - arg_types = {"this": True} - - -class DuplicateKeyProperty(Property): - arg_types = {"expressions": True} - - -class EngineProperty(Property): - arg_types = {"this": True} - - -class HeapProperty(Property): - arg_types = {} - - -class ToTableProperty(Property): - arg_types = {"this": True} - - -class ExecuteAsProperty(Property): - arg_types = {"this": True} - - -class ExternalProperty(Property): - arg_types = {"this": False} - - -class FallbackProperty(Property): - arg_types = {"no": True, "protection": False} - - -# https://docs.databricks.com/aws/en/sql/language-manual/sql-ref-syntax-ddl-create-table-hiveformat -class FileFormatProperty(Property): - arg_types = {"this": False, "expressions": False, "hive_format": False} - - -class CredentialsProperty(Property): - arg_types = {"expressions": True} - - -class FreespaceProperty(Property): - arg_types = {"this": True, "percent": False} - - -class GlobalProperty(Property): - arg_types = {} - - -class IcebergProperty(Property): - arg_types = {} - - -class InheritsProperty(Property): - arg_types = {"expressions": True} - - -class InputModelProperty(Property): - arg_types = {"this": True} - - -class OutputModelProperty(Property): - arg_types = {"this": True} - - -class IsolatedLoadingProperty(Property): - arg_types = {"no": False, "concurrent": False, "target": False} - - -class JournalProperty(Property): - arg_types = { - "no": False, - "dual": False, - "before": False, - "local": False, - "after": False, - } - - -class LanguageProperty(Property): - arg_types = {"this": True} - - -class EnviromentProperty(Property): - arg_types = {"expressions": True} - - -# spark ddl -class ClusteredByProperty(Property): - arg_types = {"expressions": True, "sorted_by": False, "buckets": True} - - -class DictProperty(Property): - arg_types = {"this": True, "kind": True, "settings": False} - - -class DictSubProperty(Property): - pass - - -class DictRange(Property): - arg_types = {"this": True, "min": True, "max": True} - - -class DynamicProperty(Property): - arg_types = {} - - -# Clickhouse CREATE ... ON CLUSTER modifier -# https://clickhouse.com/docs/en/sql-reference/distributed-ddl -class OnCluster(Property): - arg_types = {"this": True} - - -# Clickhouse EMPTY table "property" -class EmptyProperty(Property): - arg_types = {} - - -class LikeProperty(Property): - arg_types = {"this": True, "expressions": False} - - -class LocationProperty(Property): - arg_types = {"this": True} - - -class LockProperty(Property): - arg_types = {"this": True} - - -class LockingProperty(Property): - arg_types = { - "this": False, - "kind": True, - "for_or_in": False, - "lock_type": True, - "override": False, - } - - -class LogProperty(Property): - arg_types = {"no": True} - - -class MaterializedProperty(Property): - arg_types = {"this": False} - - -class MergeBlockRatioProperty(Property): - arg_types = {"this": False, "no": False, "default": False, "percent": False} - - -class NoPrimaryIndexProperty(Property): - arg_types = {} - - -class OnProperty(Property): - arg_types = {"this": True} - - -class OnCommitProperty(Property): - arg_types = {"delete": False} - - -class PartitionedByProperty(Property): - arg_types = {"this": True} - - -class PartitionedByBucket(Property): - arg_types = {"this": True, "expression": True} - - -class PartitionByTruncate(Property): - arg_types = {"this": True, "expression": True} - - -# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/CREATE_TABLE/ -class PartitionByRangeProperty(Property): - arg_types = {"partition_expressions": True, "create_expressions": True} - - -# https://docs.starrocks.io/docs/table_design/data_distribution/#range-partitioning -class PartitionByRangePropertyDynamic(Expression): - arg_types = {"this": False, "start": True, "end": True, "every": True} - - -# https://doris.apache.org/docs/table-design/data-partitioning/manual-partitioning -class PartitionByListProperty(Property): - arg_types = {"partition_expressions": True, "create_expressions": True} - - -# https://doris.apache.org/docs/table-design/data-partitioning/manual-partitioning -class PartitionList(Expression): - arg_types = {"this": True, "expressions": True} - - -# https://doris.apache.org/docs/sql-manual/sql-statements/table-and-view/async-materialized-view/CREATE-ASYNC-MATERIALIZED-VIEW -class RefreshTriggerProperty(Property): - arg_types = { - "method": True, - "kind": False, - "every": False, - "unit": False, - "starts": False, - } - - -# https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/CREATE_TABLE/ -class UniqueKeyProperty(Property): - arg_types = {"expressions": True} - - -# https://www.postgresql.org/docs/current/sql-createtable.html -class PartitionBoundSpec(Expression): - # this -> IN / MODULUS, expression -> REMAINDER, from_expressions -> FROM (...), to_expressions -> TO (...) - arg_types = { - "this": False, - "expression": False, - "from_expressions": False, - "to_expressions": False, - } - - -class PartitionedOfProperty(Property): - # this -> parent_table (schema), expression -> FOR VALUES ... / DEFAULT - arg_types = {"this": True, "expression": True} - - -class StreamingTableProperty(Property): - arg_types = {} - - -class RemoteWithConnectionModelProperty(Property): - arg_types = {"this": True} - - -class ReturnsProperty(Property): - arg_types = {"this": False, "is_table": False, "table": False, "null": False} - - -class StrictProperty(Property): - arg_types = {} - - -class RowFormatProperty(Property): - arg_types = {"this": True} - - -class RowFormatDelimitedProperty(Property): - # https://cwiki.apache.org/confluence/display/hive/languagemanual+dml - arg_types = { - "fields": False, - "escaped": False, - "collection_items": False, - "map_keys": False, - "lines": False, - "null": False, - "serde": False, - } - - -class RowFormatSerdeProperty(Property): - arg_types = {"this": True, "serde_properties": False} - - -# https://spark.apache.org/docs/3.1.2/sql-ref-syntax-qry-select-transform.html -class QueryTransform(Expression): - arg_types = { - "expressions": True, - "command_script": True, - "schema": False, - "row_format_before": False, - "record_writer": False, - "row_format_after": False, - "record_reader": False, - } - - -class SampleProperty(Property): - arg_types = {"this": True} - - -# https://prestodb.io/docs/current/sql/create-view.html#synopsis -class SecurityProperty(Property): - arg_types = {"this": True} - - -class SchemaCommentProperty(Property): - arg_types = {"this": True} - - -class SemanticView(Expression): - arg_types = { - "this": True, - "metrics": False, - "dimensions": False, - "facts": False, - "where": False, - } - - -class SerdeProperties(Property): - arg_types = {"expressions": True, "with_": False} - - -class SetProperty(Property): - arg_types = {"multi": True} - - -class SharingProperty(Property): - arg_types = {"this": False} - - -class SetConfigProperty(Property): - arg_types = {"this": True} - - -class SettingsProperty(Property): - arg_types = {"expressions": True} - - -class SortKeyProperty(Property): - arg_types = {"this": True, "compound": False} - - -class SqlReadWriteProperty(Property): - arg_types = {"this": True} - - -class SqlSecurityProperty(Property): - arg_types = {"this": True} - - -class StabilityProperty(Property): - arg_types = {"this": True} - - -class StorageHandlerProperty(Property): - arg_types = {"this": True} - - -class TemporaryProperty(Property): - arg_types = {"this": False} - - -class SecureProperty(Property): - arg_types = {} - - -# https://docs.snowflake.com/en/sql-reference/sql/create-table -class Tags(ColumnConstraintKind, Property): - arg_types = {"expressions": True} - - -class TransformModelProperty(Property): - arg_types = {"expressions": True} - - -class TransientProperty(Property): - arg_types = {"this": False} - - -class UnloggedProperty(Property): - arg_types = {} - - -# https://docs.snowflake.com/en/sql-reference/sql/create-table#create-table-using-template -class UsingTemplateProperty(Property): - arg_types = {"this": True} - - -# https://learn.microsoft.com/en-us/sql/t-sql/statements/create-view-transact-sql?view=sql-server-ver16 -class ViewAttributeProperty(Property): - arg_types = {"this": True} - - -class VolatileProperty(Property): - arg_types = {"this": False} - - -class WithDataProperty(Property): - arg_types = {"no": True, "statistics": False} - - -class WithJournalTableProperty(Property): - arg_types = {"this": True} - - -class WithSchemaBindingProperty(Property): - arg_types = {"this": True} - - -class WithSystemVersioningProperty(Property): - arg_types = { - "on": False, - "this": False, - "data_consistency": False, - "retention_period": False, - "with_": True, - } - - -class WithProcedureOptions(Property): - arg_types = {"expressions": True} - - -class EncodeProperty(Property): - arg_types = {"this": True, "properties": False, "key": False} - - -class IncludeProperty(Property): - arg_types = {"this": True, "alias": False, "column_def": False} - - -class ForceProperty(Property): - arg_types = {} - - -class Properties(Expression): - arg_types = {"expressions": True} - - NAME_TO_PROPERTY = { - "ALGORITHM": AlgorithmProperty, - "AUTO_INCREMENT": AutoIncrementProperty, - "CHARACTER SET": CharacterSetProperty, - "CLUSTERED_BY": ClusteredByProperty, - "COLLATE": CollateProperty, - "COMMENT": SchemaCommentProperty, - "CREDENTIALS": CredentialsProperty, - "DEFINER": DefinerProperty, - "DISTKEY": DistKeyProperty, - "DISTRIBUTED_BY": DistributedByProperty, - "DISTSTYLE": DistStyleProperty, - "ENGINE": EngineProperty, - "EXECUTE AS": ExecuteAsProperty, - "FORMAT": FileFormatProperty, - "LANGUAGE": LanguageProperty, - "LOCATION": LocationProperty, - "LOCK": LockProperty, - "PARTITIONED_BY": PartitionedByProperty, - "RETURNS": ReturnsProperty, - "ROW_FORMAT": RowFormatProperty, - "SORTKEY": SortKeyProperty, - "ENCODE": EncodeProperty, - "INCLUDE": IncludeProperty, - } - - PROPERTY_TO_NAME = {v: k for k, v in NAME_TO_PROPERTY.items()} - - # CREATE property locations - # Form: schema specified - # create [POST_CREATE] - # table a [POST_NAME] - # (b int) [POST_SCHEMA] - # with ([POST_WITH]) - # index (b) [POST_INDEX] - # - # Form: alias selection - # create [POST_CREATE] - # table a [POST_NAME] - # as [POST_ALIAS] (select * from b) [POST_EXPRESSION] - # index (c) [POST_INDEX] - class Location(AutoName): - POST_CREATE = auto() - POST_NAME = auto() - POST_SCHEMA = auto() - POST_WITH = auto() - POST_ALIAS = auto() - POST_EXPRESSION = auto() - POST_INDEX = auto() - UNSUPPORTED = auto() - - @classmethod - def from_dict(cls, properties_dict: t.Dict) -> Properties: - expressions = [] - for key, value in properties_dict.items(): - property_cls = cls.NAME_TO_PROPERTY.get(key.upper()) - if property_cls: - expressions.append(property_cls(this=convert(value))) - else: - expressions.append( - Property(this=Literal.string(key), value=convert(value)) - ) - - return cls(expressions=expressions) - - -class Qualify(Expression): - pass - - -class InputOutputFormat(Expression): - arg_types = {"input_format": False, "output_format": False} - - -# https://www.ibm.com/docs/en/ias?topic=procedures-return-statement-in-sql -class Return(Expression): - pass - - -class Reference(Expression): - arg_types = {"this": True, "expressions": False, "options": False} - - -class Tuple(Expression): - arg_types = {"expressions": False} - - def isin( - self, - *expressions: t.Any, - query: t.Optional[ExpOrStr] = None, - unnest: t.Optional[ExpOrStr] | t.Collection[ExpOrStr] = None, - copy: bool = True, - **opts, - ) -> In: - return In( - this=maybe_copy(self, copy), - expressions=[convert(e, copy=copy) for e in expressions], - query=maybe_parse(query, copy=copy, **opts) if query else None, - unnest=( - Unnest( - expressions=[ - maybe_parse(t.cast(ExpOrStr, e), copy=copy, **opts) - for e in ensure_list(unnest) - ] - ) - if unnest - else None - ), - ) - - -QUERY_MODIFIERS = { - "match": False, - "laterals": False, - "joins": False, - "connect": False, - "pivots": False, - "prewhere": False, - "where": False, - "group": False, - "having": False, - "qualify": False, - "windows": False, - "distribute": False, - "sort": False, - "cluster": False, - "order": False, - "limit": False, - "offset": False, - "locks": False, - "sample": False, - "settings": False, - "format": False, - "options": False, -} - - -# https://learn.microsoft.com/en-us/sql/t-sql/queries/option-clause-transact-sql?view=sql-server-ver16 -# https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-query?view=sql-server-ver16 -class QueryOption(Expression): - arg_types = {"this": True, "expression": False} - - -# https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver16 -class WithTableHint(Expression): - arg_types = {"expressions": True} - - -# https://dev.mysql.com/doc/refman/8.0/en/index-hints.html -class IndexTableHint(Expression): - arg_types = {"this": True, "expressions": False, "target": False} - - -# https://docs.snowflake.com/en/sql-reference/constructs/at-before -class HistoricalData(Expression): - arg_types = {"this": True, "kind": True, "expression": True} - - -# https://docs.snowflake.com/en/sql-reference/sql/put -class Put(Expression): - arg_types = {"this": True, "target": True, "properties": False} - - -# https://docs.snowflake.com/en/sql-reference/sql/get -class Get(Expression): - arg_types = {"this": True, "target": True, "properties": False} - - -class Table(Expression): - arg_types = { - "this": False, - "alias": False, - "db": False, - "catalog": False, - "laterals": False, - "joins": False, - "pivots": False, - "hints": False, - "system_time": False, - "version": False, - "format": False, - "pattern": False, - "ordinality": False, - "when": False, - "only": False, - "partition": False, - "changes": False, - "rows_from": False, - "sample": False, - "indexed": False, - } - - @property - def name(self) -> str: - if not self.this or isinstance(self.this, Func): - return "" - return self.this.name - - @property - def db(self) -> str: - return self.text("db") - - @property - def catalog(self) -> str: - return self.text("catalog") - - @property - def selects(self) -> t.List[Expression]: - return [] - - @property - def named_selects(self) -> t.List[str]: - return [] - - @property - def parts(self) -> t.List[Expression]: - """Return the parts of a table in order catalog, db, table.""" - parts: t.List[Expression] = [] - - for arg in ("catalog", "db", "this"): - part = self.args.get(arg) - - if isinstance(part, Dot): - parts.extend(part.flatten()) - elif isinstance(part, Expression): - parts.append(part) - - return parts - - def to_column(self, copy: bool = True) -> Expression: - parts = self.parts - last_part = parts[-1] - - if isinstance(last_part, Identifier): - col: Expression = column(*reversed(parts[0:4]), fields=parts[4:], copy=copy) # type: ignore - else: - # This branch will be reached if a function or array is wrapped in a `Table` - col = last_part - - alias = self.args.get("alias") - if alias: - col = alias_(col, alias.this, copy=copy) - - return col - - -class SetOperation(Query): - arg_types = { - "with_": False, - "this": True, - "expression": True, - "distinct": False, - "by_name": False, - "side": False, - "kind": False, - "on": False, - **QUERY_MODIFIERS, - } - - def select( - self: S, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> S: - this = maybe_copy(self, copy) - this.this.unnest().select( - *expressions, append=append, dialect=dialect, copy=False, **opts - ) - this.expression.unnest().select( - *expressions, append=append, dialect=dialect, copy=False, **opts - ) - return this - - @property - def named_selects(self) -> t.List[str]: - expression = self - while isinstance(expression, SetOperation): - expression = expression.this.unnest() - return expression.named_selects - - @property - def is_star(self) -> bool: - return self.this.is_star or self.expression.is_star - - @property - def selects(self) -> t.List[Expression]: - expression = self - while isinstance(expression, SetOperation): - expression = expression.this.unnest() - return expression.selects - - @property - def left(self) -> Query: - return self.this - - @property - def right(self) -> Query: - return self.expression - - @property - def kind(self) -> str: - return self.text("kind").upper() - - @property - def side(self) -> str: - return self.text("side").upper() - - -class Union(SetOperation): - pass - - -class Except(SetOperation): - pass - - -class Intersect(SetOperation): - pass - - -class Update(DML): - arg_types = { - "with_": False, - "this": False, - "expressions": False, - "from_": False, - "where": False, - "returning": False, - "order": False, - "limit": False, - "options": False, - } - - def table( - self, - expression: ExpOrStr, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Update: - """ - Set the table to update. - - Example: - >>> Update().table("my_table").set_("x = 1").sql() - 'UPDATE my_table SET x = 1' - - Args: - expression : the SQL code strings to parse. - If a `Table` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Table`. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Update expression. - """ - return _apply_builder( - expression=expression, - instance=self, - arg="this", - into=Table, - prefix=None, - dialect=dialect, - copy=copy, - **opts, - ) - - def set_( - self, - *expressions: ExpOrStr, - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Update: - """ - Append to or set the SET expressions. - - Example: - >>> Update().table("my_table").set_("x = 1").sql() - 'UPDATE my_table SET x = 1' - - Args: - *expressions: the SQL code strings to parse. - If `Expression` instance(s) are passed, they will be used as-is. - Multiple expressions are combined with a comma. - append: if `True`, add the new expressions to any existing SET expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - """ - return _apply_list_builder( - *expressions, - instance=self, - arg="expressions", - append=append, - into=Expression, - prefix=None, - dialect=dialect, - copy=copy, - **opts, - ) - - def where( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Append to or set the WHERE expressions. - - Example: - >>> Update().table("tbl").set_("x = 1").where("x = 'a' OR x < 'b'").sql() - "UPDATE tbl SET x = 1 WHERE x = 'a' OR x < 'b'" - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - Multiple expressions are combined with an AND operator. - append: if `True`, AND the new expressions to any existing expression. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - Select: the modified expression. - """ - return _apply_conjunction_builder( - *expressions, - instance=self, - arg="where", - append=append, - into=Where, - dialect=dialect, - copy=copy, - **opts, - ) - - def from_( - self, - expression: t.Optional[ExpOrStr] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Update: - """ - Set the FROM expression. - - Example: - >>> Update().table("my_table").set_("x = 1").from_("baz").sql() - 'UPDATE my_table SET x = 1 FROM baz' - - Args: - expression : the SQL code strings to parse. - If a `From` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `From`. - If nothing is passed in then a from is not applied to the expression - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Update expression. - """ - if not expression: - return maybe_copy(self, copy) - - return _apply_builder( - expression=expression, - instance=self, - arg="from_", - into=From, - prefix="FROM", - dialect=dialect, - copy=copy, - **opts, - ) - - def with_( - self, - alias: ExpOrStr, - as_: ExpOrStr, - recursive: t.Optional[bool] = None, - materialized: t.Optional[bool] = None, - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Update: - """ - Append to or set the common table expressions. - - Example: - >>> Update().table("my_table").set_("x = 1").from_("baz").with_("baz", "SELECT id FROM foo").sql() - 'WITH baz AS (SELECT id FROM foo) UPDATE my_table SET x = 1 FROM baz' - - Args: - alias: the SQL code string to parse as the table name. - If an `Expression` instance is passed, this is used as-is. - as_: the SQL code string to parse as the table expression. - If an `Expression` instance is passed, it will be used as-is. - recursive: set the RECURSIVE part of the expression. Defaults to `False`. - materialized: set the MATERIALIZED part of the expression. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified expression. - """ - return _apply_cte_builder( - self, - alias, - as_, - recursive=recursive, - materialized=materialized, - append=append, - dialect=dialect, - copy=copy, - **opts, - ) - - -# DuckDB supports VALUES followed by https://duckdb.org/docs/stable/sql/query_syntax/limit -class Values(UDTF): - arg_types = { - "expressions": True, - "alias": False, - "order": False, - "limit": False, - "offset": False, - } - - -class Var(Expression): - pass - - -class Version(Expression): - """ - Time travel, iceberg, bigquery etc - https://trino.io/docs/current/connector/iceberg.html?highlight=snapshot#using-snapshots - https://www.databricks.com/blog/2019/02/04/introducing-delta-time-travel-for-large-scale-data-lakes.html - https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#for_system_time_as_of - https://learn.microsoft.com/en-us/sql/relational-databases/tables/querying-data-in-a-system-versioned-temporal-table?view=sql-server-ver16 - this is either TIMESTAMP or VERSION - kind is ("AS OF", "BETWEEN") - """ - - arg_types = {"this": True, "kind": True, "expression": False} - - -class Schema(Expression): - arg_types = {"this": False, "expressions": False} - - -# https://dev.mysql.com/doc/refman/8.0/en/select.html -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/SELECT.html -class Lock(Expression): - arg_types = {"update": True, "expressions": False, "wait": False, "key": False} - - -class Select(Query): - arg_types = { - "with_": False, - "kind": False, - "expressions": False, - "hint": False, - "distinct": False, - "into": False, - "from_": False, - "operation_modifiers": False, - **QUERY_MODIFIERS, - } - - def from_( - self, - expression: ExpOrStr, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Set the FROM expression. - - Example: - >>> Select().from_("tbl").select("x").sql() - 'SELECT x FROM tbl' - - Args: - expression : the SQL code strings to parse. - If a `From` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `From`. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_builder( - expression=expression, - instance=self, - arg="from_", - into=From, - prefix="FROM", - dialect=dialect, - copy=copy, - **opts, - ) - - def group_by( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Set the GROUP BY expression. - - Example: - >>> Select().from_("tbl").select("x", "COUNT(1)").group_by("x").sql() - 'SELECT x, COUNT(1) FROM tbl GROUP BY x' - - Args: - *expressions: the SQL code strings to parse. - If a `Group` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Group`. - If nothing is passed in then a group by is not applied to the expression - append: if `True`, add to any existing expressions. - Otherwise, this flattens all the `Group` expression into a single expression. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - if not expressions: - return self if not copy else self.copy() - - return _apply_child_list_builder( - *expressions, - instance=self, - arg="group", - append=append, - copy=copy, - prefix="GROUP BY", - into=Group, - dialect=dialect, - **opts, - ) - - def sort_by( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Set the SORT BY expression. - - Example: - >>> Select().from_("tbl").select("x").sort_by("x DESC").sql(dialect="hive") - 'SELECT x FROM tbl SORT BY x DESC' - - Args: - *expressions: the SQL code strings to parse. - If a `Group` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `SORT`. - append: if `True`, add to any existing expressions. - Otherwise, this flattens all the `Order` expression into a single expression. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_child_list_builder( - *expressions, - instance=self, - arg="sort", - append=append, - copy=copy, - prefix="SORT BY", - into=Sort, - dialect=dialect, - **opts, - ) - - def cluster_by( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Set the CLUSTER BY expression. - - Example: - >>> Select().from_("tbl").select("x").cluster_by("x DESC").sql(dialect="hive") - 'SELECT x FROM tbl CLUSTER BY x DESC' - - Args: - *expressions: the SQL code strings to parse. - If a `Group` instance is passed, this is used as-is. - If another `Expression` instance is passed, it will be wrapped in a `Cluster`. - append: if `True`, add to any existing expressions. - Otherwise, this flattens all the `Order` expression into a single expression. - dialect: the dialect used to parse the input expression. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_child_list_builder( - *expressions, - instance=self, - arg="cluster", - append=append, - copy=copy, - prefix="CLUSTER BY", - into=Cluster, - dialect=dialect, - **opts, - ) - - def select( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - return _apply_list_builder( - *expressions, - instance=self, - arg="expressions", - append=append, - dialect=dialect, - into=Expression, - copy=copy, - **opts, - ) - - def lateral( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Append to or set the LATERAL expressions. - - Example: - >>> Select().select("x").lateral("OUTER explode(y) tbl2 AS z").from_("tbl").sql() - 'SELECT x FROM tbl LATERAL VIEW OUTER EXPLODE(y) tbl2 AS z' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_list_builder( - *expressions, - instance=self, - arg="laterals", - append=append, - into=Lateral, - prefix="LATERAL VIEW", - dialect=dialect, - copy=copy, - **opts, - ) - - def join( - self, - expression: ExpOrStr, - on: t.Optional[ExpOrStr] = None, - using: t.Optional[ExpOrStr | t.Collection[ExpOrStr]] = None, - append: bool = True, - join_type: t.Optional[str] = None, - join_alias: t.Optional[Identifier | str] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Append to or set the JOIN expressions. - - Example: - >>> Select().select("*").from_("tbl").join("tbl2", on="tbl1.y = tbl2.y").sql() - 'SELECT * FROM tbl JOIN tbl2 ON tbl1.y = tbl2.y' - - >>> Select().select("1").from_("a").join("b", using=["x", "y", "z"]).sql() - 'SELECT 1 FROM a JOIN b USING (x, y, z)' - - Use `join_type` to change the type of join: - - >>> Select().select("*").from_("tbl").join("tbl2", on="tbl1.y = tbl2.y", join_type="left outer").sql() - 'SELECT * FROM tbl LEFT OUTER JOIN tbl2 ON tbl1.y = tbl2.y' - - Args: - expression: the SQL code string to parse. - If an `Expression` instance is passed, it will be used as-is. - on: optionally specify the join "on" criteria as a SQL string. - If an `Expression` instance is passed, it will be used as-is. - using: optionally specify the join "using" criteria as a SQL string. - If an `Expression` instance is passed, it will be used as-is. - append: if `True`, add to any existing expressions. - Otherwise, this resets the expressions. - join_type: if set, alter the parsed join type. - join_alias: an optional alias for the joined source. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - Select: the modified expression. - """ - parse_args: t.Dict[str, t.Any] = {"dialect": dialect, **opts} - - try: - expression = maybe_parse(expression, into=Join, prefix="JOIN", **parse_args) - except ParseError: - expression = maybe_parse(expression, into=(Join, Expression), **parse_args) - - join = expression if isinstance(expression, Join) else Join(this=expression) - - if isinstance(join.this, Select): - join.this.replace(join.this.subquery()) - - if join_type: - method: t.Optional[Token] - side: t.Optional[Token] - kind: t.Optional[Token] - - method, side, kind = maybe_parse(join_type, into="JOIN_TYPE", **parse_args) # type: ignore - - if method: - join.set("method", method.text) - if side: - join.set("side", side.text) - if kind: - join.set("kind", kind.text) - - if on: - on = and_(*ensure_list(on), dialect=dialect, copy=copy, **opts) - join.set("on", on) - - if using: - join = _apply_list_builder( - *ensure_list(using), - instance=join, - arg="using", - append=append, - copy=copy, - into=Identifier, - **opts, - ) - - if join_alias: - join.set("this", alias_(join.this, join_alias, table=True)) - - return _apply_list_builder( - join, - instance=self, - arg="joins", - append=append, - copy=copy, - **opts, - ) - - def having( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - """ - Append to or set the HAVING expressions. - - Example: - >>> Select().select("x", "COUNT(y)").from_("tbl").group_by("x").having("COUNT(y) > 3").sql() - 'SELECT x, COUNT(y) FROM tbl GROUP BY x HAVING COUNT(y) > 3' - - Args: - *expressions: the SQL code strings to parse. - If an `Expression` instance is passed, it will be used as-is. - Multiple expressions are combined with an AND operator. - append: if `True`, AND the new expressions to any existing expression. - Otherwise, this resets the expression. - dialect: the dialect used to parse the input expressions. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input expressions. - - Returns: - The modified Select expression. - """ - return _apply_conjunction_builder( - *expressions, - instance=self, - arg="having", - append=append, - into=Having, - dialect=dialect, - copy=copy, - **opts, - ) - - def window( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - return _apply_list_builder( - *expressions, - instance=self, - arg="windows", - append=append, - into=Window, - dialect=dialect, - copy=copy, - **opts, - ) - - def qualify( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Select: - return _apply_conjunction_builder( - *expressions, - instance=self, - arg="qualify", - append=append, - into=Qualify, - dialect=dialect, - copy=copy, - **opts, - ) - - def distinct( - self, *ons: t.Optional[ExpOrStr], distinct: bool = True, copy: bool = True - ) -> Select: - """ - Set the OFFSET expression. - - Example: - >>> Select().from_("tbl").select("x").distinct().sql() - 'SELECT DISTINCT x FROM tbl' - - Args: - ons: the expressions to distinct on - distinct: whether the Select should be distinct - copy: if `False`, modify this expression instance in-place. - - Returns: - Select: the modified expression. - """ - instance = maybe_copy(self, copy) - on = ( - Tuple(expressions=[maybe_parse(on, copy=copy) for on in ons if on]) - if ons - else None - ) - instance.set("distinct", Distinct(on=on) if distinct else None) - return instance - - def ctas( - self, - table: ExpOrStr, - properties: t.Optional[t.Dict] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Create: - """ - Convert this expression to a CREATE TABLE AS statement. - - Example: - >>> Select().select("*").from_("tbl").ctas("x").sql() - 'CREATE TABLE x AS SELECT * FROM tbl' - - Args: - table: the SQL code string to parse as the table name. - If another `Expression` instance is passed, it will be used as-is. - properties: an optional mapping of table properties - dialect: the dialect used to parse the input table. - copy: if `False`, modify this expression instance in-place. - opts: other options to use to parse the input table. - - Returns: - The new Create expression. - """ - instance = maybe_copy(self, copy) - table_expression = maybe_parse(table, into=Table, dialect=dialect, **opts) - - properties_expression = None - if properties: - properties_expression = Properties.from_dict(properties) - - return Create( - this=table_expression, - kind="TABLE", - expression=instance, - properties=properties_expression, - ) - - def lock(self, update: bool = True, copy: bool = True) -> Select: - """ - Set the locking read mode for this expression. - - Examples: - >>> Select().select("x").from_("tbl").where("x = 'a'").lock().sql("mysql") - "SELECT x FROM tbl WHERE x = 'a' FOR UPDATE" - - >>> Select().select("x").from_("tbl").where("x = 'a'").lock(update=False).sql("mysql") - "SELECT x FROM tbl WHERE x = 'a' FOR SHARE" - - Args: - update: if `True`, the locking type will be `FOR UPDATE`, else it will be `FOR SHARE`. - copy: if `False`, modify this expression instance in-place. - - Returns: - The modified expression. - """ - inst = maybe_copy(self, copy) - inst.set("locks", [Lock(update=update)]) - - return inst - - def hint( - self, *hints: ExpOrStr, dialect: DialectType = None, copy: bool = True - ) -> Select: - """ - Set hints for this expression. - - Examples: - >>> Select().select("x").from_("tbl").hint("BROADCAST(y)").sql(dialect="spark") - 'SELECT /*+ BROADCAST(y) */ x FROM tbl' - - Args: - hints: The SQL code strings to parse as the hints. - If an `Expression` instance is passed, it will be used as-is. - dialect: The dialect used to parse the hints. - copy: If `False`, modify this expression instance in-place. - - Returns: - The modified expression. - """ - inst = maybe_copy(self, copy) - inst.set( - "hint", - Hint( - expressions=[maybe_parse(h, copy=copy, dialect=dialect) for h in hints] - ), - ) - - return inst - - @property - def named_selects(self) -> t.List[str]: - selects = [] - - for e in self.expressions: - if e.alias_or_name: - selects.append(e.output_name) - elif isinstance(e, Aliases): - selects.extend([a.name for a in e.aliases]) - return selects - - @property - def is_star(self) -> bool: - return any(expression.is_star for expression in self.expressions) - - @property - def selects(self) -> t.List[Expression]: - return self.expressions - - -UNWRAPPED_QUERIES = (Select, SetOperation) - - -class Subquery(DerivedTable, Query): - arg_types = { - "this": True, - "alias": False, - "with_": False, - **QUERY_MODIFIERS, - } - - def unnest(self): - """Returns the first non subquery.""" - expression = self - while isinstance(expression, Subquery): - expression = expression.this - return expression - - def unwrap(self) -> Subquery: - expression = self - while expression.same_parent and expression.is_wrapper: - expression = t.cast(Subquery, expression.parent) - return expression - - def select( - self, - *expressions: t.Optional[ExpOrStr], - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, - ) -> Subquery: - this = maybe_copy(self, copy) - this.unnest().select( - *expressions, append=append, dialect=dialect, copy=False, **opts - ) - return this - - @property - def is_wrapper(self) -> bool: - """ - Whether this Subquery acts as a simple wrapper around another expression. - - SELECT * FROM (((SELECT * FROM t))) - ^ - This corresponds to a "wrapper" Subquery node - """ - return all(v is None for k, v in self.args.items() if k != "this") - - @property - def is_star(self) -> bool: - return self.this.is_star - - @property - def output_name(self) -> str: - return self.alias - - -class TableSample(Expression): - arg_types = { - "expressions": False, - "method": False, - "bucket_numerator": False, - "bucket_denominator": False, - "bucket_field": False, - "percent": False, - "rows": False, - "size": False, - "seed": False, - } - - -class Tag(Expression): - """Tags are used for generating arbitrary sql like SELECT x.""" - - arg_types = { - "this": False, - "prefix": False, - "postfix": False, - } - - -# Represents both the standard SQL PIVOT operator and DuckDB's "simplified" PIVOT syntax -# https://duckdb.org/docs/sql/statements/pivot -class Pivot(Expression): - arg_types = { - "this": False, - "alias": False, - "expressions": False, - "fields": False, - "unpivot": False, - "using": False, - "group": False, - "columns": False, - "include_nulls": False, - "default_on_null": False, - "into": False, - "with_": False, - } - - @property - def unpivot(self) -> bool: - return bool(self.args.get("unpivot")) - - @property - def fields(self) -> t.List[Expression]: - return self.args.get("fields", []) - - -# https://duckdb.org/docs/sql/statements/unpivot#simplified-unpivot-syntax -# UNPIVOT ... INTO [NAME VALUE ][...,] -class UnpivotColumns(Expression): - arg_types = {"this": True, "expressions": True} - - -class Window(Condition): - arg_types = { - "this": True, - "partition_by": False, - "order": False, - "spec": False, - "alias": False, - "over": False, - "first": False, - } - - -class WindowSpec(Expression): - arg_types = { - "kind": False, - "start": False, - "start_side": False, - "end": False, - "end_side": False, - "exclude": False, - } - - -class PreWhere(Expression): - pass - - -class Where(Expression): - pass - - -class Star(Expression): - arg_types = {"except_": False, "replace": False, "rename": False} - - @property - def name(self) -> str: - return "*" - - @property - def output_name(self) -> str: - return self.name - - -class Parameter(Condition): - arg_types = {"this": True, "expression": False} - - -class SessionParameter(Condition): - arg_types = {"this": True, "kind": False} - - -# https://www.databricks.com/blog/parameterized-queries-pyspark -# https://jdbc.postgresql.org/documentation/query/#using-the-statement-or-preparedstatement-interface -class Placeholder(Condition): - arg_types = {"this": False, "kind": False, "widget": False, "jdbc": False} - - @property - def name(self) -> str: - return self.this or "?" - - -class Null(Condition): - arg_types: t.Dict[str, t.Any] = {} - - @property - def name(self) -> str: - return "NULL" - - def to_py(self) -> Lit[None]: - return None - - -class Boolean(Condition): - def to_py(self) -> bool: - return self.this - - -class DataTypeParam(Expression): - arg_types = {"this": True, "expression": False} - - @property - def name(self) -> str: - return self.this.name - - -# The `nullable` arg is helpful when transpiling types from other dialects to ClickHouse, which -# assumes non-nullable types by default. Values `None` and `True` mean the type is nullable. -class DataType(Expression): - arg_types = { - "this": True, - "expressions": False, - "nested": False, - "values": False, - "prefix": False, - "kind": False, - "nullable": False, - } - - class Type(AutoName): - ARRAY = auto() - AGGREGATEFUNCTION = auto() - SIMPLEAGGREGATEFUNCTION = auto() - BIGDECIMAL = auto() - BIGINT = auto() - BIGNUM = auto() - BIGSERIAL = auto() - BINARY = auto() - BIT = auto() - BLOB = auto() - BOOLEAN = auto() - BPCHAR = auto() - CHAR = auto() - DATE = auto() - DATE32 = auto() - DATEMULTIRANGE = auto() - DATERANGE = auto() - DATETIME = auto() - DATETIME2 = auto() - DATETIME64 = auto() - DECIMAL = auto() - DECIMAL32 = auto() - DECIMAL64 = auto() - DECIMAL128 = auto() - DECIMAL256 = auto() - DECFLOAT = auto() - DOUBLE = auto() - DYNAMIC = auto() - ENUM = auto() - ENUM8 = auto() - ENUM16 = auto() - FILE = auto() - FIXEDSTRING = auto() - FLOAT = auto() - GEOGRAPHY = auto() - GEOGRAPHYPOINT = auto() - GEOMETRY = auto() - POINT = auto() - RING = auto() - LINESTRING = auto() - MULTILINESTRING = auto() - POLYGON = auto() - MULTIPOLYGON = auto() - HLLSKETCH = auto() - HSTORE = auto() - IMAGE = auto() - INET = auto() - INT = auto() - INT128 = auto() - INT256 = auto() - INT4MULTIRANGE = auto() - INT4RANGE = auto() - INT8MULTIRANGE = auto() - INT8RANGE = auto() - INTERVAL = auto() - IPADDRESS = auto() - IPPREFIX = auto() - IPV4 = auto() - IPV6 = auto() - JSON = auto() - JSONB = auto() - LIST = auto() - LONGBLOB = auto() - LONGTEXT = auto() - LOWCARDINALITY = auto() - MAP = auto() - MEDIUMBLOB = auto() - MEDIUMINT = auto() - MEDIUMTEXT = auto() - MONEY = auto() - NAME = auto() - NCHAR = auto() - NESTED = auto() - NOTHING = auto() - NULL = auto() - NUMMULTIRANGE = auto() - NUMRANGE = auto() - NVARCHAR = auto() - OBJECT = auto() - RANGE = auto() - ROWVERSION = auto() - SERIAL = auto() - SET = auto() - SMALLDATETIME = auto() - SMALLINT = auto() - SMALLMONEY = auto() - SMALLSERIAL = auto() - STRUCT = auto() - SUPER = auto() - TEXT = auto() - TINYBLOB = auto() - TINYTEXT = auto() - TIME = auto() - TIMETZ = auto() - TIME_NS = auto() - TIMESTAMP = auto() - TIMESTAMPNTZ = auto() - TIMESTAMPLTZ = auto() - TIMESTAMPTZ = auto() - TIMESTAMP_S = auto() - TIMESTAMP_MS = auto() - TIMESTAMP_NS = auto() - TINYINT = auto() - TSMULTIRANGE = auto() - TSRANGE = auto() - TSTZMULTIRANGE = auto() - TSTZRANGE = auto() - UBIGINT = auto() - UINT = auto() - UINT128 = auto() - UINT256 = auto() - UMEDIUMINT = auto() - UDECIMAL = auto() - UDOUBLE = auto() - UNION = auto() - UNKNOWN = auto() # Sentinel value, useful for type annotation - USERDEFINED = "USER-DEFINED" - USMALLINT = auto() - UTINYINT = auto() - UUID = auto() - VARBINARY = auto() - VARCHAR = auto() - VARIANT = auto() - VECTOR = auto() - XML = auto() - YEAR = auto() - TDIGEST = auto() - - STRUCT_TYPES = { - Type.FILE, - Type.NESTED, - Type.OBJECT, - Type.STRUCT, - Type.UNION, - } - - ARRAY_TYPES = { - Type.ARRAY, - Type.LIST, - } - - NESTED_TYPES = { - *STRUCT_TYPES, - *ARRAY_TYPES, - Type.MAP, - } - - TEXT_TYPES = { - Type.CHAR, - Type.NCHAR, - Type.NVARCHAR, - Type.TEXT, - Type.VARCHAR, - Type.NAME, - } - - SIGNED_INTEGER_TYPES = { - Type.BIGINT, - Type.INT, - Type.INT128, - Type.INT256, - Type.MEDIUMINT, - Type.SMALLINT, - Type.TINYINT, - } - - UNSIGNED_INTEGER_TYPES = { - Type.UBIGINT, - Type.UINT, - Type.UINT128, - Type.UINT256, - Type.UMEDIUMINT, - Type.USMALLINT, - Type.UTINYINT, - } - - INTEGER_TYPES = { - *SIGNED_INTEGER_TYPES, - *UNSIGNED_INTEGER_TYPES, - Type.BIT, - } - - FLOAT_TYPES = { - Type.DOUBLE, - Type.FLOAT, - } - - REAL_TYPES = { - *FLOAT_TYPES, - Type.BIGDECIMAL, - Type.DECIMAL, - Type.DECIMAL32, - Type.DECIMAL64, - Type.DECIMAL128, - Type.DECIMAL256, - Type.DECFLOAT, - Type.MONEY, - Type.SMALLMONEY, - Type.UDECIMAL, - Type.UDOUBLE, - } - - NUMERIC_TYPES = { - *INTEGER_TYPES, - *REAL_TYPES, - } - - TEMPORAL_TYPES = { - Type.DATE, - Type.DATE32, - Type.DATETIME, - Type.DATETIME2, - Type.DATETIME64, - Type.SMALLDATETIME, - Type.TIME, - Type.TIMESTAMP, - Type.TIMESTAMPNTZ, - Type.TIMESTAMPLTZ, - Type.TIMESTAMPTZ, - Type.TIMESTAMP_MS, - Type.TIMESTAMP_NS, - Type.TIMESTAMP_S, - Type.TIMETZ, - } - - @classmethod - def build( - cls, - dtype: DATA_TYPE, - dialect: DialectType = None, - udt: bool = False, - copy: bool = True, - **kwargs, - ) -> DataType: - """ - Constructs a DataType object. - - Args: - dtype: the data type of interest. - dialect: the dialect to use for parsing `dtype`, in case it's a string. - udt: when set to True, `dtype` will be used as-is if it can't be parsed into a - DataType, thus creating a user-defined type. - copy: whether to copy the data type. - kwargs: additional arguments to pass in the constructor of DataType. - - Returns: - The constructed DataType object. - """ - from bigframes_vendored.sqlglot import parse_one - - if isinstance(dtype, str): - if dtype.upper() == "UNKNOWN": - return DataType(this=DataType.Type.UNKNOWN, **kwargs) - - try: - data_type_exp = parse_one( - dtype, read=dialect, into=DataType, error_level=ErrorLevel.IGNORE - ) - except ParseError: - if udt: - return DataType( - this=DataType.Type.USERDEFINED, kind=dtype, **kwargs - ) - raise - elif isinstance(dtype, (Identifier, Dot)) and udt: - return DataType(this=DataType.Type.USERDEFINED, kind=dtype, **kwargs) - elif isinstance(dtype, DataType.Type): - data_type_exp = DataType(this=dtype) - elif isinstance(dtype, DataType): - return maybe_copy(dtype, copy) - else: - raise ValueError( - f"Invalid data type: {type(dtype)}. Expected str or DataType.Type" - ) - - return DataType(**{**data_type_exp.args, **kwargs}) - - def is_type(self, *dtypes: DATA_TYPE, check_nullable: bool = False) -> bool: - """ - Checks whether this DataType matches one of the provided data types. Nested types or precision - will be compared using "structural equivalence" semantics, so e.g. array != array. - - Args: - dtypes: the data types to compare this DataType to. - check_nullable: whether to take the NULLABLE type constructor into account for the comparison. - If false, it means that NULLABLE is equivalent to INT. - - Returns: - True, if and only if there is a type in `dtypes` which is equal to this DataType. - """ - self_is_nullable = self.args.get("nullable") - for dtype in dtypes: - other_type = DataType.build(dtype, copy=False, udt=True) - other_is_nullable = other_type.args.get("nullable") - if ( - other_type.expressions - or (check_nullable and (self_is_nullable or other_is_nullable)) - or self.this == DataType.Type.USERDEFINED - or other_type.this == DataType.Type.USERDEFINED - ): - matches = self == other_type - else: - matches = self.this == other_type.this - - if matches: - return True - return False - - -# https://www.postgresql.org/docs/15/datatype-pseudo.html -class PseudoType(DataType): - arg_types = {"this": True} - - -# https://www.postgresql.org/docs/15/datatype-oid.html -class ObjectIdentifier(DataType): - arg_types = {"this": True} - - -# WHERE x EXISTS|ALL|ANY|SOME(SELECT ...) -class SubqueryPredicate(Predicate): - pass - - -class All(SubqueryPredicate): - pass - - -class Any(SubqueryPredicate): - pass - - -# Commands to interact with the databases or engines. For most of the command -# expressions we parse whatever comes after the command's name as a string. -class Command(Expression): - arg_types = {"this": True, "expression": False} - - -class Transaction(Expression): - arg_types = {"this": False, "modes": False, "mark": False} - - -class Commit(Expression): - arg_types = {"chain": False, "this": False, "durability": False} - - -class Rollback(Expression): - arg_types = {"savepoint": False, "this": False} - - -class Alter(Expression): - arg_types = { - "this": False, - "kind": True, - "actions": True, - "exists": False, - "only": False, - "options": False, - "cluster": False, - "not_valid": False, - "check": False, - "cascade": False, - } - - @property - def kind(self) -> t.Optional[str]: - kind = self.args.get("kind") - return kind and kind.upper() - - @property - def actions(self) -> t.List[Expression]: - return self.args.get("actions") or [] - - -class AlterSession(Expression): - arg_types = {"expressions": True, "unset": False} - - -class Analyze(Expression): - arg_types = { - "kind": False, - "this": False, - "options": False, - "mode": False, - "partition": False, - "expression": False, - "properties": False, - } - - -class AnalyzeStatistics(Expression): - arg_types = { - "kind": True, - "option": False, - "this": False, - "expressions": False, - } - - -class AnalyzeHistogram(Expression): - arg_types = { - "this": True, - "expressions": True, - "expression": False, - "update_options": False, - } - - -class AnalyzeSample(Expression): - arg_types = {"kind": True, "sample": True} - - -class AnalyzeListChainedRows(Expression): - arg_types = {"expression": False} - - -class AnalyzeDelete(Expression): - arg_types = {"kind": False} - - -class AnalyzeWith(Expression): - arg_types = {"expressions": True} - - -class AnalyzeValidate(Expression): - arg_types = { - "kind": True, - "this": False, - "expression": False, - } - - -class AnalyzeColumns(Expression): - pass - - -class UsingData(Expression): - pass - - -class AddConstraint(Expression): - arg_types = {"expressions": True} - - -class AddPartition(Expression): - arg_types = {"this": True, "exists": False, "location": False} - - -class AttachOption(Expression): - arg_types = {"this": True, "expression": False} - - -class DropPartition(Expression): - arg_types = {"expressions": True, "exists": False} - - -# https://clickhouse.com/docs/en/sql-reference/statements/alter/partition#replace-partition -class ReplacePartition(Expression): - arg_types = {"expression": True, "source": True} - - -# Binary expressions like (ADD a b) -class Binary(Condition): - arg_types = {"this": True, "expression": True} - - @property - def left(self) -> Expression: - return self.this - - @property - def right(self) -> Expression: - return self.expression - - -class Add(Binary): - pass - - -class Connector(Binary): - pass - - -class BitwiseAnd(Binary): - arg_types = {"this": True, "expression": True, "padside": False} - - -class BitwiseLeftShift(Binary): - pass - - -class BitwiseOr(Binary): - arg_types = {"this": True, "expression": True, "padside": False} - - -class BitwiseRightShift(Binary): - pass - - -class BitwiseXor(Binary): - arg_types = {"this": True, "expression": True, "padside": False} - - -class Div(Binary): - arg_types = {"this": True, "expression": True, "typed": False, "safe": False} - - -class Overlaps(Binary): - pass - - -class ExtendsLeft(Binary): - pass - - -class ExtendsRight(Binary): - pass - - -class Dot(Binary): - @property - def is_star(self) -> bool: - return self.expression.is_star - - @property - def name(self) -> str: - return self.expression.name - - @property - def output_name(self) -> str: - return self.name - - @classmethod - def build(self, expressions: t.Sequence[Expression]) -> Dot: - """Build a Dot object with a sequence of expressions.""" - if len(expressions) < 2: - raise ValueError("Dot requires >= 2 expressions.") - - return t.cast(Dot, reduce(lambda x, y: Dot(this=x, expression=y), expressions)) - - @property - def parts(self) -> t.List[Expression]: - """Return the parts of a table / column in order catalog, db, table.""" - this, *parts = self.flatten() - - parts.reverse() - - for arg in COLUMN_PARTS: - part = this.args.get(arg) - - if isinstance(part, Expression): - parts.append(part) - - parts.reverse() - return parts - - -DATA_TYPE = t.Union[str, Identifier, Dot, DataType, DataType.Type] - - -class DPipe(Binary): - arg_types = {"this": True, "expression": True, "safe": False} - - -class EQ(Binary, Predicate): - pass - - -class NullSafeEQ(Binary, Predicate): - pass - - -class NullSafeNEQ(Binary, Predicate): - pass - - -# Represents e.g. := in DuckDB which is mostly used for setting parameters -class PropertyEQ(Binary): - pass - - -class Distance(Binary): - pass - - -class Escape(Binary): - pass - - -class Glob(Binary, Predicate): - pass - - -class GT(Binary, Predicate): - pass - - -class GTE(Binary, Predicate): - pass - - -class ILike(Binary, Predicate): - pass - - -class IntDiv(Binary): - pass - - -class Is(Binary, Predicate): - pass - - -class Kwarg(Binary): - """Kwarg in special functions like func(kwarg => y).""" - - -class Like(Binary, Predicate): - pass - - -class Match(Binary, Predicate): - pass - - -class LT(Binary, Predicate): - pass - - -class LTE(Binary, Predicate): - pass - - -class Mod(Binary): - pass - - -class Mul(Binary): - pass - - -class NEQ(Binary, Predicate): - pass - - -# https://www.postgresql.org/docs/current/ddl-schemas.html#DDL-SCHEMAS-PATH -class Operator(Binary): - arg_types = {"this": True, "operator": True, "expression": True} - - -class SimilarTo(Binary, Predicate): - pass - - -class Sub(Binary): - pass - - -# https://www.postgresql.org/docs/current/functions-range.html -# Represents range adjacency operator: -|- -class Adjacent(Binary): - pass - - -# Unary Expressions -# (NOT a) -class Unary(Condition): - pass - - -class BitwiseNot(Unary): - pass - - -class Not(Unary): - pass - - -class Paren(Unary): - @property - def output_name(self) -> str: - return self.this.name - - -class Neg(Unary): - def to_py(self) -> int | Decimal: - if self.is_number: - return self.this.to_py() * -1 - return super().to_py() - - -class Alias(Expression): - arg_types = {"this": True, "alias": False} - - @property - def output_name(self) -> str: - return self.alias - - -# BigQuery requires the UNPIVOT column list aliases to be either strings or ints, but -# other dialects require identifiers. This enables us to transpile between them easily. -class PivotAlias(Alias): - pass - - -# Represents Snowflake's ANY [ ORDER BY ... ] syntax -# https://docs.snowflake.com/en/sql-reference/constructs/pivot -class PivotAny(Expression): - arg_types = {"this": False} - - -class Aliases(Expression): - arg_types = {"this": True, "expressions": True} - - @property - def aliases(self): - return self.expressions - - -# https://docs.aws.amazon.com/redshift/latest/dg/query-super.html -class AtIndex(Expression): - arg_types = {"this": True, "expression": True} - - -class AtTimeZone(Expression): - arg_types = {"this": True, "zone": True} - - -class FromTimeZone(Expression): - arg_types = {"this": True, "zone": True} - - -class FormatPhrase(Expression): - """Format override for a column in Teradata. - Can be expanded to additional dialects as needed - - https://docs.teradata.com/r/Enterprise_IntelliFlex_VMware/SQL-Data-Types-and-Literals/Data-Type-Formats-and-Format-Phrases/FORMAT - """ - - arg_types = {"this": True, "format": True} - - -class Between(Predicate): - arg_types = {"this": True, "low": True, "high": True, "symmetric": False} - - -class Bracket(Condition): - # https://cloud.google.com/bigquery/docs/reference/standard-sql/operators#array_subscript_operator - arg_types = { - "this": True, - "expressions": True, - "offset": False, - "safe": False, - "returns_list_for_maps": False, - } - - @property - def output_name(self) -> str: - if len(self.expressions) == 1: - return self.expressions[0].output_name - - return super().output_name - - -class Distinct(Expression): - arg_types = {"expressions": False, "on": False} - - -class In(Predicate): - arg_types = { - "this": True, - "expressions": False, - "query": False, - "unnest": False, - "field": False, - "is_global": False, - } - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/procedural-language#for-in -class ForIn(Expression): - arg_types = {"this": True, "expression": True} - - -class TimeUnit(Expression): - """Automatically converts unit arg into a var.""" - - arg_types = {"unit": False} - - UNABBREVIATED_UNIT_NAME = { - "D": "DAY", - "H": "HOUR", - "M": "MINUTE", - "MS": "MILLISECOND", - "NS": "NANOSECOND", - "Q": "QUARTER", - "S": "SECOND", - "US": "MICROSECOND", - "W": "WEEK", - "Y": "YEAR", - } - - VAR_LIKE = (Column, Literal, Var) - - def __init__(self, **args): - unit = args.get("unit") - if type(unit) in self.VAR_LIKE and not ( - isinstance(unit, Column) and len(unit.parts) != 1 - ): - args["unit"] = Var( - this=(self.UNABBREVIATED_UNIT_NAME.get(unit.name) or unit.name).upper() - ) - elif isinstance(unit, Week): - unit.set("this", Var(this=unit.this.name.upper())) - - super().__init__(**args) - - @property - def unit(self) -> t.Optional[Var | IntervalSpan]: - return self.args.get("unit") - - -class IntervalOp(TimeUnit): - arg_types = {"unit": False, "expression": True} - - def interval(self): - return Interval( - this=self.expression.copy(), - unit=self.unit.copy() if self.unit else None, - ) - - -# https://www.oracletutorial.com/oracle-basics/oracle-interval/ -# https://trino.io/docs/current/language/types.html#interval-day-to-second -# https://docs.databricks.com/en/sql/language-manual/data-types/interval-type.html -class IntervalSpan(DataType): - arg_types = {"this": True, "expression": True} - - -class Interval(TimeUnit): - arg_types = {"this": False, "unit": False} - - -class IgnoreNulls(Expression): - pass - - -class RespectNulls(Expression): - pass - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/aggregate-function-calls#max_min_clause -class HavingMax(Expression): - arg_types = {"this": True, "expression": True, "max": True} - - -# Functions -class Func(Condition): - """ - The base class for all function expressions. - - Attributes: - is_var_len_args (bool): if set to True the last argument defined in arg_types will be - treated as a variable length argument and the argument's value will be stored as a list. - _sql_names (list): the SQL name (1st item in the list) and aliases (subsequent items) for this - function expression. These values are used to map this node to a name during parsing as - well as to provide the function's name during SQL string generation. By default the SQL - name is set to the expression's class name transformed to snake case. - """ - - is_var_len_args = False - - @classmethod - def from_arg_list(cls, args): - if cls.is_var_len_args: - all_arg_keys = list(cls.arg_types) - # If this function supports variable length argument treat the last argument as such. - non_var_len_arg_keys = ( - all_arg_keys[:-1] if cls.is_var_len_args else all_arg_keys - ) - num_non_var = len(non_var_len_arg_keys) - - args_dict = { - arg_key: arg for arg, arg_key in zip(args, non_var_len_arg_keys) - } - args_dict[all_arg_keys[-1]] = args[num_non_var:] - else: - args_dict = {arg_key: arg for arg, arg_key in zip(args, cls.arg_types)} - - return cls(**args_dict) - - @classmethod - def sql_names(cls): - if cls is Func: - raise NotImplementedError( - "SQL name is only supported by concrete function implementations" - ) - if "_sql_names" not in cls.__dict__: - cls._sql_names = [camel_to_snake_case(cls.__name__)] - return cls._sql_names - - @classmethod - def sql_name(cls): - sql_names = cls.sql_names() - assert sql_names, f"Expected non-empty 'sql_names' for Func: {cls.__name__}." - return sql_names[0] - - @classmethod - def default_parser_mappings(cls): - return {name: cls.from_arg_list for name in cls.sql_names()} - - -class Typeof(Func): - pass - - -class Acos(Func): - pass - - -class Acosh(Func): - pass - - -class Asin(Func): - pass - - -class Asinh(Func): - pass - - -class Atan(Func): - arg_types = {"this": True, "expression": False} - - -class Atanh(Func): - pass - - -class Atan2(Func): - arg_types = {"this": True, "expression": True} - - -class Cot(Func): - pass - - -class Coth(Func): - pass - - -class Cos(Func): - pass - - -class Csc(Func): - pass - - -class Csch(Func): - pass - - -class Sec(Func): - pass - - -class Sech(Func): - pass - - -class Sin(Func): - pass - - -class Sinh(Func): - pass - - -class Tan(Func): - pass - - -class Tanh(Func): - pass - - -class Degrees(Func): - pass - - -class Cosh(Func): - pass - - -class CosineDistance(Func): - arg_types = {"this": True, "expression": True} - - -class DotProduct(Func): - arg_types = {"this": True, "expression": True} - - -class EuclideanDistance(Func): - arg_types = {"this": True, "expression": True} - - -class ManhattanDistance(Func): - arg_types = {"this": True, "expression": True} - - -class JarowinklerSimilarity(Func): - arg_types = {"this": True, "expression": True} - - -class AggFunc(Func): - pass - - -class BitwiseAndAgg(AggFunc): - pass - - -class BitwiseOrAgg(AggFunc): - pass - - -class BitwiseXorAgg(AggFunc): - pass - - -class BoolxorAgg(AggFunc): - pass - - -class BitwiseCount(Func): - pass - - -class BitmapBucketNumber(Func): - pass - - -class BitmapCount(Func): - pass - - -class BitmapBitPosition(Func): - pass - - -class BitmapConstructAgg(AggFunc): - pass - - -class BitmapOrAgg(AggFunc): - pass - - -class ByteLength(Func): - pass - - -class Boolnot(Func): - pass - - -class Booland(Func): - arg_types = {"this": True, "expression": True} - - -class Boolor(Func): - arg_types = {"this": True, "expression": True} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#bool_for_json -class JSONBool(Func): - pass - - -class ArrayRemove(Func): - arg_types = {"this": True, "expression": True} - - -class ParameterizedAgg(AggFunc): - arg_types = {"this": True, "expressions": True, "params": True} - - -class Abs(Func): - pass - - -class ArgMax(AggFunc): - arg_types = {"this": True, "expression": True, "count": False} - _sql_names = ["ARG_MAX", "ARGMAX", "MAX_BY"] - - -class ArgMin(AggFunc): - arg_types = {"this": True, "expression": True, "count": False} - _sql_names = ["ARG_MIN", "ARGMIN", "MIN_BY"] - - -class ApproxTopK(AggFunc): - arg_types = {"this": True, "expression": False, "counters": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/approx_top_k_accumulate -# https://spark.apache.org/docs/preview/api/sql/index.html#approx_top_k_accumulate -class ApproxTopKAccumulate(AggFunc): - arg_types = {"this": True, "expression": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/approx_top_k_combine -class ApproxTopKCombine(AggFunc): - arg_types = {"this": True, "expression": False} - - -class ApproxTopKEstimate(Func): - arg_types = {"this": True, "expression": False} - - -class ApproxTopSum(AggFunc): - arg_types = {"this": True, "expression": True, "count": True} - - -class ApproxQuantiles(AggFunc): - arg_types = {"this": True, "expression": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/approx_percentile_combine -class ApproxPercentileCombine(AggFunc): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/minhash -class Minhash(AggFunc): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - - -# https://docs.snowflake.com/en/sql-reference/functions/minhash_combine -class MinhashCombine(AggFunc): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/approximate_similarity -class ApproximateSimilarity(AggFunc): - _sql_names = ["APPROXIMATE_SIMILARITY", "APPROXIMATE_JACCARD_INDEX"] - - -class FarmFingerprint(Func): - arg_types = {"expressions": True} - is_var_len_args = True - _sql_names = ["FARM_FINGERPRINT", "FARMFINGERPRINT64"] - - -class Flatten(Func): - arg_types = {"this": True, "depth": False} - - -class Float64(Func): - arg_types = {"this": True, "expression": False} - - -# https://spark.apache.org/docs/latest/api/sql/index.html#transform -class Transform(Func): - arg_types = {"this": True, "expression": True} - - -class Translate(Func): - arg_types = {"this": True, "from_": True, "to": True} - - -class Grouping(AggFunc): - arg_types = {"expressions": True} - is_var_len_args = True - - -class GroupingId(AggFunc): - arg_types = {"expressions": True} - is_var_len_args = True - - -class Anonymous(Func): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - @property - def name(self) -> str: - return self.this if isinstance(self.this, str) else self.this.name - - -class AnonymousAggFunc(AggFunc): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -# https://clickhouse.com/docs/en/sql-reference/aggregate-functions/combinators -class CombinedAggFunc(AnonymousAggFunc): - arg_types = {"this": True, "expressions": False} - - -class CombinedParameterizedAgg(ParameterizedAgg): - arg_types = {"this": True, "expressions": True, "params": True} - - -# https://docs.snowflake.com/en/sql-reference/functions/hash_agg -class HashAgg(AggFunc): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -# https://docs.snowflake.com/en/sql-reference/functions/hll -# https://docs.aws.amazon.com/redshift/latest/dg/r_HLL_function.html -class Hll(AggFunc): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -class ApproxDistinct(AggFunc): - arg_types = {"this": True, "accuracy": False} - _sql_names = ["APPROX_DISTINCT", "APPROX_COUNT_DISTINCT"] - - -class Apply(Func): - arg_types = {"this": True, "expression": True} - - -class Array(Func): - arg_types = { - "expressions": False, - "bracket_notation": False, - "struct_name_inheritance": False, - } - is_var_len_args = True - - -class Ascii(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/to_array -class ToArray(Func): - pass - - -class ToBoolean(Func): - arg_types = {"this": True, "safe": False} - - -# https://materialize.com/docs/sql/types/list/ -class List(Func): - arg_types = {"expressions": False} - is_var_len_args = True - - -# String pad, kind True -> LPAD, False -> RPAD -class Pad(Func): - arg_types = { - "this": True, - "expression": True, - "fill_pattern": False, - "is_left": True, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/to_char -# https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/TO_CHAR-number.html -class ToChar(Func): - arg_types = { - "this": True, - "format": False, - "nlsparam": False, - "is_numeric": False, - } - - -class ToCodePoints(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/to_decimal -# https://docs.oracle.com/en/database/oracle/oracle-database/23/sqlrf/TO_NUMBER.html -class ToNumber(Func): - arg_types = { - "this": True, - "format": False, - "nlsparam": False, - "precision": False, - "scale": False, - "safe": False, - "safe_name": False, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/to_double -class ToDouble(Func): - arg_types = { - "this": True, - "format": False, - "safe": False, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/to_decfloat -class ToDecfloat(Func): - arg_types = { - "this": True, - "format": False, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/try_to_decfloat -class TryToDecfloat(Func): - arg_types = { - "this": True, - "format": False, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/to_file -class ToFile(Func): - arg_types = { - "this": True, - "path": False, - "safe": False, - } - - -class CodePointsToBytes(Func): - pass - - -class Columns(Func): - arg_types = {"this": True, "unpack": False} - - -# https://learn.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql?view=sql-server-ver16#syntax -class Convert(Func): - arg_types = {"this": True, "expression": True, "style": False, "safe": False} - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CONVERT.html -class ConvertToCharset(Func): - arg_types = {"this": True, "dest": True, "source": False} - - -class ConvertTimezone(Func): - arg_types = { - "source_tz": False, - "target_tz": True, - "timestamp": True, - "options": False, - } - - -class CodePointsToString(Func): - pass - - -class GenerateSeries(Func): - arg_types = {"start": True, "end": True, "step": False, "is_end_exclusive": False} - - -# Postgres' GENERATE_SERIES function returns a row set, i.e. it implicitly explodes when it's -# used in a projection, so this expression is a helper that facilitates transpilation to other -# dialects. For example, we'd generate UNNEST(GENERATE_SERIES(...)) in DuckDB -class ExplodingGenerateSeries(GenerateSeries): - pass - - -class ArrayAgg(AggFunc): - arg_types = {"this": True, "nulls_excluded": False} - - -class ArrayUniqueAgg(AggFunc): - pass - - -class AIAgg(AggFunc): - arg_types = {"this": True, "expression": True} - _sql_names = ["AI_AGG"] - - -class AISummarizeAgg(AggFunc): - _sql_names = ["AI_SUMMARIZE_AGG"] - - -class AIClassify(Func): - arg_types = {"this": True, "categories": True, "config": False} - _sql_names = ["AI_CLASSIFY"] - - -class ArrayAll(Func): - arg_types = {"this": True, "expression": True} - - -# Represents Python's `any(f(x) for x in array)`, where `array` is `this` and `f` is `expression` -class ArrayAny(Func): - arg_types = {"this": True, "expression": True} - - -class ArrayConcat(Func): - _sql_names = ["ARRAY_CONCAT", "ARRAY_CAT"] - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -class ArrayConcatAgg(AggFunc): - pass - - -class ArrayConstructCompact(Func): - arg_types = {"expressions": False} - is_var_len_args = True - - -class ArrayContains(Binary, Func): - arg_types = {"this": True, "expression": True, "ensure_variant": False} - _sql_names = ["ARRAY_CONTAINS", "ARRAY_HAS"] - - -class ArrayContainsAll(Binary, Func): - _sql_names = ["ARRAY_CONTAINS_ALL", "ARRAY_HAS_ALL"] - - -class ArrayFilter(Func): - arg_types = {"this": True, "expression": True} - _sql_names = ["FILTER", "ARRAY_FILTER"] - - -class ArrayFirst(Func): - pass - - -class ArrayLast(Func): - pass - - -class ArrayReverse(Func): - pass - - -class ArraySlice(Func): - arg_types = {"this": True, "start": True, "end": False, "step": False} - - -class ArrayToString(Func): - arg_types = {"this": True, "expression": True, "null": False} - _sql_names = ["ARRAY_TO_STRING", "ARRAY_JOIN"] - - -class ArrayIntersect(Func): - arg_types = {"expressions": True} - is_var_len_args = True - _sql_names = ["ARRAY_INTERSECT", "ARRAY_INTERSECTION"] - - -class StPoint(Func): - arg_types = {"this": True, "expression": True, "null": False} - _sql_names = ["ST_POINT", "ST_MAKEPOINT"] - - -class StDistance(Func): - arg_types = {"this": True, "expression": True, "use_spheroid": False} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/timestamp_functions#string -class String(Func): - arg_types = {"this": True, "zone": False} - - -class StringToArray(Func): - arg_types = {"this": True, "expression": False, "null": False} - _sql_names = ["STRING_TO_ARRAY", "SPLIT_BY_STRING", "STRTOK_TO_ARRAY"] - - -class ArrayOverlaps(Binary, Func): - pass - - -class ArraySize(Func): - arg_types = {"this": True, "expression": False} - _sql_names = ["ARRAY_SIZE", "ARRAY_LENGTH"] - - -class ArraySort(Func): - arg_types = {"this": True, "expression": False} - - -class ArraySum(Func): - arg_types = {"this": True, "expression": False} - - -class ArrayUnionAgg(AggFunc): - pass - - -class Avg(AggFunc): - pass - - -class AnyValue(AggFunc): - pass - - -class Lag(AggFunc): - arg_types = {"this": True, "offset": False, "default": False} - - -class Lead(AggFunc): - arg_types = {"this": True, "offset": False, "default": False} - - -# some dialects have a distinction between first and first_value, usually first is an aggregate func -# and first_value is a window func -class First(AggFunc): - arg_types = {"this": True, "expression": False} - - -class Last(AggFunc): - arg_types = {"this": True, "expression": False} - - -class FirstValue(AggFunc): - pass - - -class LastValue(AggFunc): - pass - - -class NthValue(AggFunc): - arg_types = {"this": True, "offset": True} - - -class ObjectAgg(AggFunc): - arg_types = {"this": True, "expression": True} - - -class Case(Func): - arg_types = {"this": False, "ifs": True, "default": False} - - def when( - self, condition: ExpOrStr, then: ExpOrStr, copy: bool = True, **opts - ) -> Case: - instance = maybe_copy(self, copy) - instance.append( - "ifs", - If( - this=maybe_parse(condition, copy=copy, **opts), - true=maybe_parse(then, copy=copy, **opts), - ), - ) - return instance - - def else_(self, condition: ExpOrStr, copy: bool = True, **opts) -> Case: - instance = maybe_copy(self, copy) - instance.set("default", maybe_parse(condition, copy=copy, **opts)) - return instance - - -class Cast(Func): - arg_types = { - "this": True, - "to": True, - "format": False, - "safe": False, - "action": False, - "default": False, - } - - @property - def name(self) -> str: - return self.this.name - - @property - def to(self) -> DataType: - return self.args["to"] - - @property - def output_name(self) -> str: - return self.name - - def is_type(self, *dtypes: DATA_TYPE) -> bool: - """ - Checks whether this Cast's DataType matches one of the provided data types. Nested types - like arrays or structs will be compared using "structural equivalence" semantics, so e.g. - array != array. - - Args: - dtypes: the data types to compare this Cast's DataType to. - - Returns: - True, if and only if there is a type in `dtypes` which is equal to this Cast's DataType. - """ - return self.to.is_type(*dtypes) - - -class TryCast(Cast): - arg_types = {**Cast.arg_types, "requires_string": False} - - -# https://clickhouse.com/docs/sql-reference/data-types/newjson#reading-json-paths-as-sub-columns -class JSONCast(Cast): - pass - - -class JustifyDays(Func): - pass - - -class JustifyHours(Func): - pass - - -class JustifyInterval(Func): - pass - - -class Try(Func): - pass - - -class CastToStrType(Func): - arg_types = {"this": True, "to": True} - - -class CheckJson(Func): - arg_types = {"this": True} - - -class CheckXml(Func): - arg_types = {"this": True, "disable_auto_convert": False} - - -# https://docs.teradata.com/r/Enterprise_IntelliFlex_VMware/SQL-Functions-Expressions-and-Predicates/String-Operators-and-Functions/TRANSLATE/TRANSLATE-Function-Syntax -class TranslateCharacters(Expression): - arg_types = {"this": True, "expression": True, "with_error": False} - - -class Collate(Binary, Func): - pass - - -class Collation(Func): - pass - - -class Ceil(Func): - arg_types = {"this": True, "decimals": False, "to": False} - _sql_names = ["CEIL", "CEILING"] - - -class Coalesce(Func): - arg_types = {"this": True, "expressions": False, "is_nvl": False, "is_null": False} - is_var_len_args = True - _sql_names = ["COALESCE", "IFNULL", "NVL"] - - -class Chr(Func): - arg_types = {"expressions": True, "charset": False} - is_var_len_args = True - _sql_names = ["CHR", "CHAR"] - - -class Concat(Func): - arg_types = {"expressions": True, "safe": False, "coalesce": False} - is_var_len_args = True - - -class ConcatWs(Concat): - _sql_names = ["CONCAT_WS"] - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#contains_substr -class Contains(Func): - arg_types = {"this": True, "expression": True, "json_scope": False} - - -# https://docs.oracle.com/cd/B13789_01/server.101/b10759/operators004.htm#i1035022 -class ConnectByRoot(Func): - pass - - -class Count(AggFunc): - arg_types = {"this": False, "expressions": False, "big_int": False} - is_var_len_args = True - - -class CountIf(AggFunc): - _sql_names = ["COUNT_IF", "COUNTIF"] - - -# cube root -class Cbrt(Func): - pass - - -class CurrentAccount(Func): - arg_types = {} - - -class CurrentAccountName(Func): - arg_types = {} - - -class CurrentAvailableRoles(Func): - arg_types = {} - - -class CurrentClient(Func): - arg_types = {} - - -class CurrentIpAddress(Func): - arg_types = {} - - -class CurrentDatabase(Func): - arg_types = {} - - -class CurrentSchemas(Func): - arg_types = {"this": False} - - -class CurrentSecondaryRoles(Func): - arg_types = {} - - -class CurrentSession(Func): - arg_types = {} - - -class CurrentStatement(Func): - arg_types = {} - - -class CurrentVersion(Func): - arg_types = {} - - -class CurrentTransaction(Func): - arg_types = {} - - -class CurrentWarehouse(Func): - arg_types = {} - - -class CurrentDate(Func): - arg_types = {"this": False} - - -class CurrentDatetime(Func): - arg_types = {"this": False} - - -class CurrentTime(Func): - arg_types = {"this": False} - - -# https://www.postgresql.org/docs/current/functions-datetime.html#FUNCTIONS-DATETIME-CURRENT -# In Postgres, the difference between CURRENT_TIME vs LOCALTIME etc is that the latter does not have tz -class Localtime(Func): - arg_types = {"this": False} - - -class Localtimestamp(Func): - arg_types = {"this": False} - - -class CurrentTimestamp(Func): - arg_types = {"this": False, "sysdate": False} - - -class CurrentTimestampLTZ(Func): - arg_types = {} - - -class CurrentTimezone(Func): - arg_types = {} - - -class CurrentOrganizationName(Func): - arg_types = {} - - -class CurrentSchema(Func): - arg_types = {"this": False} - - -class CurrentUser(Func): - arg_types = {"this": False} - - -class CurrentCatalog(Func): - arg_types = {} - - -class CurrentRegion(Func): - arg_types = {} - - -class CurrentRole(Func): - arg_types = {} - - -class CurrentRoleType(Func): - arg_types = {} - - -class CurrentOrganizationUser(Func): - arg_types = {} - - -class SessionUser(Func): - arg_types = {} - - -class UtcDate(Func): - arg_types = {} - - -class UtcTime(Func): - arg_types = {"this": False} - - -class UtcTimestamp(Func): - arg_types = {"this": False} - - -class DateAdd(Func, IntervalOp): - arg_types = {"this": True, "expression": True, "unit": False} - - -class DateBin(Func, IntervalOp): - arg_types = { - "this": True, - "expression": True, - "unit": False, - "zone": False, - "origin": False, - } - - -class DateSub(Func, IntervalOp): - arg_types = {"this": True, "expression": True, "unit": False} - - -class DateDiff(Func, TimeUnit): - _sql_names = ["DATEDIFF", "DATE_DIFF"] - arg_types = { - "this": True, - "expression": True, - "unit": False, - "zone": False, - "big_int": False, - "date_part_boundary": False, - } - - -class DateTrunc(Func): - arg_types = {"unit": True, "this": True, "zone": False} - - def __init__(self, **args): - # Across most dialects it's safe to unabbreviate the unit (e.g. 'Q' -> 'QUARTER') except Oracle - # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ROUND-and-TRUNC-Date-Functions.html - unabbreviate = args.pop("unabbreviate", True) - - unit = args.get("unit") - if isinstance(unit, TimeUnit.VAR_LIKE) and not ( - isinstance(unit, Column) and len(unit.parts) != 1 - ): - unit_name = unit.name.upper() - if unabbreviate and unit_name in TimeUnit.UNABBREVIATED_UNIT_NAME: - unit_name = TimeUnit.UNABBREVIATED_UNIT_NAME[unit_name] - - args["unit"] = Literal.string(unit_name) - - super().__init__(**args) - - @property - def unit(self) -> Expression: - return self.args["unit"] - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/datetime_functions#datetime -# expression can either be time_expr or time_zone -class Datetime(Func): - arg_types = {"this": True, "expression": False} - - -class DatetimeAdd(Func, IntervalOp): - arg_types = {"this": True, "expression": True, "unit": False} - - -class DatetimeSub(Func, IntervalOp): - arg_types = {"this": True, "expression": True, "unit": False} - - -class DatetimeDiff(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class DatetimeTrunc(Func, TimeUnit): - arg_types = {"this": True, "unit": True, "zone": False} - - -class DateFromUnixDate(Func): - pass - - -class DayOfWeek(Func): - _sql_names = ["DAY_OF_WEEK", "DAYOFWEEK"] - - -# https://duckdb.org/docs/sql/functions/datepart.html#part-specifiers-only-usable-as-date-part-specifiers -# ISO day of week function in duckdb is ISODOW -class DayOfWeekIso(Func): - _sql_names = ["DAYOFWEEK_ISO", "ISODOW"] - - -class DayOfMonth(Func): - _sql_names = ["DAY_OF_MONTH", "DAYOFMONTH"] - - -class DayOfYear(Func): - _sql_names = ["DAY_OF_YEAR", "DAYOFYEAR"] - - -class Dayname(Func): - arg_types = {"this": True, "abbreviated": False} - - -class ToDays(Func): - pass - - -class WeekOfYear(Func): - _sql_names = ["WEEK_OF_YEAR", "WEEKOFYEAR"] - - -class YearOfWeek(Func): - _sql_names = ["YEAR_OF_WEEK", "YEAROFWEEK"] - - -class YearOfWeekIso(Func): - _sql_names = ["YEAR_OF_WEEK_ISO", "YEAROFWEEKISO"] - - -class MonthsBetween(Func): - arg_types = {"this": True, "expression": True, "roundoff": False} - - -class MakeInterval(Func): - arg_types = { - "year": False, - "month": False, - "week": False, - "day": False, - "hour": False, - "minute": False, - "second": False, - } - - -class LastDay(Func, TimeUnit): - _sql_names = ["LAST_DAY", "LAST_DAY_OF_MONTH"] - arg_types = {"this": True, "unit": False} - - -class PreviousDay(Func): - arg_types = {"this": True, "expression": True} - - -class LaxBool(Func): - pass - - -class LaxFloat64(Func): - pass - - -class LaxInt64(Func): - pass - - -class LaxString(Func): - pass - - -class Extract(Func): - arg_types = {"this": True, "expression": True} - - -class Exists(Func, SubqueryPredicate): - arg_types = {"this": True, "expression": False} - - -class Elt(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - - -class Timestamp(Func): - arg_types = {"this": False, "zone": False, "with_tz": False, "safe": False} - - -class TimestampAdd(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimestampSub(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimestampDiff(Func, TimeUnit): - _sql_names = ["TIMESTAMPDIFF", "TIMESTAMP_DIFF"] - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimestampTrunc(Func, TimeUnit): - arg_types = {"this": True, "unit": True, "zone": False} - - -class TimeSlice(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": True, "kind": False} - - -class TimeAdd(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimeSub(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimeDiff(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TimeTrunc(Func, TimeUnit): - arg_types = {"this": True, "unit": True, "zone": False} - - -class DateFromParts(Func): - _sql_names = ["DATE_FROM_PARTS", "DATEFROMPARTS"] - arg_types = {"year": True, "month": False, "day": False} - - -class TimeFromParts(Func): - _sql_names = ["TIME_FROM_PARTS", "TIMEFROMPARTS"] - arg_types = { - "hour": True, - "min": True, - "sec": True, - "nano": False, - "fractions": False, - "precision": False, - } - - -class DateStrToDate(Func): - pass - - -class DateToDateStr(Func): - pass - - -class DateToDi(Func): - pass - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/date_functions#date -class Date(Func): - arg_types = {"this": False, "zone": False, "expressions": False} - is_var_len_args = True - - -class Day(Func): - pass - - -class Decode(Func): - arg_types = {"this": True, "charset": True, "replace": False} - - -class DecodeCase(Func): - arg_types = {"expressions": True} - is_var_len_args = True - - -class DenseRank(AggFunc): - arg_types = {"expressions": False} - is_var_len_args = True - - -class DiToDate(Func): - pass - - -class Encode(Func): - arg_types = {"this": True, "charset": True} - - -class EqualNull(Func): - arg_types = {"this": True, "expression": True} - - -class Exp(Func): - pass - - -class Factorial(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/flatten -class Explode(Func, UDTF): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -# https://spark.apache.org/docs/latest/api/sql/#inline -class Inline(Func): - pass - - -class ExplodeOuter(Explode): - pass - - -class Posexplode(Explode): - pass - - -class PosexplodeOuter(Posexplode, ExplodeOuter): - pass - - -class PositionalColumn(Expression): - pass - - -class Unnest(Func, UDTF): - arg_types = { - "expressions": True, - "alias": False, - "offset": False, - "explode_array": False, - } - - @property - def selects(self) -> t.List[Expression]: - columns = super().selects - offset = self.args.get("offset") - if offset: - columns = columns + [to_identifier("offset") if offset is True else offset] - return columns - - -class Floor(Func): - arg_types = {"this": True, "decimals": False, "to": False} - - -class FromBase32(Func): - pass - - -class FromBase64(Func): - pass - - -class ToBase32(Func): - pass - - -class ToBase64(Func): - pass - - -class ToBinary(Func): - arg_types = {"this": True, "format": False, "safe": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/base64_decode_binary -class Base64DecodeBinary(Func): - arg_types = {"this": True, "alphabet": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/base64_decode_string -class Base64DecodeString(Func): - arg_types = {"this": True, "alphabet": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/base64_encode -class Base64Encode(Func): - arg_types = {"this": True, "max_line_length": False, "alphabet": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/try_base64_decode_binary -class TryBase64DecodeBinary(Func): - arg_types = {"this": True, "alphabet": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/try_base64_decode_string -class TryBase64DecodeString(Func): - arg_types = {"this": True, "alphabet": False} - - -# https://docs.snowflake.com/en/sql-reference/functions/try_hex_decode_binary -class TryHexDecodeBinary(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/try_hex_decode_string -class TryHexDecodeString(Func): - pass - - -# https://trino.io/docs/current/functions/datetime.html#from_iso8601_timestamp -class FromISO8601Timestamp(Func): - _sql_names = ["FROM_ISO8601_TIMESTAMP"] - - -class GapFill(Func): - arg_types = { - "this": True, - "ts_column": True, - "bucket_width": True, - "partitioning_columns": False, - "value_columns": False, - "origin": False, - "ignore_nulls": False, - } - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions#generate_date_array -class GenerateDateArray(Func): - arg_types = {"start": True, "end": True, "step": False} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/array_functions#generate_timestamp_array -class GenerateTimestampArray(Func): - arg_types = {"start": True, "end": True, "step": True} - - -# https://docs.snowflake.com/en/sql-reference/functions/get -class GetExtract(Func): - arg_types = {"this": True, "expression": True} - - -class Getbit(Func): - arg_types = {"this": True, "expression": True} - - -class Greatest(Func): - arg_types = {"this": True, "expressions": False, "ignore_nulls": True} - is_var_len_args = True - - -# Trino's `ON OVERFLOW TRUNCATE [filler_string] {WITH | WITHOUT} COUNT` -# https://trino.io/docs/current/functions/aggregate.html#listagg -class OverflowTruncateBehavior(Expression): - arg_types = {"this": False, "with_count": True} - - -class GroupConcat(AggFunc): - arg_types = {"this": True, "separator": False, "on_overflow": False} - - -class Hex(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/hex_decode_string -class HexDecodeString(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/hex_encode -class HexEncode(Func): - arg_types = {"this": True, "case": False} - - -class Hour(Func): - pass - - -class Minute(Func): - pass - - -class Second(Func): - pass - - -# T-SQL: https://learn.microsoft.com/en-us/sql/t-sql/functions/compress-transact-sql?view=sql-server-ver17 -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/compress -class Compress(Func): - arg_types = {"this": True, "method": False} - - -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/decompress_binary -class DecompressBinary(Func): - arg_types = {"this": True, "method": True} - - -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/decompress_string -class DecompressString(Func): - arg_types = {"this": True, "method": True} - - -class LowerHex(Hex): - pass - - -class And(Connector, Func): - pass - - -class Or(Connector, Func): - pass - - -class Xor(Connector, Func): - arg_types = {"this": False, "expression": False, "expressions": False} - - -class If(Func): - arg_types = {"this": True, "true": True, "false": False} - _sql_names = ["IF", "IIF"] - - -class Nullif(Func): - arg_types = {"this": True, "expression": True} - - -class Initcap(Func): - arg_types = {"this": True, "expression": False} - - -class IsAscii(Func): - pass - - -class IsNan(Func): - _sql_names = ["IS_NAN", "ISNAN"] - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#int64_for_json -class Int64(Func): - pass - - -class IsInf(Func): - _sql_names = ["IS_INF", "ISINF"] - - -class IsNullValue(Func): - pass - - -# https://www.postgresql.org/docs/current/functions-json.html -class JSON(Expression): - arg_types = {"this": False, "with_": False, "unique": False} - - -class JSONPath(Expression): - arg_types = {"expressions": True, "escape": False} - - @property - def output_name(self) -> str: - last_segment = self.expressions[-1].this - return last_segment if isinstance(last_segment, str) else "" - - -class JSONPathPart(Expression): - arg_types = {} - - -class JSONPathFilter(JSONPathPart): - arg_types = {"this": True} - - -class JSONPathKey(JSONPathPart): - arg_types = {"this": True} - - -class JSONPathRecursive(JSONPathPart): - arg_types = {"this": False} - - -class JSONPathRoot(JSONPathPart): - pass - - -class JSONPathScript(JSONPathPart): - arg_types = {"this": True} - - -class JSONPathSlice(JSONPathPart): - arg_types = {"start": False, "end": False, "step": False} - - -class JSONPathSelector(JSONPathPart): - arg_types = {"this": True} - - -class JSONPathSubscript(JSONPathPart): - arg_types = {"this": True} - - -class JSONPathUnion(JSONPathPart): - arg_types = {"expressions": True} - - -class JSONPathWildcard(JSONPathPart): - pass - - -class FormatJson(Expression): - pass - - -class Format(Func): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -class JSONKeyValue(Expression): - arg_types = {"this": True, "expression": True} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#json_keys -class JSONKeysAtDepth(Func): - arg_types = {"this": True, "expression": False, "mode": False} - - -class JSONObject(Func): - arg_types = { - "expressions": False, - "null_handling": False, - "unique_keys": False, - "return_type": False, - "encoding": False, - } - - -class JSONObjectAgg(AggFunc): - arg_types = { - "expressions": False, - "null_handling": False, - "unique_keys": False, - "return_type": False, - "encoding": False, - } - - -# https://www.postgresql.org/docs/9.5/functions-aggregate.html -class JSONBObjectAgg(AggFunc): - arg_types = {"this": True, "expression": True} - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/JSON_ARRAY.html -class JSONArray(Func): - arg_types = { - "expressions": False, - "null_handling": False, - "return_type": False, - "strict": False, - } - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/JSON_ARRAYAGG.html -class JSONArrayAgg(AggFunc): - arg_types = { - "this": True, - "order": False, - "null_handling": False, - "return_type": False, - "strict": False, - } - - -class JSONExists(Func): - arg_types = { - "this": True, - "path": True, - "passing": False, - "on_condition": False, - "from_dcolonqmark": False, - } - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/JSON_TABLE.html -# Note: parsing of JSON column definitions is currently incomplete. -class JSONColumnDef(Expression): - arg_types = { - "this": False, - "kind": False, - "path": False, - "nested_schema": False, - "ordinality": False, - } - - -class JSONSchema(Expression): - arg_types = {"expressions": True} - - -class JSONSet(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - _sql_names = ["JSON_SET"] - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#json_strip_nulls -class JSONStripNulls(Func): - arg_types = { - "this": True, - "expression": False, - "include_arrays": False, - "remove_empty": False, - } - _sql_names = ["JSON_STRIP_NULLS"] - - -# https://dev.mysql.com/doc/refman/8.4/en/json-search-functions.html#function_json-value -class JSONValue(Expression): - arg_types = { - "this": True, - "path": True, - "returning": False, - "on_condition": False, - } - - -class JSONValueArray(Func): - arg_types = {"this": True, "expression": False} - - -class JSONRemove(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - _sql_names = ["JSON_REMOVE"] - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/JSON_TABLE.html -class JSONTable(Func): - arg_types = { - "this": True, - "schema": True, - "path": False, - "error_handling": False, - "empty_handling": False, - } - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/json_functions#json_type -# https://doris.apache.org/docs/sql-manual/sql-functions/scalar-functions/json-functions/json-type#description -class JSONType(Func): - arg_types = {"this": True, "expression": False} - _sql_names = ["JSON_TYPE"] - - -# https://docs.snowflake.com/en/sql-reference/functions/object_insert -class ObjectInsert(Func): - arg_types = { - "this": True, - "key": True, - "value": True, - "update_flag": False, - } - - -class OpenJSONColumnDef(Expression): - arg_types = {"this": True, "kind": True, "path": False, "as_json": False} - - -class OpenJSON(Func): - arg_types = {"this": True, "path": False, "expressions": False} - - -class JSONBContains(Binary, Func): - _sql_names = ["JSONB_CONTAINS"] - - -# https://www.postgresql.org/docs/9.5/functions-json.html -class JSONBContainsAnyTopKeys(Binary, Func): - pass - - -# https://www.postgresql.org/docs/9.5/functions-json.html -class JSONBContainsAllTopKeys(Binary, Func): - pass - - -class JSONBExists(Func): - arg_types = {"this": True, "path": True} - _sql_names = ["JSONB_EXISTS"] - - -# https://www.postgresql.org/docs/9.5/functions-json.html -class JSONBDeleteAtPath(Binary, Func): - pass - - -class JSONExtract(Binary, Func): - arg_types = { - "this": True, - "expression": True, - "only_json_types": False, - "expressions": False, - "variant_extract": False, - "json_query": False, - "option": False, - "quote": False, - "on_condition": False, - "requires_json": False, - } - _sql_names = ["JSON_EXTRACT"] - is_var_len_args = True - - @property - def output_name(self) -> str: - return self.expression.output_name if not self.expressions else "" - - -# https://trino.io/docs/current/functions/json.html#json-query -class JSONExtractQuote(Expression): - arg_types = { - "option": True, - "scalar": False, - } - - -class JSONExtractArray(Func): - arg_types = {"this": True, "expression": False} - _sql_names = ["JSON_EXTRACT_ARRAY"] - - -class JSONExtractScalar(Binary, Func): - arg_types = { - "this": True, - "expression": True, - "only_json_types": False, - "expressions": False, - "json_type": False, - "scalar_only": False, - } - _sql_names = ["JSON_EXTRACT_SCALAR"] - is_var_len_args = True - - @property - def output_name(self) -> str: - return self.expression.output_name - - -class JSONBExtract(Binary, Func): - _sql_names = ["JSONB_EXTRACT"] - - -class JSONBExtractScalar(Binary, Func): - arg_types = {"this": True, "expression": True, "json_type": False} - _sql_names = ["JSONB_EXTRACT_SCALAR"] - - -class JSONFormat(Func): - arg_types = {"this": False, "options": False, "is_json": False, "to_json": False} - _sql_names = ["JSON_FORMAT"] - - -class JSONArrayAppend(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - _sql_names = ["JSON_ARRAY_APPEND"] - - -# https://dev.mysql.com/doc/refman/8.0/en/json-search-functions.html#operator_member-of -class JSONArrayContains(Binary, Predicate, Func): - arg_types = {"this": True, "expression": True, "json_type": False} - _sql_names = ["JSON_ARRAY_CONTAINS"] - - -class JSONArrayInsert(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - _sql_names = ["JSON_ARRAY_INSERT"] - - -class ParseBignumeric(Func): - pass - - -class ParseNumeric(Func): - pass - - -class ParseJSON(Func): - # BigQuery, Snowflake have PARSE_JSON, Presto has JSON_PARSE - # Snowflake also has TRY_PARSE_JSON, which is represented using `safe` - _sql_names = ["PARSE_JSON", "JSON_PARSE"] - arg_types = {"this": True, "expression": False, "safe": False} - - -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/parse_url -# Databricks: https://docs.databricks.com/aws/en/sql/language-manual/functions/parse_url -class ParseUrl(Func): - arg_types = { - "this": True, - "part_to_extract": False, - "key": False, - "permissive": False, - } - - -class ParseIp(Func): - arg_types = {"this": True, "type": True, "permissive": False} - - -class ParseTime(Func): - arg_types = {"this": True, "format": True} - - -class ParseDatetime(Func): - arg_types = {"this": True, "format": False, "zone": False} - - -class Least(Func): - arg_types = {"this": True, "expressions": False, "ignore_nulls": True} - is_var_len_args = True - - -class Left(Func): - arg_types = {"this": True, "expression": True} - - -class Right(Func): - arg_types = {"this": True, "expression": True} - - -class Reverse(Func): - pass - - -class Length(Func): - arg_types = {"this": True, "binary": False, "encoding": False} - _sql_names = ["LENGTH", "LEN", "CHAR_LENGTH", "CHARACTER_LENGTH"] - - -class RtrimmedLength(Func): - pass - - -class BitLength(Func): - pass - - -class Levenshtein(Func): - arg_types = { - "this": True, - "expression": False, - "ins_cost": False, - "del_cost": False, - "sub_cost": False, - "max_dist": False, - } - - -class Ln(Func): - pass - - -class Log(Func): - arg_types = {"this": True, "expression": False} - - -class LogicalOr(AggFunc): - _sql_names = ["LOGICAL_OR", "BOOL_OR", "BOOLOR_AGG"] - - -class LogicalAnd(AggFunc): - _sql_names = ["LOGICAL_AND", "BOOL_AND", "BOOLAND_AGG"] - - -class Lower(Func): - _sql_names = ["LOWER", "LCASE"] - - -class Map(Func): - arg_types = {"keys": False, "values": False} - - @property - def keys(self) -> t.List[Expression]: - keys = self.args.get("keys") - return keys.expressions if keys else [] - - @property - def values(self) -> t.List[Expression]: - values = self.args.get("values") - return values.expressions if values else [] - - -# Represents the MAP {...} syntax in DuckDB - basically convert a struct to a MAP -class ToMap(Func): - pass - - -class MapFromEntries(Func): - pass - - -class MapCat(Func): - arg_types = {"this": True, "expression": True} - - -class MapContainsKey(Func): - arg_types = {"this": True, "key": True} - - -class MapDelete(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - - -class MapInsert(Func): - arg_types = {"this": True, "key": False, "value": True, "update_flag": False} - - -class MapKeys(Func): - pass - - -class MapPick(Func): - arg_types = {"this": True, "expressions": True} - is_var_len_args = True - - -class MapSize(Func): - pass - - -# https://learn.microsoft.com/en-us/sql/t-sql/language-elements/scope-resolution-operator-transact-sql?view=sql-server-ver16 -class ScopeResolution(Expression): - arg_types = {"this": False, "expression": True} - - -class Slice(Expression): - arg_types = {"this": False, "expression": False, "step": False} - - -class Stream(Expression): - pass - - -class StarMap(Func): - pass - - -class VarMap(Func): - arg_types = {"keys": True, "values": True} - is_var_len_args = True - - @property - def keys(self) -> t.List[Expression]: - return self.args["keys"].expressions - - @property - def values(self) -> t.List[Expression]: - return self.args["values"].expressions - - -# https://dev.mysql.com/doc/refman/8.0/en/fulltext-search.html -class MatchAgainst(Func): - arg_types = {"this": True, "expressions": True, "modifier": False} - - -class Max(AggFunc): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -class MD5(Func): - _sql_names = ["MD5"] - - -# Represents the variant of the MD5 function that returns a binary value -class MD5Digest(Func): - _sql_names = ["MD5_DIGEST"] - - -# https://docs.snowflake.com/en/sql-reference/functions/md5_number_lower64 -class MD5NumberLower64(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/md5_number_upper64 -class MD5NumberUpper64(Func): - pass - - -class Median(AggFunc): - pass - - -class Mode(AggFunc): - arg_types = {"this": False, "deterministic": False} - - -class Min(AggFunc): - arg_types = {"this": True, "expressions": False} - is_var_len_args = True - - -class Month(Func): - pass - - -class Monthname(Func): - arg_types = {"this": True, "abbreviated": False} - - -class AddMonths(Func): - arg_types = {"this": True, "expression": True, "preserve_end_of_month": False} - - -class Nvl2(Func): - arg_types = {"this": True, "true": True, "false": False} - - -class Ntile(AggFunc): - arg_types = {"this": False} - - -class Normalize(Func): - arg_types = {"this": True, "form": False, "is_casefold": False} - - -class Normal(Func): - arg_types = {"this": True, "stddev": True, "gen": True} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/net_functions#nethost -class NetHost(Func): - _sql_names = ["NET.HOST"] - - -class Overlay(Func): - arg_types = {"this": True, "expression": True, "from_": True, "for_": False} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-predict#mlpredict_function -class Predict(Func): - arg_types = {"this": True, "expression": True, "params_struct": False} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-translate#mltranslate_function -class MLTranslate(Func): - arg_types = {"this": True, "expression": True, "params_struct": True} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-feature-time -class FeaturesAtTime(Func): - arg_types = { - "this": True, - "time": False, - "num_rows": False, - "ignore_feature_nulls": False, - } - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/bigqueryml-syntax-generate-embedding -class GenerateEmbedding(Func): - arg_types = { - "this": True, - "expression": True, - "params_struct": False, - "is_text": False, - } - - -class MLForecast(Func): - arg_types = {"this": True, "expression": False, "params_struct": False} - - -# Represents Snowflake's ! syntax. For example: SELECT model!PREDICT(INPUT_DATA => {*}) -# See: https://docs.snowflake.com/en/guides-overview-ml-functions -class ModelAttribute(Expression): - arg_types = {"this": True, "expression": True} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/search_functions#vector_search -class VectorSearch(Func): - arg_types = { - "this": True, - "column_to_search": True, - "query_table": True, - "query_column_to_search": False, - "top_k": False, - "distance_type": False, - "options": False, - } - - -class Pi(Func): - arg_types = {} - - -class Pow(Binary, Func): - _sql_names = ["POWER", "POW"] - - -class PercentileCont(AggFunc): - arg_types = {"this": True, "expression": False} - - -class PercentileDisc(AggFunc): - arg_types = {"this": True, "expression": False} - - -class PercentRank(AggFunc): - arg_types = {"expressions": False} - is_var_len_args = True - - -class Quantile(AggFunc): - arg_types = {"this": True, "quantile": True} - - -class ApproxQuantile(Quantile): - arg_types = { - "this": True, - "quantile": True, - "accuracy": False, - "weight": False, - "error_tolerance": False, - } - - -# https://docs.snowflake.com/en/sql-reference/functions/approx_percentile_accumulate -class ApproxPercentileAccumulate(AggFunc): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/approx_percentile_estimate -class ApproxPercentileEstimate(Func): - arg_types = {"this": True, "percentile": True} - - -class Quarter(Func): - pass - - -# https://docs.teradata.com/r/Enterprise_IntelliFlex_VMware/SQL-Functions-Expressions-and-Predicates/Arithmetic-Trigonometric-Hyperbolic-Operators/Functions/RANDOM/RANDOM-Function-Syntax -# teradata lower and upper bounds -class Rand(Func): - _sql_names = ["RAND", "RANDOM"] - arg_types = {"this": False, "lower": False, "upper": False} - - -class Randn(Func): - arg_types = {"this": False} - - -class Randstr(Func): - arg_types = {"this": True, "generator": False} - - -class RangeN(Func): - arg_types = {"this": True, "expressions": True, "each": False} - - -class RangeBucket(Func): - arg_types = {"this": True, "expression": True} - - -class Rank(AggFunc): - arg_types = {"expressions": False} - is_var_len_args = True - - -class ReadCSV(Func): - _sql_names = ["READ_CSV"] - is_var_len_args = True - arg_types = {"this": True, "expressions": False} - - -class ReadParquet(Func): - is_var_len_args = True - arg_types = {"expressions": True} - - -class Reduce(Func): - arg_types = {"this": True, "initial": True, "merge": True, "finish": False} - - -class RegexpExtract(Func): - arg_types = { - "this": True, - "expression": True, - "position": False, - "occurrence": False, - "parameters": False, - "group": False, - "null_if_pos_overflow": False, # for transpilation target behavior - } - - -class RegexpExtractAll(Func): - arg_types = { - "this": True, - "expression": True, - "group": False, - "parameters": False, - "position": False, - "occurrence": False, - } - - -class RegexpReplace(Func): - arg_types = { - "this": True, - "expression": True, - "replacement": False, - "position": False, - "occurrence": False, - "modifiers": False, - "single_replace": False, - } - - -class RegexpLike(Binary, Func): - arg_types = {"this": True, "expression": True, "flag": False} - - -class RegexpILike(Binary, Func): - arg_types = {"this": True, "expression": True, "flag": False} - - -class RegexpFullMatch(Binary, Func): - arg_types = {"this": True, "expression": True, "options": False} - - -class RegexpInstr(Func): - arg_types = { - "this": True, - "expression": True, - "position": False, - "occurrence": False, - "option": False, - "parameters": False, - "group": False, - } - - -# https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.functions.split.html -# limit is the number of times a pattern is applied -class RegexpSplit(Func): - arg_types = {"this": True, "expression": True, "limit": False} - - -class RegexpCount(Func): - arg_types = { - "this": True, - "expression": True, - "position": False, - "parameters": False, - } - - -class RegrValx(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrValy(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrAvgy(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrAvgx(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrCount(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrIntercept(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrR2(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrSxx(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrSxy(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrSyy(AggFunc): - arg_types = {"this": True, "expression": True} - - -class RegrSlope(AggFunc): - arg_types = {"this": True, "expression": True} - - -class Repeat(Func): - arg_types = {"this": True, "times": True} - - -# Some dialects like Snowflake support two argument replace -class Replace(Func): - arg_types = {"this": True, "expression": True, "replacement": False} - - -class Radians(Func): - pass - - -# https://learn.microsoft.com/en-us/sql/t-sql/functions/round-transact-sql?view=sql-server-ver16 -# tsql third argument function == trunctaion if not 0 -class Round(Func): - arg_types = { - "this": True, - "decimals": False, - "truncate": False, - "casts_non_integer_decimals": False, - } - - -class RowNumber(Func): - arg_types = {"this": False} - - -class SafeAdd(Func): - arg_types = {"this": True, "expression": True} - - -class SafeDivide(Func): - arg_types = {"this": True, "expression": True} - - -class SafeMultiply(Func): - arg_types = {"this": True, "expression": True} - - -class SafeNegate(Func): - pass - - -class SafeSubtract(Func): - arg_types = {"this": True, "expression": True} - - -class SafeConvertBytesToString(Func): - pass - - -class SHA(Func): - _sql_names = ["SHA", "SHA1"] - - -class SHA2(Func): - _sql_names = ["SHA2"] - arg_types = {"this": True, "length": False} - - -# Represents the variant of the SHA1 function that returns a binary value -class SHA1Digest(Func): - pass - - -# Represents the variant of the SHA2 function that returns a binary value -class SHA2Digest(Func): - arg_types = {"this": True, "length": False} - - -class Sign(Func): - _sql_names = ["SIGN", "SIGNUM"] - - -class SortArray(Func): - arg_types = {"this": True, "asc": False, "nulls_first": False} - - -class Soundex(Func): - pass - - -# https://docs.snowflake.com/en/sql-reference/functions/soundex_p123 -class SoundexP123(Func): - pass - - -class Split(Func): - arg_types = {"this": True, "expression": True, "limit": False} - - -# https://spark.apache.org/docs/latest/api/python/reference/pyspark.sql/api/pyspark.sql.functions.split_part.html -# https://docs.snowflake.com/en/sql-reference/functions/split_part -# https://docs.snowflake.com/en/sql-reference/functions/strtok -class SplitPart(Func): - arg_types = {"this": True, "delimiter": False, "part_index": False} - - -# Start may be omitted in the case of postgres -# https://www.postgresql.org/docs/9.1/functions-string.html @ Table 9-6 -class Substring(Func): - _sql_names = ["SUBSTRING", "SUBSTR"] - arg_types = {"this": True, "start": False, "length": False} - - -class SubstringIndex(Func): - """ - SUBSTRING_INDEX(str, delim, count) - - *count* > 0 → left slice before the *count*-th delimiter - *count* < 0 → right slice after the |count|-th delimiter - """ - - arg_types = {"this": True, "delimiter": True, "count": True} - - -class StandardHash(Func): - arg_types = {"this": True, "expression": False} - - -class StartsWith(Func): - _sql_names = ["STARTS_WITH", "STARTSWITH"] - arg_types = {"this": True, "expression": True} - - -class EndsWith(Func): - _sql_names = ["ENDS_WITH", "ENDSWITH"] - arg_types = {"this": True, "expression": True} - - -class StrPosition(Func): - arg_types = { - "this": True, - "substr": True, - "position": False, - "occurrence": False, - } - - -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/search -# BigQuery: https://cloud.google.com/bigquery/docs/reference/standard-sql/search_functions#search -class Search(Func): - arg_types = { - "this": True, # data_to_search / search_data - "expression": True, # search_query / search_string - "json_scope": False, # BigQuery: JSON_VALUES | JSON_KEYS | JSON_KEYS_AND_VALUES - "analyzer": False, # Both: analyzer / ANALYZER - "analyzer_options": False, # BigQuery: analyzer_options_values - "search_mode": False, # Snowflake: OR | AND - } - - -# Snowflake: https://docs.snowflake.com/en/sql-reference/functions/search_ip -class SearchIp(Func): - arg_types = {"this": True, "expression": True} - - -class StrToDate(Func): - arg_types = {"this": True, "format": False, "safe": False} - - -class StrToTime(Func): - arg_types = { - "this": True, - "format": True, - "zone": False, - "safe": False, - "target_type": False, - } - - -# Spark allows unix_timestamp() -# https://spark.apache.org/docs/3.1.3/api/python/reference/api/pyspark.sql.functions.unix_timestamp.html -class StrToUnix(Func): - arg_types = {"this": False, "format": False} - - -# https://prestodb.io/docs/current/functions/string.html -# https://spark.apache.org/docs/latest/api/sql/index.html#str_to_map -class StrToMap(Func): - arg_types = { - "this": True, - "pair_delim": False, - "key_value_delim": False, - "duplicate_resolution_callback": False, - } - - -class NumberToStr(Func): - arg_types = {"this": True, "format": True, "culture": False} - - -class FromBase(Func): - arg_types = {"this": True, "expression": True} - - -class Space(Func): - """ - SPACE(n) → string consisting of n blank characters - """ - - pass - - -class Struct(Func): - arg_types = {"expressions": False} - is_var_len_args = True - - -class StructExtract(Func): - arg_types = {"this": True, "expression": True} - - -# https://learn.microsoft.com/en-us/sql/t-sql/functions/stuff-transact-sql?view=sql-server-ver16 -# https://docs.snowflake.com/en/sql-reference/functions/insert -class Stuff(Func): - _sql_names = ["STUFF", "INSERT"] - arg_types = {"this": True, "start": True, "length": True, "expression": True} - - -class Sum(AggFunc): - pass - - -class Sqrt(Func): - pass - - -class Stddev(AggFunc): - _sql_names = ["STDDEV", "STDEV"] - - -class StddevPop(AggFunc): - pass - - -class StddevSamp(AggFunc): - pass - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/time_functions#time -class Time(Func): - arg_types = {"this": False, "zone": False} - - -class TimeToStr(Func): - arg_types = {"this": True, "format": True, "culture": False, "zone": False} - - -class TimeToTimeStr(Func): - pass - - -class TimeToUnix(Func): - pass - - -class TimeStrToDate(Func): - pass - - -class TimeStrToTime(Func): - arg_types = {"this": True, "zone": False} - - -class TimeStrToUnix(Func): - pass - - -class Trim(Func): - arg_types = { - "this": True, - "expression": False, - "position": False, - "collation": False, - } - - -class TsOrDsAdd(Func, TimeUnit): - # return_type is used to correctly cast the arguments of this expression when transpiling it - arg_types = {"this": True, "expression": True, "unit": False, "return_type": False} - - @property - def return_type(self) -> DataType: - return DataType.build(self.args.get("return_type") or DataType.Type.DATE) - - -class TsOrDsDiff(Func, TimeUnit): - arg_types = {"this": True, "expression": True, "unit": False} - - -class TsOrDsToDateStr(Func): - pass - - -class TsOrDsToDate(Func): - arg_types = {"this": True, "format": False, "safe": False} - - -class TsOrDsToDatetime(Func): - pass - - -class TsOrDsToTime(Func): - arg_types = {"this": True, "format": False, "safe": False} - - -class TsOrDsToTimestamp(Func): - pass - - -class TsOrDiToDi(Func): - pass - - -class Unhex(Func): - arg_types = {"this": True, "expression": False} - - -class Unicode(Func): - pass - - -class Uniform(Func): - arg_types = {"this": True, "expression": True, "gen": False, "seed": False} - - -# https://cloud.google.com/bigquery/docs/reference/standard-sql/date_functions#unix_date -class UnixDate(Func): - pass - - -class UnixToStr(Func): - arg_types = {"this": True, "format": False} - - -# https://prestodb.io/docs/current/functions/datetime.html -# presto has weird zone/hours/minutes -class UnixToTime(Func): - arg_types = { - "this": True, - "scale": False, - "zone": False, - "hours": False, - "minutes": False, - "format": False, - } - - SECONDS = Literal.number(0) - DECIS = Literal.number(1) - CENTIS = Literal.number(2) - MILLIS = Literal.number(3) - DECIMILLIS = Literal.number(4) - CENTIMILLIS = Literal.number(5) - MICROS = Literal.number(6) - DECIMICROS = Literal.number(7) - CENTIMICROS = Literal.number(8) - NANOS = Literal.number(9) - - -class UnixToTimeStr(Func): - pass - - -class UnixSeconds(Func): - pass - - -class UnixMicros(Func): - pass - - -class UnixMillis(Func): - pass - - -class Uuid(Func): - _sql_names = ["UUID", "GEN_RANDOM_UUID", "GENERATE_UUID", "UUID_STRING"] - - arg_types = {"this": False, "name": False, "is_string": False} - - -TIMESTAMP_PARTS = { - "year": False, - "month": False, - "day": False, - "hour": False, - "min": False, - "sec": False, - "nano": False, -} - - -class TimestampFromParts(Func): - _sql_names = ["TIMESTAMP_FROM_PARTS", "TIMESTAMPFROMPARTS"] - arg_types = { - **TIMESTAMP_PARTS, - "zone": False, - "milli": False, - "this": False, - "expression": False, - } - - -class TimestampLtzFromParts(Func): - _sql_names = ["TIMESTAMP_LTZ_FROM_PARTS", "TIMESTAMPLTZFROMPARTS"] - arg_types = TIMESTAMP_PARTS.copy() - - -class TimestampTzFromParts(Func): - _sql_names = ["TIMESTAMP_TZ_FROM_PARTS", "TIMESTAMPTZFROMPARTS"] - arg_types = { - **TIMESTAMP_PARTS, - "zone": False, - } - - -class Upper(Func): - _sql_names = ["UPPER", "UCASE"] - - -class Corr(Binary, AggFunc): - pass - - -# https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/CUME_DIST.html -class CumeDist(AggFunc): - arg_types = {"expressions": False} - is_var_len_args = True - - -class Variance(AggFunc): - _sql_names = ["VARIANCE", "VARIANCE_SAMP", "VAR_SAMP"] - - -class VariancePop(AggFunc): - _sql_names = ["VARIANCE_POP", "VAR_POP"] - - -class Skewness(AggFunc): - pass - - -class WidthBucket(Func): - arg_types = { - "this": True, - "min_value": True, - "max_value": True, - "num_buckets": True, - } - - -class CovarSamp(Binary, AggFunc): - pass - - -class CovarPop(Binary, AggFunc): - pass - - -class Week(Func): - arg_types = {"this": True, "mode": False} - - -class WeekStart(Expression): - pass - - -class NextDay(Func): - arg_types = {"this": True, "expression": True} - - -class XMLElement(Func): - _sql_names = ["XMLELEMENT"] - arg_types = {"this": True, "expressions": False} - - -class XMLGet(Func): - _sql_names = ["XMLGET"] - arg_types = {"this": True, "expression": True, "instance": False} - - -class XMLTable(Func): - arg_types = { - "this": True, - "namespaces": False, - "passing": False, - "columns": False, - "by_ref": False, - } - - -class XMLNamespace(Expression): - pass - - -# https://learn.microsoft.com/en-us/sql/t-sql/queries/select-for-clause-transact-sql?view=sql-server-ver17#syntax -class XMLKeyValueOption(Expression): - arg_types = {"this": True, "expression": False} - - -class Year(Func): - pass - - -class Zipf(Func): - arg_types = {"this": True, "elementcount": True, "gen": True} - - -class Use(Expression): - arg_types = {"this": False, "expressions": False, "kind": False} - - -class Merge(DML): - arg_types = { - "this": True, - "using": True, - "on": False, - "using_cond": False, - "whens": True, - "with_": False, - "returning": False, - } - - -class When(Expression): - arg_types = {"matched": True, "source": False, "condition": False, "then": True} - - -class Whens(Expression): - """Wraps around one or more WHEN [NOT] MATCHED [...] clauses.""" - - arg_types = {"expressions": True} - - -# https://docs.oracle.com/javadb/10.8.3.0/ref/rrefsqljnextvaluefor.html -# https://learn.microsoft.com/en-us/sql/t-sql/functions/next-value-for-transact-sql?view=sql-server-ver16 -class NextValueFor(Func): - arg_types = {"this": True, "order": False} - - -# Refers to a trailing semi-colon. This is only used to preserve trailing comments -# select 1; -- my comment -class Semicolon(Expression): - arg_types = {} - - -# BigQuery allows SELECT t FROM t and treats the projection as a struct value. This expression -# type is intended to be constructed by qualify so that we can properly annotate its type later -class TableColumn(Expression): - pass - - -ALL_FUNCTIONS = subclasses(__name__, Func, {AggFunc, Anonymous, Func}) -FUNCTION_BY_NAME = {name: func for func in ALL_FUNCTIONS for name in func.sql_names()} - -JSON_PATH_PARTS = subclasses(__name__, JSONPathPart, {JSONPathPart}) - -PERCENTILES = (PercentileCont, PercentileDisc) - - -# Helpers -@t.overload -def maybe_parse( - sql_or_expression: ExpOrStr, - *, - into: t.Type[E], - dialect: DialectType = None, - prefix: t.Optional[str] = None, - copy: bool = False, - **opts, -) -> E: ... - - -@t.overload -def maybe_parse( - sql_or_expression: str | E, - *, - into: t.Optional[IntoType] = None, - dialect: DialectType = None, - prefix: t.Optional[str] = None, - copy: bool = False, - **opts, -) -> E: ... - - -def maybe_parse( - sql_or_expression: ExpOrStr, - *, - into: t.Optional[IntoType] = None, - dialect: DialectType = None, - prefix: t.Optional[str] = None, - copy: bool = False, - **opts, -) -> Expression: - """Gracefully handle a possible string or expression. - - Example: - >>> maybe_parse("1") - Literal(this=1, is_string=False) - >>> maybe_parse(to_identifier("x")) - Identifier(this=x, quoted=False) - - Args: - sql_or_expression: the SQL code string or an expression - into: the SQLGlot Expression to parse into - dialect: the dialect used to parse the input expressions (in the case that an - input expression is a SQL string). - prefix: a string to prefix the sql with before it gets parsed - (automatically includes a space) - copy: whether to copy the expression. - **opts: other options to use to parse the input expressions (again, in the case - that an input expression is a SQL string). - - Returns: - Expression: the parsed or given expression. - """ - if isinstance(sql_or_expression, Expression): - if copy: - return sql_or_expression.copy() - return sql_or_expression - - if sql_or_expression is None: - raise ParseError("SQL cannot be None") - - import bigframes_vendored.sqlglot - - sql = str(sql_or_expression) - if prefix: - sql = f"{prefix} {sql}" - - return bigframes_vendored.sqlglot.parse_one(sql, read=dialect, into=into, **opts) - - -@t.overload -def maybe_copy(instance: None, copy: bool = True) -> None: ... - - -@t.overload -def maybe_copy(instance: E, copy: bool = True) -> E: ... - - -def maybe_copy(instance, copy=True): - return instance.copy() if copy and instance else instance - - -def _to_s( - node: t.Any, verbose: bool = False, level: int = 0, repr_str: bool = False -) -> str: - """Generate a textual representation of an Expression tree""" - indent = "\n" + (" " * (level + 1)) - delim = f",{indent}" - - if isinstance(node, Expression): - args = { - k: v for k, v in node.args.items() if (v is not None and v != []) or verbose - } - - if (node.type or verbose) and not isinstance(node, DataType): - args["_type"] = node.type - if node.comments or verbose: - args["_comments"] = node.comments - - if verbose: - args["_id"] = id(node) - - # Inline leaves for a more compact representation - if node.is_leaf(): - indent = "" - delim = ", " - - repr_str = node.is_string or (isinstance(node, Identifier) and node.quoted) - items = delim.join( - [ - f"{k}={_to_s(v, verbose, level + 1, repr_str=repr_str)}" - for k, v in args.items() - ] - ) - return f"{node.__class__.__name__}({indent}{items})" - - if isinstance(node, list): - items = delim.join(_to_s(i, verbose, level + 1) for i in node) - items = f"{indent}{items}" if items else "" - return f"[{items}]" - - # We use the representation of the string to avoid stripping out important whitespace - if repr_str and isinstance(node, str): - node = repr(node) - - # Indent multiline strings to match the current level - return indent.join(textwrap.dedent(str(node).strip("\n")).splitlines()) - - -def _is_wrong_expression(expression, into): - return isinstance(expression, Expression) and not isinstance(expression, into) - - -def _apply_builder( - expression, - instance, - arg, - copy=True, - prefix=None, - into=None, - dialect=None, - into_arg="this", - **opts, -): - if _is_wrong_expression(expression, into): - expression = into(**{into_arg: expression}) - instance = maybe_copy(instance, copy) - expression = maybe_parse( - sql_or_expression=expression, - prefix=prefix, - into=into, - dialect=dialect, - **opts, - ) - instance.set(arg, expression) - return instance - - -def _apply_child_list_builder( - *expressions, - instance, - arg, - append=True, - copy=True, - prefix=None, - into=None, - dialect=None, - properties=None, - **opts, -): - instance = maybe_copy(instance, copy) - parsed = [] - properties = {} if properties is None else properties - - for expression in expressions: - if expression is not None: - if _is_wrong_expression(expression, into): - expression = into(expressions=[expression]) - - expression = maybe_parse( - expression, - into=into, - dialect=dialect, - prefix=prefix, - **opts, - ) - for k, v in expression.args.items(): - if k == "expressions": - parsed.extend(v) - else: - properties[k] = v - - existing = instance.args.get(arg) - if append and existing: - parsed = existing.expressions + parsed - - child = into(expressions=parsed) - for k, v in properties.items(): - child.set(k, v) - instance.set(arg, child) - - return instance - - -def _apply_list_builder( - *expressions, - instance, - arg, - append=True, - copy=True, - prefix=None, - into=None, - dialect=None, - **opts, -): - inst = maybe_copy(instance, copy) - - expressions = [ - maybe_parse( - sql_or_expression=expression, - into=into, - prefix=prefix, - dialect=dialect, - **opts, - ) - for expression in expressions - if expression is not None - ] - - existing_expressions = inst.args.get(arg) - if append and existing_expressions: - expressions = existing_expressions + expressions - - inst.set(arg, expressions) - return inst - - -def _apply_conjunction_builder( - *expressions, - instance, - arg, - into=None, - append=True, - copy=True, - dialect=None, - **opts, -): - expressions = [exp for exp in expressions if exp is not None and exp != ""] - if not expressions: - return instance - - inst = maybe_copy(instance, copy) - - existing = inst.args.get(arg) - if append and existing is not None: - expressions = [existing.this if into else existing] + list(expressions) - - node = and_(*expressions, dialect=dialect, copy=copy, **opts) - - inst.set(arg, into(this=node) if into else node) - return inst - - -def _apply_cte_builder( - instance: E, - alias: ExpOrStr, - as_: ExpOrStr, - recursive: t.Optional[bool] = None, - materialized: t.Optional[bool] = None, - append: bool = True, - dialect: DialectType = None, - copy: bool = True, - scalar: t.Optional[bool] = None, - **opts, -) -> E: - alias_expression = maybe_parse(alias, dialect=dialect, into=TableAlias, **opts) - as_expression = maybe_parse(as_, dialect=dialect, copy=copy, **opts) - if scalar and not isinstance(as_expression, Subquery): - # scalar CTE must be wrapped in a subquery - as_expression = Subquery(this=as_expression) - cte = CTE( - this=as_expression, - alias=alias_expression, - materialized=materialized, - scalar=scalar, - ) - return _apply_child_list_builder( - cte, - instance=instance, - arg="with_", - append=append, - copy=copy, - into=With, - properties={"recursive": recursive} if recursive else {}, - ) - - -def _combine( - expressions: t.Sequence[t.Optional[ExpOrStr]], - operator: t.Type[Connector], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, -) -> Expression: - conditions = [ - condition(expression, dialect=dialect, copy=copy, **opts) - for expression in expressions - if expression is not None - ] - - this, *rest = conditions - if rest and wrap: - this = _wrap(this, Connector) - for expression in rest: - this = operator( - this=this, expression=_wrap(expression, Connector) if wrap else expression - ) - - return this - - -@t.overload -def _wrap(expression: None, kind: t.Type[Expression]) -> None: ... - - -@t.overload -def _wrap(expression: E, kind: t.Type[Expression]) -> E | Paren: ... - - -def _wrap(expression: t.Optional[E], kind: t.Type[Expression]) -> t.Optional[E] | Paren: - return Paren(this=expression) if isinstance(expression, kind) else expression - - -def _apply_set_operation( - *expressions: ExpOrStr, - set_operation: t.Type[S], - distinct: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> S: - return reduce( - lambda x, y: set_operation(this=x, expression=y, distinct=distinct, **opts), - (maybe_parse(e, dialect=dialect, copy=copy, **opts) for e in expressions), - ) - - -def union( - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> Union: - """ - Initializes a syntax tree for the `UNION` operation. - - Example: - >>> union("SELECT * FROM foo", "SELECT * FROM bla").sql() - 'SELECT * FROM foo UNION SELECT * FROM bla' - - Args: - expressions: the SQL code strings, corresponding to the `UNION`'s operands. - If `Expression` instances are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - copy: whether to copy the expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Union instance. - """ - assert len(expressions) >= 2, "At least two expressions are required by `union`." - return _apply_set_operation( - *expressions, - set_operation=Union, - distinct=distinct, - dialect=dialect, - copy=copy, - **opts, - ) - - -def intersect( - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> Intersect: - """ - Initializes a syntax tree for the `INTERSECT` operation. - - Example: - >>> intersect("SELECT * FROM foo", "SELECT * FROM bla").sql() - 'SELECT * FROM foo INTERSECT SELECT * FROM bla' - - Args: - expressions: the SQL code strings, corresponding to the `INTERSECT`'s operands. - If `Expression` instances are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - copy: whether to copy the expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Intersect instance. - """ - assert len(expressions) >= 2, ( - "At least two expressions are required by `intersect`." - ) - return _apply_set_operation( - *expressions, - set_operation=Intersect, - distinct=distinct, - dialect=dialect, - copy=copy, - **opts, - ) - - -def except_( - *expressions: ExpOrStr, - distinct: bool = True, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> Except: - """ - Initializes a syntax tree for the `EXCEPT` operation. - - Example: - >>> except_("SELECT * FROM foo", "SELECT * FROM bla").sql() - 'SELECT * FROM foo EXCEPT SELECT * FROM bla' - - Args: - expressions: the SQL code strings, corresponding to the `EXCEPT`'s operands. - If `Expression` instances are passed, they will be used as-is. - distinct: set the DISTINCT flag if and only if this is true. - dialect: the dialect used to parse the input expression. - copy: whether to copy the expression. - opts: other options to use to parse the input expressions. - - Returns: - The new Except instance. - """ - assert len(expressions) >= 2, "At least two expressions are required by `except_`." - return _apply_set_operation( - *expressions, - set_operation=Except, - distinct=distinct, - dialect=dialect, - copy=copy, - **opts, - ) - - -def select(*expressions: ExpOrStr, dialect: DialectType = None, **opts) -> Select: - """ - Initializes a syntax tree from one or multiple SELECT expressions. - - Example: - >>> select("col1", "col2").from_("tbl").sql() - 'SELECT col1, col2 FROM tbl' - - Args: - *expressions: the SQL code string to parse as the expressions of a - SELECT statement. If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expressions (in the case that an - input expression is a SQL string). - **opts: other options to use to parse the input expressions (again, in the case - that an input expression is a SQL string). - - Returns: - Select: the syntax tree for the SELECT statement. - """ - return Select().select(*expressions, dialect=dialect, **opts) - - -def from_(expression: ExpOrStr, dialect: DialectType = None, **opts) -> Select: - """ - Initializes a syntax tree from a FROM expression. - - Example: - >>> from_("tbl").select("col1", "col2").sql() - 'SELECT col1, col2 FROM tbl' - - Args: - *expression: the SQL code string to parse as the FROM expressions of a - SELECT statement. If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression (in the case that the - input expression is a SQL string). - **opts: other options to use to parse the input expressions (again, in the case - that the input expression is a SQL string). - - Returns: - Select: the syntax tree for the SELECT statement. - """ - return Select().from_(expression, dialect=dialect, **opts) - - -def update( - table: str | Table, - properties: t.Optional[dict] = None, - where: t.Optional[ExpOrStr] = None, - from_: t.Optional[ExpOrStr] = None, - with_: t.Optional[t.Dict[str, ExpOrStr]] = None, - dialect: DialectType = None, - **opts, -) -> Update: - """ - Creates an update statement. - - Example: - >>> update("my_table", {"x": 1, "y": "2", "z": None}, from_="baz_cte", where="baz_cte.id > 1 and my_table.id = baz_cte.id", with_={"baz_cte": "SELECT id FROM foo"}).sql() - "WITH baz_cte AS (SELECT id FROM foo) UPDATE my_table SET x = 1, y = '2', z = NULL FROM baz_cte WHERE baz_cte.id > 1 AND my_table.id = baz_cte.id" - - Args: - properties: dictionary of properties to SET which are - auto converted to sql objects eg None -> NULL - where: sql conditional parsed into a WHERE statement - from_: sql statement parsed into a FROM statement - with_: dictionary of CTE aliases / select statements to include in a WITH clause. - dialect: the dialect used to parse the input expressions. - **opts: other options to use to parse the input expressions. - - Returns: - Update: the syntax tree for the UPDATE statement. - """ - update_expr = Update(this=maybe_parse(table, into=Table, dialect=dialect)) - if properties: - update_expr.set( - "expressions", - [ - EQ(this=maybe_parse(k, dialect=dialect, **opts), expression=convert(v)) - for k, v in properties.items() - ], - ) - if from_: - update_expr.set( - "from_", - maybe_parse(from_, into=From, dialect=dialect, prefix="FROM", **opts), - ) - if isinstance(where, Condition): - where = Where(this=where) - if where: - update_expr.set( - "where", - maybe_parse(where, into=Where, dialect=dialect, prefix="WHERE", **opts), - ) - if with_: - cte_list = [ - alias_( - CTE(this=maybe_parse(qry, dialect=dialect, **opts)), alias, table=True - ) - for alias, qry in with_.items() - ] - update_expr.set( - "with_", - With(expressions=cte_list), - ) - return update_expr - - -def delete( - table: ExpOrStr, - where: t.Optional[ExpOrStr] = None, - returning: t.Optional[ExpOrStr] = None, - dialect: DialectType = None, - **opts, -) -> Delete: - """ - Builds a delete statement. - - Example: - >>> delete("my_table", where="id > 1").sql() - 'DELETE FROM my_table WHERE id > 1' - - Args: - where: sql conditional parsed into a WHERE statement - returning: sql conditional parsed into a RETURNING statement - dialect: the dialect used to parse the input expressions. - **opts: other options to use to parse the input expressions. - - Returns: - Delete: the syntax tree for the DELETE statement. - """ - delete_expr = Delete().delete(table, dialect=dialect, copy=False, **opts) - if where: - delete_expr = delete_expr.where(where, dialect=dialect, copy=False, **opts) - if returning: - delete_expr = delete_expr.returning( - returning, dialect=dialect, copy=False, **opts - ) - return delete_expr - - -def insert( - expression: ExpOrStr, - into: ExpOrStr, - columns: t.Optional[t.Sequence[str | Identifier]] = None, - overwrite: t.Optional[bool] = None, - returning: t.Optional[ExpOrStr] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> Insert: - """ - Builds an INSERT statement. - - Example: - >>> insert("VALUES (1, 2, 3)", "tbl").sql() - 'INSERT INTO tbl VALUES (1, 2, 3)' - - Args: - expression: the sql string or expression of the INSERT statement - into: the tbl to insert data to. - columns: optionally the table's column names. - overwrite: whether to INSERT OVERWRITE or not. - returning: sql conditional parsed into a RETURNING statement - dialect: the dialect used to parse the input expressions. - copy: whether to copy the expression. - **opts: other options to use to parse the input expressions. - - Returns: - Insert: the syntax tree for the INSERT statement. - """ - expr = maybe_parse(expression, dialect=dialect, copy=copy, **opts) - this: Table | Schema = maybe_parse( - into, into=Table, dialect=dialect, copy=copy, **opts - ) - - if columns: - this = Schema( - this=this, expressions=[to_identifier(c, copy=copy) for c in columns] - ) - - insert = Insert(this=this, expression=expr, overwrite=overwrite) - - if returning: - insert = insert.returning(returning, dialect=dialect, copy=False, **opts) - - return insert - - -def merge( - *when_exprs: ExpOrStr, - into: ExpOrStr, - using: ExpOrStr, - on: ExpOrStr, - returning: t.Optional[ExpOrStr] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, -) -> Merge: - """ - Builds a MERGE statement. - - Example: - >>> merge("WHEN MATCHED THEN UPDATE SET col1 = source_table.col1", - ... "WHEN NOT MATCHED THEN INSERT (col1) VALUES (source_table.col1)", - ... into="my_table", - ... using="source_table", - ... on="my_table.id = source_table.id").sql() - 'MERGE INTO my_table USING source_table ON my_table.id = source_table.id WHEN MATCHED THEN UPDATE SET col1 = source_table.col1 WHEN NOT MATCHED THEN INSERT (col1) VALUES (source_table.col1)' - - Args: - *when_exprs: The WHEN clauses specifying actions for matched and unmatched rows. - into: The target table to merge data into. - using: The source table to merge data from. - on: The join condition for the merge. - returning: The columns to return from the merge. - dialect: The dialect used to parse the input expressions. - copy: Whether to copy the expression. - **opts: Other options to use to parse the input expressions. - - Returns: - Merge: The syntax tree for the MERGE statement. - """ - expressions: t.List[Expression] = [] - for when_expr in when_exprs: - expression = maybe_parse( - when_expr, dialect=dialect, copy=copy, into=Whens, **opts - ) - expressions.extend( - [expression] if isinstance(expression, When) else expression.expressions - ) - - merge = Merge( - this=maybe_parse(into, dialect=dialect, copy=copy, **opts), - using=maybe_parse(using, dialect=dialect, copy=copy, **opts), - on=maybe_parse(on, dialect=dialect, copy=copy, **opts), - whens=Whens(expressions=expressions), - ) - if returning: - merge = merge.returning(returning, dialect=dialect, copy=False, **opts) - - if isinstance(using_clause := merge.args.get("using"), Alias): - using_clause.replace( - alias_(using_clause.this, using_clause.args["alias"], table=True) - ) - - return merge - - -def condition( - expression: ExpOrStr, dialect: DialectType = None, copy: bool = True, **opts -) -> Condition: - """ - Initialize a logical condition expression. - - Example: - >>> condition("x=1").sql() - 'x = 1' - - This is helpful for composing larger logical syntax trees: - >>> where = condition("x=1") - >>> where = where.and_("y=1") - >>> Select().from_("tbl").select("*").where(where).sql() - 'SELECT * FROM tbl WHERE x = 1 AND y = 1' - - Args: - *expression: the SQL code string to parse. - If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression (in the case that the - input expression is a SQL string). - copy: Whether to copy `expression` (only applies to expressions). - **opts: other options to use to parse the input expressions (again, in the case - that the input expression is a SQL string). - - Returns: - The new Condition instance - """ - return maybe_parse( - expression, - into=Condition, - dialect=dialect, - copy=copy, - **opts, - ) - - -def and_( - *expressions: t.Optional[ExpOrStr], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, -) -> Condition: - """ - Combine multiple conditions with an AND logical operator. - - Example: - >>> and_("x=1", and_("y=1", "z=1")).sql() - 'x = 1 AND (y = 1 AND z = 1)' - - Args: - *expressions: the SQL code strings to parse. - If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy `expressions` (only applies to Expressions). - wrap: whether to wrap the operands in `Paren`s. This is true by default to avoid - precedence issues, but can be turned off when the produced AST is too deep and - causes recursion-related issues. - **opts: other options to use to parse the input expressions. - - Returns: - The new condition - """ - return t.cast( - Condition, _combine(expressions, And, dialect, copy=copy, wrap=wrap, **opts) - ) - - -def or_( - *expressions: t.Optional[ExpOrStr], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, -) -> Condition: - """ - Combine multiple conditions with an OR logical operator. - - Example: - >>> or_("x=1", or_("y=1", "z=1")).sql() - 'x = 1 OR (y = 1 OR z = 1)' - - Args: - *expressions: the SQL code strings to parse. - If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy `expressions` (only applies to Expressions). - wrap: whether to wrap the operands in `Paren`s. This is true by default to avoid - precedence issues, but can be turned off when the produced AST is too deep and - causes recursion-related issues. - **opts: other options to use to parse the input expressions. - - Returns: - The new condition - """ - return t.cast( - Condition, _combine(expressions, Or, dialect, copy=copy, wrap=wrap, **opts) - ) - - -def xor( - *expressions: t.Optional[ExpOrStr], - dialect: DialectType = None, - copy: bool = True, - wrap: bool = True, - **opts, -) -> Condition: - """ - Combine multiple conditions with an XOR logical operator. - - Example: - >>> xor("x=1", xor("y=1", "z=1")).sql() - 'x = 1 XOR (y = 1 XOR z = 1)' - - Args: - *expressions: the SQL code strings to parse. - If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy `expressions` (only applies to Expressions). - wrap: whether to wrap the operands in `Paren`s. This is true by default to avoid - precedence issues, but can be turned off when the produced AST is too deep and - causes recursion-related issues. - **opts: other options to use to parse the input expressions. - - Returns: - The new condition - """ - return t.cast( - Condition, _combine(expressions, Xor, dialect, copy=copy, wrap=wrap, **opts) - ) - - -def not_( - expression: ExpOrStr, dialect: DialectType = None, copy: bool = True, **opts -) -> Not: - """ - Wrap a condition with a NOT operator. - - Example: - >>> not_("this_suit='black'").sql() - "NOT this_suit = 'black'" - - Args: - expression: the SQL code string to parse. - If an Expression instance is passed, this is used as-is. - dialect: the dialect used to parse the input expression. - copy: whether to copy the expression or not. - **opts: other options to use to parse the input expressions. - - Returns: - The new condition. - """ - this = condition( - expression, - dialect=dialect, - copy=copy, - **opts, - ) - return Not(this=_wrap(this, Connector)) - - -def paren(expression: ExpOrStr, copy: bool = True) -> Paren: - """ - Wrap an expression in parentheses. - - Example: - >>> paren("5 + 3").sql() - '(5 + 3)' - - Args: - expression: the SQL code string to parse. - If an Expression instance is passed, this is used as-is. - copy: whether to copy the expression or not. - - Returns: - The wrapped expression. - """ - return Paren(this=maybe_parse(expression, copy=copy)) - - -SAFE_IDENTIFIER_RE: t.Pattern[str] = re.compile(r"^[_a-zA-Z][\w]*$") - - -@t.overload -def to_identifier( - name: None, quoted: t.Optional[bool] = None, copy: bool = True -) -> None: ... - - -@t.overload -def to_identifier( - name: str | Identifier, quoted: t.Optional[bool] = None, copy: bool = True -) -> Identifier: ... - - -def to_identifier(name, quoted=None, copy=True): - """Builds an identifier. - - Args: - name: The name to turn into an identifier. - quoted: Whether to force quote the identifier. - copy: Whether to copy name if it's an Identifier. - - Returns: - The identifier ast node. - """ - - if name is None: - return None - - if isinstance(name, Identifier): - identifier = maybe_copy(name, copy) - elif isinstance(name, str): - identifier = Identifier( - this=name, - quoted=not SAFE_IDENTIFIER_RE.match(name) if quoted is None else quoted, - ) - else: - raise ValueError( - f"Name needs to be a string or an Identifier, got: {name.__class__}" - ) - return identifier - - -def parse_identifier(name: str | Identifier, dialect: DialectType = None) -> Identifier: - """ - Parses a given string into an identifier. - - Args: - name: The name to parse into an identifier. - dialect: The dialect to parse against. - - Returns: - The identifier ast node. - """ - try: - expression = maybe_parse(name, dialect=dialect, into=Identifier) - except (ParseError, TokenError): - expression = to_identifier(name) - - return expression - - -INTERVAL_STRING_RE = re.compile(r"\s*(-?[0-9]+(?:\.[0-9]+)?)\s*([a-zA-Z]+)\s*") - -# Matches day-time interval strings that contain -# - A number of days (possibly negative or with decimals) -# - At least one space -# - Portions of a time-like signature, potentially negative -# - Standard format [-]h+:m+:s+[.f+] -# - Just minutes/seconds/frac seconds [-]m+:s+.f+ -# - Just hours, minutes, maybe colon [-]h+:m+[:] -# - Just hours, maybe colon [-]h+[:] -# - Just colon : -INTERVAL_DAY_TIME_RE = re.compile( - r"\s*-?\s*\d+(?:\.\d+)?\s+(?:-?(?:\d+:)?\d+:\d+(?:\.\d+)?|-?(?:\d+:){1,2}|:)\s*" -) - - -def to_interval(interval: str | Literal) -> Interval: - """Builds an interval expression from a string like '1 day' or '5 months'.""" - if isinstance(interval, Literal): - if not interval.is_string: - raise ValueError("Invalid interval string.") - - interval = interval.this - - interval = maybe_parse(f"INTERVAL {interval}") - assert isinstance(interval, Interval) - return interval - - -def to_table( - sql_path: str | Table, dialect: DialectType = None, copy: bool = True, **kwargs -) -> Table: - """ - Create a table expression from a `[catalog].[schema].[table]` sql path. Catalog and schema are optional. - If a table is passed in then that table is returned. - - Args: - sql_path: a `[catalog].[schema].[table]` string. - dialect: the source dialect according to which the table name will be parsed. - copy: Whether to copy a table if it is passed in. - kwargs: the kwargs to instantiate the resulting `Table` expression with. - - Returns: - A table expression. - """ - if isinstance(sql_path, Table): - return maybe_copy(sql_path, copy=copy) - - try: - table = maybe_parse(sql_path, into=Table, dialect=dialect) - except ParseError: - catalog, db, this = split_num_words(sql_path, ".", 3) - - if not this: - raise - - table = table_(this, db=db, catalog=catalog) - - for k, v in kwargs.items(): - table.set(k, v) - - return table - - -def to_column( - sql_path: str | Column, - quoted: t.Optional[bool] = None, - dialect: DialectType = None, - copy: bool = True, - **kwargs, -) -> Column: - """ - Create a column from a `[table].[column]` sql path. Table is optional. - If a column is passed in then that column is returned. - - Args: - sql_path: a `[table].[column]` string. - quoted: Whether or not to force quote identifiers. - dialect: the source dialect according to which the column name will be parsed. - copy: Whether to copy a column if it is passed in. - kwargs: the kwargs to instantiate the resulting `Column` expression with. - - Returns: - A column expression. - """ - if isinstance(sql_path, Column): - return maybe_copy(sql_path, copy=copy) - - try: - col = maybe_parse(sql_path, into=Column, dialect=dialect) - except ParseError: - return column(*reversed(sql_path.split(".")), quoted=quoted, **kwargs) - - for k, v in kwargs.items(): - col.set(k, v) - - if quoted: - for i in col.find_all(Identifier): - i.set("quoted", True) - - return col - - -def alias_( - expression: ExpOrStr, - alias: t.Optional[str | Identifier], - table: bool | t.Sequence[str | Identifier] = False, - quoted: t.Optional[bool] = None, - dialect: DialectType = None, - copy: bool = True, - **opts, -): - """Create an Alias expression. - - Example: - >>> alias_('foo', 'bar').sql() - 'foo AS bar' - - >>> alias_('(select 1, 2)', 'bar', table=['a', 'b']).sql() - '(SELECT 1, 2) AS bar(a, b)' - - Args: - expression: the SQL code strings to parse. - If an Expression instance is passed, this is used as-is. - alias: the alias name to use. If the name has - special characters it is quoted. - table: Whether to create a table alias, can also be a list of columns. - quoted: whether to quote the alias - dialect: the dialect used to parse the input expression. - copy: Whether to copy the expression. - **opts: other options to use to parse the input expressions. - - Returns: - Alias: the aliased expression - """ - exp = maybe_parse(expression, dialect=dialect, copy=copy, **opts) - alias = to_identifier(alias, quoted=quoted) - - if table: - table_alias = TableAlias(this=alias) - exp.set("alias", table_alias) - - if not isinstance(table, bool): - for column in table: - table_alias.append("columns", to_identifier(column, quoted=quoted)) - - return exp - - # We don't set the "alias" arg for Window expressions, because that would add an IDENTIFIER node in - # the AST, representing a "named_window" [1] construct (eg. bigquery). What we want is an ALIAS node - # for the complete Window expression. - # - # [1]: https://cloud.google.com/bigquery/docs/reference/standard-sql/window-function-calls - - if "alias" in exp.arg_types and not isinstance(exp, Window): - exp.set("alias", alias) - return exp - return Alias(this=exp, alias=alias) - - -def subquery( - expression: ExpOrStr, - alias: t.Optional[Identifier | str] = None, - dialect: DialectType = None, - **opts, -) -> Select: - """ - Build a subquery expression that's selected from. - - Example: - >>> subquery('select x from tbl', 'bar').select('x').sql() - 'SELECT x FROM (SELECT x FROM tbl) AS bar' - - Args: - expression: the SQL code strings to parse. - If an Expression instance is passed, this is used as-is. - alias: the alias name to use. - dialect: the dialect used to parse the input expression. - **opts: other options to use to parse the input expressions. - - Returns: - A new Select instance with the subquery expression included. - """ - - expression = maybe_parse(expression, dialect=dialect, **opts).subquery( - alias, **opts - ) - return Select().from_(expression, dialect=dialect, **opts) - - -@t.overload -def column( - col: str | Identifier, - table: t.Optional[str | Identifier] = None, - db: t.Optional[str | Identifier] = None, - catalog: t.Optional[str | Identifier] = None, - *, - fields: t.Collection[t.Union[str, Identifier]], - quoted: t.Optional[bool] = None, - copy: bool = True, -) -> Dot: - pass - - -@t.overload -def column( - col: str | Identifier | Star, - table: t.Optional[str | Identifier] = None, - db: t.Optional[str | Identifier] = None, - catalog: t.Optional[str | Identifier] = None, - *, - fields: Lit[None] = None, - quoted: t.Optional[bool] = None, - copy: bool = True, -) -> Column: - pass - - -def column( - col, - table=None, - db=None, - catalog=None, - *, - fields=None, - quoted=None, - copy=True, -): - """ - Build a Column. - - Args: - col: Column name. - table: Table name. - db: Database name. - catalog: Catalog name. - fields: Additional fields using dots. - quoted: Whether to force quotes on the column's identifiers. - copy: Whether to copy identifiers if passed in. - - Returns: - The new Column instance. - """ - if not isinstance(col, Star): - col = to_identifier(col, quoted=quoted, copy=copy) - - this = Column( - this=col, - table=to_identifier(table, quoted=quoted, copy=copy), - db=to_identifier(db, quoted=quoted, copy=copy), - catalog=to_identifier(catalog, quoted=quoted, copy=copy), - ) - - if fields: - this = Dot.build( - ( - this, - *(to_identifier(field, quoted=quoted, copy=copy) for field in fields), - ) - ) - return this - - -def cast( - expression: ExpOrStr, - to: DATA_TYPE, - copy: bool = True, - dialect: DialectType = None, - **opts, -) -> Cast: - """Cast an expression to a data type. - - Example: - >>> cast('x + 1', 'int').sql() - 'CAST(x + 1 AS INT)' - - Args: - expression: The expression to cast. - to: The datatype to cast to. - copy: Whether to copy the supplied expressions. - dialect: The target dialect. This is used to prevent a re-cast in the following scenario: - - The expression to be cast is already a exp.Cast expression - - The existing cast is to a type that is logically equivalent to new type - - For example, if :expression='CAST(x as DATETIME)' and :to=Type.TIMESTAMP, - but in the target dialect DATETIME is mapped to TIMESTAMP, then we will NOT return `CAST(x (as DATETIME) as TIMESTAMP)` - and instead just return the original expression `CAST(x as DATETIME)`. - - This is to prevent it being output as a double cast `CAST(x (as TIMESTAMP) as TIMESTAMP)` once the DATETIME -> TIMESTAMP - mapping is applied in the target dialect generator. - - Returns: - The new Cast instance. - """ - expr = maybe_parse(expression, copy=copy, dialect=dialect, **opts) - data_type = DataType.build(to, copy=copy, dialect=dialect, **opts) - - # dont re-cast if the expression is already a cast to the correct type - if isinstance(expr, Cast): - from bigframes_vendored.sqlglot.dialects.dialect import Dialect - - target_dialect = Dialect.get_or_raise(dialect) - type_mapping = target_dialect.generator_class.TYPE_MAPPING - - existing_cast_type: DataType.Type = expr.to.this - new_cast_type: DataType.Type = data_type.this - types_are_equivalent = type_mapping.get( - existing_cast_type, existing_cast_type.value - ) == type_mapping.get(new_cast_type, new_cast_type.value) - - if expr.is_type(data_type) or types_are_equivalent: - return expr - - expr = Cast(this=expr, to=data_type) - expr.type = data_type - - return expr - - -def table_( - table: Identifier | str, - db: t.Optional[Identifier | str] = None, - catalog: t.Optional[Identifier | str] = None, - quoted: t.Optional[bool] = None, - alias: t.Optional[Identifier | str] = None, -) -> Table: - """Build a Table. - - Args: - table: Table name. - db: Database name. - catalog: Catalog name. - quote: Whether to force quotes on the table's identifiers. - alias: Table's alias. - - Returns: - The new Table instance. - """ - return Table( - this=to_identifier(table, quoted=quoted) if table else None, - db=to_identifier(db, quoted=quoted) if db else None, - catalog=to_identifier(catalog, quoted=quoted) if catalog else None, - alias=TableAlias(this=to_identifier(alias)) if alias else None, - ) - - -def values( - values: t.Iterable[t.Tuple[t.Any, ...]], - alias: t.Optional[str] = None, - columns: t.Optional[t.Iterable[str] | t.Dict[str, DataType]] = None, -) -> Values: - """Build VALUES statement. - - Example: - >>> values([(1, '2')]).sql() - "VALUES (1, '2')" - - Args: - values: values statements that will be converted to SQL - alias: optional alias - columns: Optional list of ordered column names or ordered dictionary of column names to types. - If either are provided then an alias is also required. - - Returns: - Values: the Values expression object - """ - if columns and not alias: - raise ValueError("Alias is required when providing columns") - - return Values( - expressions=[convert(tup) for tup in values], - alias=( - TableAlias( - this=to_identifier(alias), columns=[to_identifier(x) for x in columns] - ) - if columns - else (TableAlias(this=to_identifier(alias)) if alias else None) - ), - ) - - -def var(name: t.Optional[ExpOrStr]) -> Var: - """Build a SQL variable. - - Example: - >>> repr(var('x')) - 'Var(this=x)' - - >>> repr(var(column('x', table='y'))) - 'Var(this=x)' - - Args: - name: The name of the var or an expression who's name will become the var. - - Returns: - The new variable node. - """ - if not name: - raise ValueError("Cannot convert empty name into var.") - - if isinstance(name, Expression): - name = name.name - return Var(this=name) - - -def rename_table( - old_name: str | Table, - new_name: str | Table, - dialect: DialectType = None, -) -> Alter: - """Build ALTER TABLE... RENAME... expression - - Args: - old_name: The old name of the table - new_name: The new name of the table - dialect: The dialect to parse the table. - - Returns: - Alter table expression - """ - old_table = to_table(old_name, dialect=dialect) - new_table = to_table(new_name, dialect=dialect) - return Alter( - this=old_table, - kind="TABLE", - actions=[ - AlterRename(this=new_table), - ], - ) - - -def rename_column( - table_name: str | Table, - old_column_name: str | Column, - new_column_name: str | Column, - exists: t.Optional[bool] = None, - dialect: DialectType = None, -) -> Alter: - """Build ALTER TABLE... RENAME COLUMN... expression - - Args: - table_name: Name of the table - old_column: The old name of the column - new_column: The new name of the column - exists: Whether to add the `IF EXISTS` clause - dialect: The dialect to parse the table/column. - - Returns: - Alter table expression - """ - table = to_table(table_name, dialect=dialect) - old_column = to_column(old_column_name, dialect=dialect) - new_column = to_column(new_column_name, dialect=dialect) - return Alter( - this=table, - kind="TABLE", - actions=[ - RenameColumn(this=old_column, to=new_column, exists=exists), - ], - ) - - -def convert(value: t.Any, copy: bool = False) -> Expression: - """Convert a python value into an expression object. - - Raises an error if a conversion is not possible. - - Args: - value: A python object. - copy: Whether to copy `value` (only applies to Expressions and collections). - - Returns: - The equivalent expression object. - """ - if isinstance(value, Expression): - return maybe_copy(value, copy) - if isinstance(value, str): - return Literal.string(value) - if isinstance(value, bool): - return Boolean(this=value) - if value is None or (isinstance(value, float) and math.isnan(value)): - return null() - if isinstance(value, numbers.Number): - return Literal.number(value) - if isinstance(value, bytes): - return HexString(this=value.hex()) - if isinstance(value, datetime.datetime): - datetime_literal = Literal.string(value.isoformat(sep=" ")) - - tz = None - if value.tzinfo: - # this works for zoneinfo.ZoneInfo, pytz.timezone and datetime.datetime.utc to return IANA timezone names like "America/Los_Angeles" - # instead of abbreviations like "PDT". This is for consistency with other timezone handling functions in SQLGlot - tz = Literal.string(str(value.tzinfo)) - - return TimeStrToTime(this=datetime_literal, zone=tz) - if isinstance(value, datetime.date): - date_literal = Literal.string(value.strftime("%Y-%m-%d")) - return DateStrToDate(this=date_literal) - if isinstance(value, datetime.time): - time_literal = Literal.string(value.isoformat()) - return TsOrDsToTime(this=time_literal) - if isinstance(value, tuple): - if hasattr(value, "_fields"): - return Struct( - expressions=[ - PropertyEQ( - this=to_identifier(k), - expression=convert(getattr(value, k), copy=copy), - ) - for k in value._fields - ] - ) - return Tuple(expressions=[convert(v, copy=copy) for v in value]) - if isinstance(value, list): - return Array(expressions=[convert(v, copy=copy) for v in value]) - if isinstance(value, dict): - return Map( - keys=Array(expressions=[convert(k, copy=copy) for k in value]), - values=Array(expressions=[convert(v, copy=copy) for v in value.values()]), - ) - if hasattr(value, "__dict__"): - return Struct( - expressions=[ - PropertyEQ(this=to_identifier(k), expression=convert(v, copy=copy)) - for k, v in value.__dict__.items() - ] - ) - raise ValueError(f"Cannot convert {value}") - - -def replace_children(expression: Expression, fun: t.Callable, *args, **kwargs) -> None: - """ - Replace children of an expression with the result of a lambda fun(child) -> exp. - """ - for k, v in tuple(expression.args.items()): - is_list_arg = type(v) is list - - child_nodes = v if is_list_arg else [v] - new_child_nodes = [] - - for cn in child_nodes: - if isinstance(cn, Expression): - for child_node in ensure_collection(fun(cn, *args, **kwargs)): - new_child_nodes.append(child_node) - else: - new_child_nodes.append(cn) - - expression.set( - k, new_child_nodes if is_list_arg else seq_get(new_child_nodes, 0) - ) - - -def replace_tree( - expression: Expression, - fun: t.Callable, - prune: t.Optional[t.Callable[[Expression], bool]] = None, -) -> Expression: - """ - Replace an entire tree with the result of function calls on each node. - - This will be traversed in reverse dfs, so leaves first. - If new nodes are created as a result of function calls, they will also be traversed. - """ - stack = list(expression.dfs(prune=prune)) - - while stack: - node = stack.pop() - new_node = fun(node) - - if new_node is not node: - node.replace(new_node) - - if isinstance(new_node, Expression): - stack.append(new_node) - - return new_node - - -def find_tables(expression: Expression) -> t.Set[Table]: - """ - Find all tables referenced in a query. - - Args: - expressions: The query to find the tables in. - - Returns: - A set of all the tables. - """ - from bigframes_vendored.sqlglot.optimizer.scope import traverse_scope - - return { - table - for scope in traverse_scope(expression) - for table in scope.tables - if table.name and table.name not in scope.cte_sources - } - - -def column_table_names(expression: Expression, exclude: str = "") -> t.Set[str]: - """ - Return all table names referenced through columns in an expression. - - Example: - >>> import sqlglot - >>> sorted(column_table_names(sqlglot.parse_one("a.b AND c.d AND c.e"))) - ['a', 'c'] - - Args: - expression: expression to find table names. - exclude: a table name to exclude - - Returns: - A list of unique names. - """ - return { - table - for table in (column.table for column in expression.find_all(Column)) - if table and table != exclude - } - - -def table_name( - table: Table | str, dialect: DialectType = None, identify: bool = False -) -> str: - """Get the full name of a table as a string. - - Args: - table: Table expression node or string. - dialect: The dialect to generate the table name for. - identify: Determines when an identifier should be quoted. Possible values are: - False (default): Never quote, except in cases where it's mandatory by the dialect. - True: Always quote. - - Examples: - >>> from sqlglot import exp, parse_one - >>> table_name(parse_one("select * from a.b.c").find(exp.Table)) - 'a.b.c' - - Returns: - The table name. - """ - - table = maybe_parse(table, into=Table, dialect=dialect) - - if not table: - raise ValueError(f"Cannot parse {table}") - - return ".".join( - ( - part.sql(dialect=dialect, identify=True, copy=False, comments=False) - if identify or not SAFE_IDENTIFIER_RE.match(part.name) - else part.name - ) - for part in table.parts - ) - - -def normalize_table_name( - table: str | Table, dialect: DialectType = None, copy: bool = True -) -> str: - """Returns a case normalized table name without quotes. - - Args: - table: the table to normalize - dialect: the dialect to use for normalization rules - copy: whether to copy the expression. - - Examples: - >>> normalize_table_name("`A-B`.c", dialect="bigquery") - 'A-B.c' - """ - from bigframes_vendored.sqlglot.optimizer.normalize_identifiers import ( - normalize_identifiers, - ) - - return ".".join( - p.name - for p in normalize_identifiers( - to_table(table, dialect=dialect, copy=copy), dialect=dialect - ).parts - ) - - -def replace_tables( - expression: E, - mapping: t.Dict[str, str], - dialect: DialectType = None, - copy: bool = True, -) -> E: - """Replace all tables in expression according to the mapping. - - Args: - expression: expression node to be transformed and replaced. - mapping: mapping of table names. - dialect: the dialect of the mapping table - copy: whether to copy the expression. - - Examples: - >>> from sqlglot import exp, parse_one - >>> replace_tables(parse_one("select * from a.b"), {"a.b": "c"}).sql() - 'SELECT * FROM c /* a.b */' - - Returns: - The mapped expression. - """ - - mapping = {normalize_table_name(k, dialect=dialect): v for k, v in mapping.items()} - - def _replace_tables(node: Expression) -> Expression: - if isinstance(node, Table) and node.meta.get("replace") is not False: - original = normalize_table_name(node, dialect=dialect) - new_name = mapping.get(original) - - if new_name: - table = to_table( - new_name, - **{k: v for k, v in node.args.items() if k not in TABLE_PARTS}, - dialect=dialect, - ) - table.add_comments([original]) - return table - return node - - return expression.transform(_replace_tables, copy=copy) # type: ignore - - -def replace_placeholders(expression: Expression, *args, **kwargs) -> Expression: - """Replace placeholders in an expression. - - Args: - expression: expression node to be transformed and replaced. - args: positional names that will substitute unnamed placeholders in the given order. - kwargs: keyword arguments that will substitute named placeholders. - - Examples: - >>> from sqlglot import exp, parse_one - >>> replace_placeholders( - ... parse_one("select * from :tbl where ? = ?"), - ... exp.to_identifier("str_col"), "b", tbl=exp.to_identifier("foo") - ... ).sql() - "SELECT * FROM foo WHERE str_col = 'b'" - - Returns: - The mapped expression. - """ - - def _replace_placeholders(node: Expression, args, **kwargs) -> Expression: - if isinstance(node, Placeholder): - if node.this: - new_name = kwargs.get(node.this) - if new_name is not None: - return convert(new_name) - else: - try: - return convert(next(args)) - except StopIteration: - pass - return node - - return expression.transform(_replace_placeholders, iter(args), **kwargs) - - -def expand( - expression: Expression, - sources: t.Dict[str, Query | t.Callable[[], Query]], - dialect: DialectType = None, - copy: bool = True, -) -> Expression: - """Transforms an expression by expanding all referenced sources into subqueries. - - Examples: - >>> from sqlglot import parse_one - >>> expand(parse_one("select * from x AS z"), {"x": parse_one("select * from y")}).sql() - 'SELECT * FROM (SELECT * FROM y) AS z /* source: x */' - - >>> expand(parse_one("select * from x AS z"), {"x": parse_one("select * from y"), "y": parse_one("select * from z")}).sql() - 'SELECT * FROM (SELECT * FROM (SELECT * FROM z) AS y /* source: y */) AS z /* source: x */' - - Args: - expression: The expression to expand. - sources: A dict of name to query or a callable that provides a query on demand. - dialect: The dialect of the sources dict or the callable. - copy: Whether to copy the expression during transformation. Defaults to True. - - Returns: - The transformed expression. - """ - normalized_sources = { - normalize_table_name(k, dialect=dialect): v for k, v in sources.items() - } - - def _expand(node: Expression): - if isinstance(node, Table): - name = normalize_table_name(node, dialect=dialect) - source = normalized_sources.get(name) - - if source: - # Create a subquery with the same alias (or table name if no alias) - parsed_source = source() if callable(source) else source - subquery = parsed_source.subquery(node.alias or name) - subquery.comments = [f"source: {name}"] - - # Continue expanding within the subquery - return subquery.transform(_expand, copy=False) - - return node - - return expression.transform(_expand, copy=copy) - - -def func( - name: str, *args, copy: bool = True, dialect: DialectType = None, **kwargs -) -> Func: - """ - Returns a Func expression. - - Examples: - >>> func("abs", 5).sql() - 'ABS(5)' - - >>> func("cast", this=5, to=DataType.build("DOUBLE")).sql() - 'CAST(5 AS DOUBLE)' - - Args: - name: the name of the function to build. - args: the args used to instantiate the function of interest. - copy: whether to copy the argument expressions. - dialect: the source dialect. - kwargs: the kwargs used to instantiate the function of interest. - - Note: - The arguments `args` and `kwargs` are mutually exclusive. - - Returns: - An instance of the function of interest, or an anonymous function, if `name` doesn't - correspond to an existing `sqlglot.expressions.Func` class. - """ - if args and kwargs: - raise ValueError("Can't use both args and kwargs to instantiate a function.") - - from bigframes_vendored.sqlglot.dialects.dialect import Dialect - - dialect = Dialect.get_or_raise(dialect) - - converted: t.List[Expression] = [ - maybe_parse(arg, dialect=dialect, copy=copy) for arg in args - ] - kwargs = { - key: maybe_parse(value, dialect=dialect, copy=copy) - for key, value in kwargs.items() - } - - constructor = dialect.parser_class.FUNCTIONS.get(name.upper()) - if constructor: - if converted: - if "dialect" in constructor.__code__.co_varnames: - function = constructor(converted, dialect=dialect) - else: - function = constructor(converted) - elif constructor.__name__ == "from_arg_list": - function = constructor.__self__(**kwargs) # type: ignore - else: - constructor = FUNCTION_BY_NAME.get(name.upper()) - if constructor: - function = constructor(**kwargs) - else: - raise ValueError( - f"Unable to convert '{name}' into a Func. Either manually construct " - "the Func expression of interest or parse the function call." - ) - else: - kwargs = kwargs or {"expressions": converted} - function = Anonymous(this=name, **kwargs) - - for error_message in function.error_messages(converted): - raise ValueError(error_message) - - return function - - -def case( - expression: t.Optional[ExpOrStr] = None, - **opts, -) -> Case: - """ - Initialize a CASE statement. - - Example: - case().when("a = 1", "foo").else_("bar") - - Args: - expression: Optionally, the input expression (not all dialects support this) - **opts: Extra keyword arguments for parsing `expression` - """ - if expression is not None: - this = maybe_parse(expression, **opts) - else: - this = None - return Case(this=this, ifs=[]) - - -def array( - *expressions: ExpOrStr, copy: bool = True, dialect: DialectType = None, **kwargs -) -> Array: - """ - Returns an array. - - Examples: - >>> array(1, 'x').sql() - 'ARRAY(1, x)' - - Args: - expressions: the expressions to add to the array. - copy: whether to copy the argument expressions. - dialect: the source dialect. - kwargs: the kwargs used to instantiate the function of interest. - - Returns: - An array expression. - """ - return Array( - expressions=[ - maybe_parse(expression, copy=copy, dialect=dialect, **kwargs) - for expression in expressions - ] - ) - - -def tuple_( - *expressions: ExpOrStr, copy: bool = True, dialect: DialectType = None, **kwargs -) -> Tuple: - """ - Returns an tuple. - - Examples: - >>> tuple_(1, 'x').sql() - '(1, x)' - - Args: - expressions: the expressions to add to the tuple. - copy: whether to copy the argument expressions. - dialect: the source dialect. - kwargs: the kwargs used to instantiate the function of interest. - - Returns: - A tuple expression. - """ - return Tuple( - expressions=[ - maybe_parse(expression, copy=copy, dialect=dialect, **kwargs) - for expression in expressions - ] - ) - - -def true() -> Boolean: - """ - Returns a true Boolean expression. - """ - return Boolean(this=True) - - -def false() -> Boolean: - """ - Returns a false Boolean expression. - """ - return Boolean(this=False) - - -def null() -> Null: - """ - Returns a Null expression. - """ - return Null() - - -NONNULL_CONSTANTS = ( - Literal, - Boolean, -) - -CONSTANTS = ( - Literal, - Boolean, - Null, -) diff --git a/third_party/bigframes_vendored/sqlglot/generator.py b/third_party/bigframes_vendored/sqlglot/generator.py deleted file mode 100644 index 80546fadc44..00000000000 --- a/third_party/bigframes_vendored/sqlglot/generator.py +++ /dev/null @@ -1,5850 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/generator.py - -from __future__ import annotations - -import logging -import re -import typing as t -from collections import defaultdict -from functools import reduce, wraps - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.errors import ( - ErrorLevel, - UnsupportedError, - concat_messages, -) -from bigframes_vendored.sqlglot.helper import ( - apply_index_offset, - csv, - name_sequence, - seq_get, -) -from bigframes_vendored.sqlglot.jsonpath import ( - ALL_JSON_PATH_PARTS, - JSON_PATH_PART_TRANSFORMS, -) -from bigframes_vendored.sqlglot.time import format_time -from bigframes_vendored.sqlglot.tokens import TokenType - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - G = t.TypeVar("G", bound="Generator") - GeneratorMethod = t.Callable[[G, E], str] - -logger = logging.getLogger("sqlglot") - -ESCAPED_UNICODE_RE = re.compile(r"\\(\d+)") -UNSUPPORTED_TEMPLATE = ( - "Argument '{}' is not supported for expression '{}' when targeting {}." -) - - -def unsupported_args( - *args: t.Union[str, t.Tuple[str, str]], -) -> t.Callable[[GeneratorMethod], GeneratorMethod]: - """ - Decorator that can be used to mark certain args of an `Expression` subclass as unsupported. - It expects a sequence of argument names or pairs of the form (argument_name, diagnostic_msg). - """ - diagnostic_by_arg: t.Dict[str, t.Optional[str]] = {} - for arg in args: - if isinstance(arg, str): - diagnostic_by_arg[arg] = None - else: - diagnostic_by_arg[arg[0]] = arg[1] - - def decorator(func: GeneratorMethod) -> GeneratorMethod: - @wraps(func) - def _func(generator: G, expression: E) -> str: - expression_name = expression.__class__.__name__ - dialect_name = generator.dialect.__class__.__name__ - - for arg_name, diagnostic in diagnostic_by_arg.items(): - if expression.args.get(arg_name): - diagnostic = diagnostic or UNSUPPORTED_TEMPLATE.format( - arg_name, expression_name, dialect_name - ) - generator.unsupported(diagnostic) - - return func(generator, expression) - - return _func - - return decorator - - -class _Generator(type): - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - - # Remove transforms that correspond to unsupported JSONPathPart expressions - for part in ALL_JSON_PATH_PARTS - klass.SUPPORTED_JSON_PATH_PARTS: - klass.TRANSFORMS.pop(part, None) - - return klass - - -class Generator(metaclass=_Generator): - """ - Generator converts a given syntax tree to the corresponding SQL string. - - Args: - pretty: Whether to format the produced SQL string. - Default: False. - identify: Determines when an identifier should be quoted. Possible values are: - False (default): Never quote, except in cases where it's mandatory by the dialect. - True: Always quote except for specials cases. - 'safe': Only quote identifiers that are case insensitive. - normalize: Whether to normalize identifiers to lowercase. - Default: False. - pad: The pad size in a formatted string. For example, this affects the indentation of - a projection in a query, relative to its nesting level. - Default: 2. - indent: The indentation size in a formatted string. For example, this affects the - indentation of subqueries and filters under a `WHERE` clause. - Default: 2. - normalize_functions: How to normalize function names. Possible values are: - "upper" or True (default): Convert names to uppercase. - "lower": Convert names to lowercase. - False: Disables function name normalization. - unsupported_level: Determines the generator's behavior when it encounters unsupported expressions. - Default ErrorLevel.WARN. - max_unsupported: Maximum number of unsupported messages to include in a raised UnsupportedError. - This is only relevant if unsupported_level is ErrorLevel.RAISE. - Default: 3 - leading_comma: Whether the comma is leading or trailing in select expressions. - This is only relevant when generating in pretty mode. - Default: False - max_text_width: The max number of characters in a segment before creating new lines in pretty mode. - The default is on the smaller end because the length only represents a segment and not the true - line length. - Default: 80 - comments: Whether to preserve comments in the output SQL code. - Default: True - """ - - TRANSFORMS: t.Dict[t.Type[exp.Expression], t.Callable[..., str]] = { - **JSON_PATH_PART_TRANSFORMS, - exp.Adjacent: lambda self, e: self.binary(e, "-|-"), - exp.AllowedValuesProperty: lambda self, - e: f"ALLOWED_VALUES {self.expressions(e, flat=True)}", - exp.AnalyzeColumns: lambda self, e: self.sql(e, "this"), - exp.AnalyzeWith: lambda self, e: self.expressions(e, prefix="WITH ", sep=" "), - exp.ArrayContainsAll: lambda self, e: self.binary(e, "@>"), - exp.ArrayOverlaps: lambda self, e: self.binary(e, "&&"), - exp.AutoRefreshProperty: lambda self, e: f"AUTO REFRESH {self.sql(e, 'this')}", - exp.BackupProperty: lambda self, e: f"BACKUP {self.sql(e, 'this')}", - exp.CaseSpecificColumnConstraint: lambda _, - e: f"{'NOT ' if e.args.get('not_') else ''}CASESPECIFIC", - exp.Ceil: lambda self, e: self.ceil_floor(e), - exp.CharacterSetColumnConstraint: lambda self, - e: f"CHARACTER SET {self.sql(e, 'this')}", - exp.CharacterSetProperty: lambda self, - e: f"{'DEFAULT ' if e.args.get('default') else ''}CHARACTER SET={self.sql(e, 'this')}", - exp.ClusteredColumnConstraint: lambda self, - e: f"CLUSTERED ({self.expressions(e, 'this', indent=False)})", - exp.CollateColumnConstraint: lambda self, e: f"COLLATE {self.sql(e, 'this')}", - exp.CommentColumnConstraint: lambda self, e: f"COMMENT {self.sql(e, 'this')}", - exp.ConnectByRoot: lambda self, e: f"CONNECT_BY_ROOT {self.sql(e, 'this')}", - exp.ConvertToCharset: lambda self, e: self.func( - "CONVERT", e.this, e.args["dest"], e.args.get("source") - ), - exp.CopyGrantsProperty: lambda *_: "COPY GRANTS", - exp.CredentialsProperty: lambda self, - e: f"CREDENTIALS=({self.expressions(e, 'expressions', sep=' ')})", - exp.CurrentCatalog: lambda *_: "CURRENT_CATALOG", - exp.SessionUser: lambda *_: "SESSION_USER", - exp.DateFormatColumnConstraint: lambda self, e: f"FORMAT {self.sql(e, 'this')}", - exp.DefaultColumnConstraint: lambda self, e: f"DEFAULT {self.sql(e, 'this')}", - exp.DynamicProperty: lambda *_: "DYNAMIC", - exp.EmptyProperty: lambda *_: "EMPTY", - exp.EncodeColumnConstraint: lambda self, e: f"ENCODE {self.sql(e, 'this')}", - exp.EnviromentProperty: lambda self, - e: f"ENVIRONMENT ({self.expressions(e, flat=True)})", - exp.EphemeralColumnConstraint: lambda self, - e: f"EPHEMERAL{(' ' + self.sql(e, 'this')) if e.this else ''}", - exp.ExcludeColumnConstraint: lambda self, - e: f"EXCLUDE {self.sql(e, 'this').lstrip()}", - exp.ExecuteAsProperty: lambda self, e: self.naked_property(e), - exp.Except: lambda self, e: self.set_operations(e), - exp.ExternalProperty: lambda *_: "EXTERNAL", - exp.Floor: lambda self, e: self.ceil_floor(e), - exp.Get: lambda self, e: self.get_put_sql(e), - exp.GlobalProperty: lambda *_: "GLOBAL", - exp.HeapProperty: lambda *_: "HEAP", - exp.IcebergProperty: lambda *_: "ICEBERG", - exp.InheritsProperty: lambda self, - e: f"INHERITS ({self.expressions(e, flat=True)})", - exp.InlineLengthColumnConstraint: lambda self, - e: f"INLINE LENGTH {self.sql(e, 'this')}", - exp.InputModelProperty: lambda self, e: f"INPUT{self.sql(e, 'this')}", - exp.Intersect: lambda self, e: self.set_operations(e), - exp.IntervalSpan: lambda self, - e: f"{self.sql(e, 'this')} TO {self.sql(e, 'expression')}", - exp.Int64: lambda self, e: self.sql(exp.cast(e.this, exp.DataType.Type.BIGINT)), - exp.JSONBContainsAnyTopKeys: lambda self, e: self.binary(e, "?|"), - exp.JSONBContainsAllTopKeys: lambda self, e: self.binary(e, "?&"), - exp.JSONBDeleteAtPath: lambda self, e: self.binary(e, "#-"), - exp.LanguageProperty: lambda self, e: self.naked_property(e), - exp.LocationProperty: lambda self, e: self.naked_property(e), - exp.LogProperty: lambda _, e: f"{'NO ' if e.args.get('no') else ''}LOG", - exp.MaterializedProperty: lambda *_: "MATERIALIZED", - exp.NonClusteredColumnConstraint: lambda self, - e: f"NONCLUSTERED ({self.expressions(e, 'this', indent=False)})", - exp.NoPrimaryIndexProperty: lambda *_: "NO PRIMARY INDEX", - exp.NotForReplicationColumnConstraint: lambda *_: "NOT FOR REPLICATION", - exp.OnCommitProperty: lambda _, - e: f"ON COMMIT {'DELETE' if e.args.get('delete') else 'PRESERVE'} ROWS", - exp.OnProperty: lambda self, e: f"ON {self.sql(e, 'this')}", - exp.OnUpdateColumnConstraint: lambda self, - e: f"ON UPDATE {self.sql(e, 'this')}", - exp.Operator: lambda self, e: self.binary( - e, "" - ), # The operator is produced in `binary` - exp.OutputModelProperty: lambda self, e: f"OUTPUT{self.sql(e, 'this')}", - exp.ExtendsLeft: lambda self, e: self.binary(e, "&<"), - exp.ExtendsRight: lambda self, e: self.binary(e, "&>"), - exp.PathColumnConstraint: lambda self, e: f"PATH {self.sql(e, 'this')}", - exp.PartitionedByBucket: lambda self, e: self.func( - "BUCKET", e.this, e.expression - ), - exp.PartitionByTruncate: lambda self, e: self.func( - "TRUNCATE", e.this, e.expression - ), - exp.PivotAny: lambda self, e: f"ANY{self.sql(e, 'this')}", - exp.PositionalColumn: lambda self, e: f"#{self.sql(e, 'this')}", - exp.ProjectionPolicyColumnConstraint: lambda self, - e: f"PROJECTION POLICY {self.sql(e, 'this')}", - exp.ZeroFillColumnConstraint: lambda self, e: "ZEROFILL", - exp.Put: lambda self, e: self.get_put_sql(e), - exp.RemoteWithConnectionModelProperty: lambda self, - e: f"REMOTE WITH CONNECTION {self.sql(e, 'this')}", - exp.ReturnsProperty: lambda self, e: ( - "RETURNS NULL ON NULL INPUT" - if e.args.get("null") - else self.naked_property(e) - ), - exp.SampleProperty: lambda self, e: f"SAMPLE BY {self.sql(e, 'this')}", - exp.SecureProperty: lambda *_: "SECURE", - exp.SecurityProperty: lambda self, e: f"SECURITY {self.sql(e, 'this')}", - exp.SetConfigProperty: lambda self, e: self.sql(e, "this"), - exp.SetProperty: lambda _, e: f"{'MULTI' if e.args.get('multi') else ''}SET", - exp.SettingsProperty: lambda self, - e: f"SETTINGS{self.seg('')}{(self.expressions(e))}", - exp.SharingProperty: lambda self, e: f"SHARING={self.sql(e, 'this')}", - exp.SqlReadWriteProperty: lambda _, e: e.name, - exp.SqlSecurityProperty: lambda self, e: f"SQL SECURITY {self.sql(e, 'this')}", - exp.StabilityProperty: lambda _, e: e.name, - exp.Stream: lambda self, e: f"STREAM {self.sql(e, 'this')}", - exp.StreamingTableProperty: lambda *_: "STREAMING", - exp.StrictProperty: lambda *_: "STRICT", - exp.SwapTable: lambda self, e: f"SWAP WITH {self.sql(e, 'this')}", - exp.TableColumn: lambda self, e: self.sql(e.this), - exp.Tags: lambda self, e: f"TAG ({self.expressions(e, flat=True)})", - exp.TemporaryProperty: lambda *_: "TEMPORARY", - exp.TitleColumnConstraint: lambda self, e: f"TITLE {self.sql(e, 'this')}", - exp.ToMap: lambda self, e: f"MAP {self.sql(e, 'this')}", - exp.ToTableProperty: lambda self, e: f"TO {self.sql(e.this)}", - exp.TransformModelProperty: lambda self, e: self.func( - "TRANSFORM", *e.expressions - ), - exp.TransientProperty: lambda *_: "TRANSIENT", - exp.Union: lambda self, e: self.set_operations(e), - exp.UnloggedProperty: lambda *_: "UNLOGGED", - exp.UsingTemplateProperty: lambda self, - e: f"USING TEMPLATE {self.sql(e, 'this')}", - exp.UsingData: lambda self, e: f"USING DATA {self.sql(e, 'this')}", - exp.UppercaseColumnConstraint: lambda *_: "UPPERCASE", - exp.UtcDate: lambda self, e: self.sql( - exp.CurrentDate(this=exp.Literal.string("UTC")) - ), - exp.UtcTime: lambda self, e: self.sql( - exp.CurrentTime(this=exp.Literal.string("UTC")) - ), - exp.UtcTimestamp: lambda self, e: self.sql( - exp.CurrentTimestamp(this=exp.Literal.string("UTC")) - ), - exp.VarMap: lambda self, e: self.func("MAP", e.args["keys"], e.args["values"]), - exp.ViewAttributeProperty: lambda self, e: f"WITH {self.sql(e, 'this')}", - exp.VolatileProperty: lambda *_: "VOLATILE", - exp.WithJournalTableProperty: lambda self, - e: f"WITH JOURNAL TABLE={self.sql(e, 'this')}", - exp.WithProcedureOptions: lambda self, - e: f"WITH {self.expressions(e, flat=True)}", - exp.WithSchemaBindingProperty: lambda self, - e: f"WITH SCHEMA {self.sql(e, 'this')}", - exp.WithOperator: lambda self, - e: f"{self.sql(e, 'this')} WITH {self.sql(e, 'op')}", - exp.ForceProperty: lambda *_: "FORCE", - } - - # Whether null ordering is supported in order by - # True: Full Support, None: No support, False: No support for certain cases - # such as window specifications, aggregate functions etc - NULL_ORDERING_SUPPORTED: t.Optional[bool] = True - - # Whether ignore nulls is inside the agg or outside. - # FIRST(x IGNORE NULLS) OVER vs FIRST (x) IGNORE NULLS OVER - IGNORE_NULLS_IN_FUNC = False - - # Whether locking reads (i.e. SELECT ... FOR UPDATE/SHARE) are supported - LOCKING_READS_SUPPORTED = False - - # Whether the EXCEPT and INTERSECT operations can return duplicates - EXCEPT_INTERSECT_SUPPORT_ALL_CLAUSE = True - - # Wrap derived values in parens, usually standard but spark doesn't support it - WRAP_DERIVED_VALUES = True - - # Whether create function uses an AS before the RETURN - CREATE_FUNCTION_RETURN_AS = True - - # Whether MERGE ... WHEN MATCHED BY SOURCE is allowed - MATCHED_BY_SOURCE = True - - # Whether the INTERVAL expression works only with values like '1 day' - SINGLE_STRING_INTERVAL = False - - # Whether the plural form of date parts like day (i.e. "days") is supported in INTERVALs - INTERVAL_ALLOWS_PLURAL_FORM = True - - # Whether limit and fetch are supported (possible values: "ALL", "LIMIT", "FETCH") - LIMIT_FETCH = "ALL" - - # Whether limit and fetch allows expresions or just limits - LIMIT_ONLY_LITERALS = False - - # Whether a table is allowed to be renamed with a db - RENAME_TABLE_WITH_DB = True - - # The separator for grouping sets and rollups - GROUPINGS_SEP = "," - - # The string used for creating an index on a table - INDEX_ON = "ON" - - # Whether join hints should be generated - JOIN_HINTS = True - - # Whether table hints should be generated - TABLE_HINTS = True - - # Whether query hints should be generated - QUERY_HINTS = True - - # What kind of separator to use for query hints - QUERY_HINT_SEP = ", " - - # Whether comparing against booleans (e.g. x IS TRUE) is supported - IS_BOOL_ALLOWED = True - - # Whether to include the "SET" keyword in the "INSERT ... ON DUPLICATE KEY UPDATE" statement - DUPLICATE_KEY_UPDATE_WITH_SET = True - - # Whether to generate the limit as TOP instead of LIMIT - LIMIT_IS_TOP = False - - # Whether to generate INSERT INTO ... RETURNING or INSERT INTO RETURNING ... - RETURNING_END = True - - # Whether to generate an unquoted value for EXTRACT's date part argument - EXTRACT_ALLOWS_QUOTES = True - - # Whether TIMETZ / TIMESTAMPTZ will be generated using the "WITH TIME ZONE" syntax - TZ_TO_WITH_TIME_ZONE = False - - # Whether the NVL2 function is supported - NVL2_SUPPORTED = True - - # https://cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax - SELECT_KINDS: t.Tuple[str, ...] = ("STRUCT", "VALUE") - - # Whether VALUES statements can be used as derived tables. - # MySQL 5 and Redshift do not allow this, so when False, it will convert - # SELECT * VALUES into SELECT UNION - VALUES_AS_TABLE = True - - # Whether the word COLUMN is included when adding a column with ALTER TABLE - ALTER_TABLE_INCLUDE_COLUMN_KEYWORD = True - - # UNNEST WITH ORDINALITY (presto) instead of UNNEST WITH OFFSET (bigquery) - UNNEST_WITH_ORDINALITY = True - - # Whether FILTER (WHERE cond) can be used for conditional aggregation - AGGREGATE_FILTER_SUPPORTED = True - - # Whether JOIN sides (LEFT, RIGHT) are supported in conjunction with SEMI/ANTI join kinds - SEMI_ANTI_JOIN_WITH_SIDE = True - - # Whether to include the type of a computed column in the CREATE DDL - COMPUTED_COLUMN_WITH_TYPE = True - - # Whether CREATE TABLE .. COPY .. is supported. False means we'll generate CLONE instead of COPY - SUPPORTS_TABLE_COPY = True - - # Whether parentheses are required around the table sample's expression - TABLESAMPLE_REQUIRES_PARENS = True - - # Whether a table sample clause's size needs to be followed by the ROWS keyword - TABLESAMPLE_SIZE_IS_ROWS = True - - # The keyword(s) to use when generating a sample clause - TABLESAMPLE_KEYWORDS = "TABLESAMPLE" - - # Whether the TABLESAMPLE clause supports a method name, like BERNOULLI - TABLESAMPLE_WITH_METHOD = True - - # The keyword to use when specifying the seed of a sample clause - TABLESAMPLE_SEED_KEYWORD = "SEED" - - # Whether COLLATE is a function instead of a binary operator - COLLATE_IS_FUNC = False - - # Whether data types support additional specifiers like e.g. CHAR or BYTE (oracle) - DATA_TYPE_SPECIFIERS_ALLOWED = False - - # Whether conditions require booleans WHERE x = 0 vs WHERE x - ENSURE_BOOLS = False - - # Whether the "RECURSIVE" keyword is required when defining recursive CTEs - CTE_RECURSIVE_KEYWORD_REQUIRED = True - - # Whether CONCAT requires >1 arguments - SUPPORTS_SINGLE_ARG_CONCAT = True - - # Whether LAST_DAY function supports a date part argument - LAST_DAY_SUPPORTS_DATE_PART = True - - # Whether named columns are allowed in table aliases - SUPPORTS_TABLE_ALIAS_COLUMNS = True - - # Whether UNPIVOT aliases are Identifiers (False means they're Literals) - UNPIVOT_ALIASES_ARE_IDENTIFIERS = True - - # What delimiter to use for separating JSON key/value pairs - JSON_KEY_VALUE_PAIR_SEP = ":" - - # INSERT OVERWRITE TABLE x override - INSERT_OVERWRITE = " OVERWRITE TABLE" - - # Whether the SELECT .. INTO syntax is used instead of CTAS - SUPPORTS_SELECT_INTO = False - - # Whether UNLOGGED tables can be created - SUPPORTS_UNLOGGED_TABLES = False - - # Whether the CREATE TABLE LIKE statement is supported - SUPPORTS_CREATE_TABLE_LIKE = True - - # Whether the LikeProperty needs to be specified inside of the schema clause - LIKE_PROPERTY_INSIDE_SCHEMA = False - - # Whether DISTINCT can be followed by multiple args in an AggFunc. If not, it will be - # transpiled into a series of CASE-WHEN-ELSE, ultimately using a tuple conseisting of the args - MULTI_ARG_DISTINCT = True - - # Whether the JSON extraction operators expect a value of type JSON - JSON_TYPE_REQUIRED_FOR_EXTRACTION = False - - # Whether bracketed keys like ["foo"] are supported in JSON paths - JSON_PATH_BRACKETED_KEY_SUPPORTED = True - - # Whether to escape keys using single quotes in JSON paths - JSON_PATH_SINGLE_QUOTE_ESCAPE = False - - # The JSONPathPart expressions supported by this dialect - SUPPORTED_JSON_PATH_PARTS = ALL_JSON_PATH_PARTS.copy() - - # Whether any(f(x) for x in array) can be implemented by this dialect - CAN_IMPLEMENT_ARRAY_ANY = False - - # Whether the function TO_NUMBER is supported - SUPPORTS_TO_NUMBER = True - - # Whether EXCLUDE in window specification is supported - SUPPORTS_WINDOW_EXCLUDE = False - - # Whether or not set op modifiers apply to the outer set op or select. - # SELECT * FROM x UNION SELECT * FROM y LIMIT 1 - # True means limit 1 happens after the set op, False means it it happens on y. - SET_OP_MODIFIERS = True - - # Whether parameters from COPY statement are wrapped in parentheses - COPY_PARAMS_ARE_WRAPPED = True - - # Whether values of params are set with "=" token or empty space - COPY_PARAMS_EQ_REQUIRED = False - - # Whether COPY statement has INTO keyword - COPY_HAS_INTO_KEYWORD = True - - # Whether the conditional TRY(expression) function is supported - TRY_SUPPORTED = True - - # Whether the UESCAPE syntax in unicode strings is supported - SUPPORTS_UESCAPE = True - - # Function used to replace escaped unicode codes in unicode strings - UNICODE_SUBSTITUTE: t.Optional[t.Callable[[re.Match[str]], str]] = None - - # The keyword to use when generating a star projection with excluded columns - STAR_EXCEPT = "EXCEPT" - - # The HEX function name - HEX_FUNC = "HEX" - - # The keywords to use when prefixing & separating WITH based properties - WITH_PROPERTIES_PREFIX = "WITH" - - # Whether to quote the generated expression of exp.JsonPath - QUOTE_JSON_PATH = True - - # Whether the text pattern/fill (3rd) parameter of RPAD()/LPAD() is optional (defaults to space) - PAD_FILL_PATTERN_IS_REQUIRED = False - - # Whether a projection can explode into multiple rows, e.g. by unnesting an array. - SUPPORTS_EXPLODING_PROJECTIONS = True - - # Whether ARRAY_CONCAT can be generated with varlen args or if it should be reduced to 2-arg version - ARRAY_CONCAT_IS_VAR_LEN = True - - # Whether CONVERT_TIMEZONE() is supported; if not, it will be generated as exp.AtTimeZone - SUPPORTS_CONVERT_TIMEZONE = False - - # Whether MEDIAN(expr) is supported; if not, it will be generated as PERCENTILE_CONT(expr, 0.5) - SUPPORTS_MEDIAN = True - - # Whether UNIX_SECONDS(timestamp) is supported - SUPPORTS_UNIX_SECONDS = False - - # Whether to wrap in `AlterSet`, e.g., ALTER ... SET () - ALTER_SET_WRAPPED = False - - # Whether to normalize the date parts in EXTRACT( FROM ) into a common representation - # For instance, to extract the day of week in ISO semantics, one can use ISODOW, DAYOFWEEKISO etc depending on the dialect. - # TODO: The normalization should be done by default once we've tested it across all dialects. - NORMALIZE_EXTRACT_DATE_PARTS = False - - # The name to generate for the JSONPath expression. If `None`, only `this` will be generated - PARSE_JSON_NAME: t.Optional[str] = "PARSE_JSON" - - # The function name of the exp.ArraySize expression - ARRAY_SIZE_NAME: str = "ARRAY_LENGTH" - - # The syntax to use when altering the type of a column - ALTER_SET_TYPE = "SET DATA TYPE" - - # Whether exp.ArraySize should generate the dimension arg too (valid for Postgres & DuckDB) - # None -> Doesn't support it at all - # False (DuckDB) -> Has backwards-compatible support, but preferably generated without - # True (Postgres) -> Explicitly requires it - ARRAY_SIZE_DIM_REQUIRED: t.Optional[bool] = None - - # Whether a multi-argument DECODE(...) function is supported. If not, a CASE expression is generated - SUPPORTS_DECODE_CASE = True - - # Whether SYMMETRIC and ASYMMETRIC flags are supported with BETWEEN expression - SUPPORTS_BETWEEN_FLAGS = False - - # Whether LIKE and ILIKE support quantifiers such as LIKE ANY/ALL/SOME - SUPPORTS_LIKE_QUANTIFIERS = True - - # Prefix which is appended to exp.Table expressions in MATCH AGAINST - MATCH_AGAINST_TABLE_PREFIX: t.Optional[str] = None - - # Whether to include the VARIABLE keyword for SET assignments - SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD = False - - TYPE_MAPPING = { - exp.DataType.Type.DATETIME2: "TIMESTAMP", - exp.DataType.Type.NCHAR: "CHAR", - exp.DataType.Type.NVARCHAR: "VARCHAR", - exp.DataType.Type.MEDIUMTEXT: "TEXT", - exp.DataType.Type.LONGTEXT: "TEXT", - exp.DataType.Type.TINYTEXT: "TEXT", - exp.DataType.Type.BLOB: "VARBINARY", - exp.DataType.Type.MEDIUMBLOB: "BLOB", - exp.DataType.Type.LONGBLOB: "BLOB", - exp.DataType.Type.TINYBLOB: "BLOB", - exp.DataType.Type.INET: "INET", - exp.DataType.Type.ROWVERSION: "VARBINARY", - exp.DataType.Type.SMALLDATETIME: "TIMESTAMP", - } - - UNSUPPORTED_TYPES: set[exp.DataType.Type] = set() - - TIME_PART_SINGULARS = { - "MICROSECONDS": "MICROSECOND", - "SECONDS": "SECOND", - "MINUTES": "MINUTE", - "HOURS": "HOUR", - "DAYS": "DAY", - "WEEKS": "WEEK", - "MONTHS": "MONTH", - "QUARTERS": "QUARTER", - "YEARS": "YEAR", - } - - AFTER_HAVING_MODIFIER_TRANSFORMS = { - "cluster": lambda self, e: self.sql(e, "cluster"), - "distribute": lambda self, e: self.sql(e, "distribute"), - "sort": lambda self, e: self.sql(e, "sort"), - "windows": lambda self, e: ( - self.seg("WINDOW ") + self.expressions(e, key="windows", flat=True) - if e.args.get("windows") - else "" - ), - "qualify": lambda self, e: self.sql(e, "qualify"), - } - - TOKEN_MAPPING: t.Dict[TokenType, str] = {} - - STRUCT_DELIMITER = ("<", ">") - - PARAMETER_TOKEN = "@" - NAMED_PLACEHOLDER_TOKEN = ":" - - EXPRESSION_PRECEDES_PROPERTIES_CREATABLES: t.Set[str] = set() - - PROPERTIES_LOCATION = { - exp.AllowedValuesProperty: exp.Properties.Location.POST_SCHEMA, - exp.AlgorithmProperty: exp.Properties.Location.POST_CREATE, - exp.AutoIncrementProperty: exp.Properties.Location.POST_SCHEMA, - exp.AutoRefreshProperty: exp.Properties.Location.POST_SCHEMA, - exp.BackupProperty: exp.Properties.Location.POST_SCHEMA, - exp.BlockCompressionProperty: exp.Properties.Location.POST_NAME, - exp.CharacterSetProperty: exp.Properties.Location.POST_SCHEMA, - exp.ChecksumProperty: exp.Properties.Location.POST_NAME, - exp.CollateProperty: exp.Properties.Location.POST_SCHEMA, - exp.CopyGrantsProperty: exp.Properties.Location.POST_SCHEMA, - exp.Cluster: exp.Properties.Location.POST_SCHEMA, - exp.ClusteredByProperty: exp.Properties.Location.POST_SCHEMA, - exp.DistributedByProperty: exp.Properties.Location.POST_SCHEMA, - exp.DuplicateKeyProperty: exp.Properties.Location.POST_SCHEMA, - exp.DataBlocksizeProperty: exp.Properties.Location.POST_NAME, - exp.DataDeletionProperty: exp.Properties.Location.POST_SCHEMA, - exp.DefinerProperty: exp.Properties.Location.POST_CREATE, - exp.DictRange: exp.Properties.Location.POST_SCHEMA, - exp.DictProperty: exp.Properties.Location.POST_SCHEMA, - exp.DynamicProperty: exp.Properties.Location.POST_CREATE, - exp.DistKeyProperty: exp.Properties.Location.POST_SCHEMA, - exp.DistStyleProperty: exp.Properties.Location.POST_SCHEMA, - exp.EmptyProperty: exp.Properties.Location.POST_SCHEMA, - exp.EncodeProperty: exp.Properties.Location.POST_EXPRESSION, - exp.EngineProperty: exp.Properties.Location.POST_SCHEMA, - exp.EnviromentProperty: exp.Properties.Location.POST_SCHEMA, - exp.ExecuteAsProperty: exp.Properties.Location.POST_SCHEMA, - exp.ExternalProperty: exp.Properties.Location.POST_CREATE, - exp.FallbackProperty: exp.Properties.Location.POST_NAME, - exp.FileFormatProperty: exp.Properties.Location.POST_WITH, - exp.FreespaceProperty: exp.Properties.Location.POST_NAME, - exp.GlobalProperty: exp.Properties.Location.POST_CREATE, - exp.HeapProperty: exp.Properties.Location.POST_WITH, - exp.InheritsProperty: exp.Properties.Location.POST_SCHEMA, - exp.IcebergProperty: exp.Properties.Location.POST_CREATE, - exp.IncludeProperty: exp.Properties.Location.POST_SCHEMA, - exp.InputModelProperty: exp.Properties.Location.POST_SCHEMA, - exp.IsolatedLoadingProperty: exp.Properties.Location.POST_NAME, - exp.JournalProperty: exp.Properties.Location.POST_NAME, - exp.LanguageProperty: exp.Properties.Location.POST_SCHEMA, - exp.LikeProperty: exp.Properties.Location.POST_SCHEMA, - exp.LocationProperty: exp.Properties.Location.POST_SCHEMA, - exp.LockProperty: exp.Properties.Location.POST_SCHEMA, - exp.LockingProperty: exp.Properties.Location.POST_ALIAS, - exp.LogProperty: exp.Properties.Location.POST_NAME, - exp.MaterializedProperty: exp.Properties.Location.POST_CREATE, - exp.MergeBlockRatioProperty: exp.Properties.Location.POST_NAME, - exp.NoPrimaryIndexProperty: exp.Properties.Location.POST_EXPRESSION, - exp.OnProperty: exp.Properties.Location.POST_SCHEMA, - exp.OnCommitProperty: exp.Properties.Location.POST_EXPRESSION, - exp.Order: exp.Properties.Location.POST_SCHEMA, - exp.OutputModelProperty: exp.Properties.Location.POST_SCHEMA, - exp.PartitionedByProperty: exp.Properties.Location.POST_WITH, - exp.PartitionedOfProperty: exp.Properties.Location.POST_SCHEMA, - exp.PrimaryKey: exp.Properties.Location.POST_SCHEMA, - exp.Property: exp.Properties.Location.POST_WITH, - exp.RemoteWithConnectionModelProperty: exp.Properties.Location.POST_SCHEMA, - exp.ReturnsProperty: exp.Properties.Location.POST_SCHEMA, - exp.RowFormatProperty: exp.Properties.Location.POST_SCHEMA, - exp.RowFormatDelimitedProperty: exp.Properties.Location.POST_SCHEMA, - exp.RowFormatSerdeProperty: exp.Properties.Location.POST_SCHEMA, - exp.SampleProperty: exp.Properties.Location.POST_SCHEMA, - exp.SchemaCommentProperty: exp.Properties.Location.POST_SCHEMA, - exp.SecureProperty: exp.Properties.Location.POST_CREATE, - exp.SecurityProperty: exp.Properties.Location.POST_SCHEMA, - exp.SerdeProperties: exp.Properties.Location.POST_SCHEMA, - exp.Set: exp.Properties.Location.POST_SCHEMA, - exp.SettingsProperty: exp.Properties.Location.POST_SCHEMA, - exp.SetProperty: exp.Properties.Location.POST_CREATE, - exp.SetConfigProperty: exp.Properties.Location.POST_SCHEMA, - exp.SharingProperty: exp.Properties.Location.POST_EXPRESSION, - exp.SequenceProperties: exp.Properties.Location.POST_EXPRESSION, - exp.SortKeyProperty: exp.Properties.Location.POST_SCHEMA, - exp.SqlReadWriteProperty: exp.Properties.Location.POST_SCHEMA, - exp.SqlSecurityProperty: exp.Properties.Location.POST_CREATE, - exp.StabilityProperty: exp.Properties.Location.POST_SCHEMA, - exp.StorageHandlerProperty: exp.Properties.Location.POST_SCHEMA, - exp.StreamingTableProperty: exp.Properties.Location.POST_CREATE, - exp.StrictProperty: exp.Properties.Location.POST_SCHEMA, - exp.Tags: exp.Properties.Location.POST_WITH, - exp.TemporaryProperty: exp.Properties.Location.POST_CREATE, - exp.ToTableProperty: exp.Properties.Location.POST_SCHEMA, - exp.TransientProperty: exp.Properties.Location.POST_CREATE, - exp.TransformModelProperty: exp.Properties.Location.POST_SCHEMA, - exp.MergeTreeTTL: exp.Properties.Location.POST_SCHEMA, - exp.UnloggedProperty: exp.Properties.Location.POST_CREATE, - exp.UsingTemplateProperty: exp.Properties.Location.POST_SCHEMA, - exp.ViewAttributeProperty: exp.Properties.Location.POST_SCHEMA, - exp.VolatileProperty: exp.Properties.Location.POST_CREATE, - exp.WithDataProperty: exp.Properties.Location.POST_EXPRESSION, - exp.WithJournalTableProperty: exp.Properties.Location.POST_NAME, - exp.WithProcedureOptions: exp.Properties.Location.POST_SCHEMA, - exp.WithSchemaBindingProperty: exp.Properties.Location.POST_SCHEMA, - exp.WithSystemVersioningProperty: exp.Properties.Location.POST_SCHEMA, - exp.ForceProperty: exp.Properties.Location.POST_CREATE, - } - - # Keywords that can't be used as unquoted identifier names - RESERVED_KEYWORDS: t.Set[str] = set() - - # Expressions whose comments are separated from them for better formatting - WITH_SEPARATED_COMMENTS: t.Tuple[t.Type[exp.Expression], ...] = ( - exp.Command, - exp.Create, - exp.Describe, - exp.Delete, - exp.Drop, - exp.From, - exp.Insert, - exp.Join, - exp.MultitableInserts, - exp.Order, - exp.Group, - exp.Having, - exp.Select, - exp.SetOperation, - exp.Update, - exp.Where, - exp.With, - ) - - # Expressions that should not have their comments generated in maybe_comment - EXCLUDE_COMMENTS: t.Tuple[t.Type[exp.Expression], ...] = ( - exp.Binary, - exp.SetOperation, - ) - - # Expressions that can remain unwrapped when appearing in the context of an INTERVAL - UNWRAPPED_INTERVAL_VALUES: t.Tuple[t.Type[exp.Expression], ...] = ( - exp.Column, - exp.Literal, - exp.Neg, - exp.Paren, - ) - - PARAMETERIZABLE_TEXT_TYPES = { - exp.DataType.Type.NVARCHAR, - exp.DataType.Type.VARCHAR, - exp.DataType.Type.CHAR, - exp.DataType.Type.NCHAR, - } - - # Expressions that need to have all CTEs under them bubbled up to them - EXPRESSIONS_WITHOUT_NESTED_CTES: t.Set[t.Type[exp.Expression]] = set() - - RESPECT_IGNORE_NULLS_UNSUPPORTED_EXPRESSIONS: t.Tuple[ - t.Type[exp.Expression], ... - ] = () - - SAFE_JSON_PATH_KEY_RE = exp.SAFE_IDENTIFIER_RE - - SENTINEL_LINE_BREAK = "__SQLGLOT__LB__" - - __slots__ = ( - "pretty", - "identify", - "normalize", - "pad", - "_indent", - "normalize_functions", - "unsupported_level", - "max_unsupported", - "leading_comma", - "max_text_width", - "comments", - "dialect", - "unsupported_messages", - "_escaped_quote_end", - "_escaped_byte_quote_end", - "_escaped_identifier_end", - "_next_name", - "_identifier_start", - "_identifier_end", - "_quote_json_path_key_using_brackets", - ) - - def __init__( - self, - pretty: t.Optional[bool] = None, - identify: str | bool = False, - normalize: bool = False, - pad: int = 2, - indent: int = 2, - normalize_functions: t.Optional[str | bool] = None, - unsupported_level: ErrorLevel = ErrorLevel.WARN, - max_unsupported: int = 3, - leading_comma: bool = False, - max_text_width: int = 80, - comments: bool = True, - dialect: DialectType = None, - ): - import bigframes_vendored.sqlglot - from bigframes_vendored.sqlglot.dialects import Dialect - - self.pretty = ( - pretty if pretty is not None else bigframes_vendored.sqlglot.pretty - ) - self.identify = identify - self.normalize = normalize - self.pad = pad - self._indent = indent - self.unsupported_level = unsupported_level - self.max_unsupported = max_unsupported - self.leading_comma = leading_comma - self.max_text_width = max_text_width - self.comments = comments - self.dialect = Dialect.get_or_raise(dialect) - - # This is both a Dialect property and a Generator argument, so we prioritize the latter - self.normalize_functions = ( - self.dialect.NORMALIZE_FUNCTIONS - if normalize_functions is None - else normalize_functions - ) - - self.unsupported_messages: t.List[str] = [] - self._escaped_quote_end: str = ( - self.dialect.tokenizer_class.STRING_ESCAPES[0] + self.dialect.QUOTE_END - ) - self._escaped_byte_quote_end: str = ( - self.dialect.tokenizer_class.STRING_ESCAPES[0] + self.dialect.BYTE_END - if self.dialect.BYTE_END - else "" - ) - self._escaped_identifier_end = self.dialect.IDENTIFIER_END * 2 - - self._next_name = name_sequence("_t") - - self._identifier_start = self.dialect.IDENTIFIER_START - self._identifier_end = self.dialect.IDENTIFIER_END - - self._quote_json_path_key_using_brackets = True - - def generate(self, expression: exp.Expression, copy: bool = True) -> str: - """ - Generates the SQL string corresponding to the given syntax tree. - - Args: - expression: The syntax tree. - copy: Whether to copy the expression. The generator performs mutations so - it is safer to copy. - - Returns: - The SQL string corresponding to `expression`. - """ - if copy: - expression = expression.copy() - - expression = self.preprocess(expression) - - self.unsupported_messages = [] - sql = self.sql(expression).strip() - - if self.pretty: - sql = sql.replace(self.SENTINEL_LINE_BREAK, "\n") - - if self.unsupported_level == ErrorLevel.IGNORE: - return sql - - if self.unsupported_level == ErrorLevel.WARN: - for msg in self.unsupported_messages: - logger.warning(msg) - elif self.unsupported_level == ErrorLevel.RAISE and self.unsupported_messages: - raise UnsupportedError( - concat_messages(self.unsupported_messages, self.max_unsupported) - ) - - return sql - - def preprocess(self, expression: exp.Expression) -> exp.Expression: - """Apply generic preprocessing transformations to a given expression.""" - expression = self._move_ctes_to_top_level(expression) - - if self.ENSURE_BOOLS: - from bigframes_vendored.sqlglot.transforms import ensure_bools - - expression = ensure_bools(expression) - - return expression - - def _move_ctes_to_top_level(self, expression: E) -> E: - if ( - not expression.parent - and type(expression) in self.EXPRESSIONS_WITHOUT_NESTED_CTES - and any( - node.parent is not expression for node in expression.find_all(exp.With) - ) - ): - from bigframes_vendored.sqlglot.transforms import move_ctes_to_top_level - - expression = move_ctes_to_top_level(expression) - return expression - - def unsupported(self, message: str) -> None: - if self.unsupported_level == ErrorLevel.IMMEDIATE: - raise UnsupportedError(message) - self.unsupported_messages.append(message) - - def sep(self, sep: str = " ") -> str: - return f"{sep.strip()}\n" if self.pretty else sep - - def seg(self, sql: str, sep: str = " ") -> str: - return f"{self.sep(sep)}{sql}" - - def sanitize_comment(self, comment: str) -> str: - comment = " " + comment if comment[0].strip() else comment - comment = comment + " " if comment[-1].strip() else comment - - if not self.dialect.tokenizer_class.NESTED_COMMENTS: - # Necessary workaround to avoid syntax errors due to nesting: /* ... */ ... */ - comment = comment.replace("*/", "* /") - - return comment - - def maybe_comment( - self, - sql: str, - expression: t.Optional[exp.Expression] = None, - comments: t.Optional[t.List[str]] = None, - separated: bool = False, - ) -> str: - comments = ( - ((expression and expression.comments) if comments is None else comments) # type: ignore - if self.comments - else None - ) - - if not comments or isinstance(expression, self.EXCLUDE_COMMENTS): - return sql - - comments_sql = " ".join( - f"/*{self.sanitize_comment(comment)}*/" for comment in comments if comment - ) - - if not comments_sql: - return sql - - comments_sql = self._replace_line_breaks(comments_sql) - - if separated or isinstance(expression, self.WITH_SEPARATED_COMMENTS): - return ( - f"{self.sep()}{comments_sql}{sql}" - if not sql or sql[0].isspace() - else f"{comments_sql}{self.sep()}{sql}" - ) - - return f"{sql} {comments_sql}" - - def wrap(self, expression: exp.Expression | str) -> str: - this_sql = ( - self.sql(expression) - if isinstance(expression, exp.UNWRAPPED_QUERIES) - else self.sql(expression, "this") - ) - if not this_sql: - return "()" - - this_sql = self.indent(this_sql, level=1, pad=0) - return f"({self.sep('')}{this_sql}{self.seg(')', sep='')}" - - def no_identify(self, func: t.Callable[..., str], *args, **kwargs) -> str: - original = self.identify - self.identify = False - result = func(*args, **kwargs) - self.identify = original - return result - - def normalize_func(self, name: str) -> str: - if self.normalize_functions == "upper" or self.normalize_functions is True: - return name.upper() - if self.normalize_functions == "lower": - return name.lower() - return name - - def indent( - self, - sql: str, - level: int = 0, - pad: t.Optional[int] = None, - skip_first: bool = False, - skip_last: bool = False, - ) -> str: - if not self.pretty or not sql: - return sql - - pad = self.pad if pad is None else pad - lines = sql.split("\n") - - return "\n".join( - ( - line - if (skip_first and i == 0) or (skip_last and i == len(lines) - 1) - else f"{' ' * (level * self._indent + pad)}{line}" - ) - for i, line in enumerate(lines) - ) - - def sql( - self, - expression: t.Optional[str | exp.Expression], - key: t.Optional[str] = None, - comment: bool = True, - ) -> str: - if not expression: - return "" - - if isinstance(expression, str): - return expression - - if key: - value = expression.args.get(key) - if value: - return self.sql(value) - return "" - - transform = self.TRANSFORMS.get(expression.__class__) - - if callable(transform): - sql = transform(self, expression) - elif isinstance(expression, exp.Expression): - exp_handler_name = f"{expression.key}_sql" - - if hasattr(self, exp_handler_name): - sql = getattr(self, exp_handler_name)(expression) - elif isinstance(expression, exp.Func): - sql = self.function_fallback_sql(expression) - elif isinstance(expression, exp.Property): - sql = self.property_sql(expression) - else: - raise ValueError( - f"Unsupported expression type {expression.__class__.__name__}" - ) - else: - raise ValueError( - f"Expected an Expression. Received {type(expression)}: {expression}" - ) - - return self.maybe_comment(sql, expression) if self.comments and comment else sql - - def uncache_sql(self, expression: exp.Uncache) -> str: - table = self.sql(expression, "this") - exists_sql = " IF EXISTS" if expression.args.get("exists") else "" - return f"UNCACHE TABLE{exists_sql} {table}" - - def cache_sql(self, expression: exp.Cache) -> str: - lazy = " LAZY" if expression.args.get("lazy") else "" - table = self.sql(expression, "this") - options = expression.args.get("options") - options = ( - f" OPTIONS({self.sql(options[0])} = {self.sql(options[1])})" - if options - else "" - ) - sql = self.sql(expression, "expression") - sql = f" AS{self.sep()}{sql}" if sql else "" - sql = f"CACHE{lazy} TABLE {table}{options}{sql}" - return self.prepend_ctes(expression, sql) - - def characterset_sql(self, expression: exp.CharacterSet) -> str: - if isinstance(expression.parent, exp.Cast): - return f"CHAR CHARACTER SET {self.sql(expression, 'this')}" - default = "DEFAULT " if expression.args.get("default") else "" - return f"{default}CHARACTER SET={self.sql(expression, 'this')}" - - def column_parts(self, expression: exp.Column) -> str: - return ".".join( - self.sql(part) - for part in ( - expression.args.get("catalog"), - expression.args.get("db"), - expression.args.get("table"), - expression.args.get("this"), - ) - if part - ) - - def column_sql(self, expression: exp.Column) -> str: - join_mark = " (+)" if expression.args.get("join_mark") else "" - - if join_mark and not self.dialect.SUPPORTS_COLUMN_JOIN_MARKS: - join_mark = "" - self.unsupported( - "Outer join syntax using the (+) operator is not supported." - ) - - return f"{self.column_parts(expression)}{join_mark}" - - def pseudocolumn_sql(self, expression: exp.Pseudocolumn) -> str: - return self.column_sql(expression) - - def columnposition_sql(self, expression: exp.ColumnPosition) -> str: - this = self.sql(expression, "this") - this = f" {this}" if this else "" - position = self.sql(expression, "position") - return f"{position}{this}" - - def columndef_sql(self, expression: exp.ColumnDef, sep: str = " ") -> str: - column = self.sql(expression, "this") - kind = self.sql(expression, "kind") - constraints = self.expressions( - expression, key="constraints", sep=" ", flat=True - ) - exists = "IF NOT EXISTS " if expression.args.get("exists") else "" - kind = f"{sep}{kind}" if kind else "" - constraints = f" {constraints}" if constraints else "" - position = self.sql(expression, "position") - position = f" {position}" if position else "" - - if ( - expression.find(exp.ComputedColumnConstraint) - and not self.COMPUTED_COLUMN_WITH_TYPE - ): - kind = "" - - return f"{exists}{column}{kind}{constraints}{position}" - - def columnconstraint_sql(self, expression: exp.ColumnConstraint) -> str: - this = self.sql(expression, "this") - kind_sql = self.sql(expression, "kind").strip() - return f"CONSTRAINT {this} {kind_sql}" if this else kind_sql - - def computedcolumnconstraint_sql( - self, expression: exp.ComputedColumnConstraint - ) -> str: - this = self.sql(expression, "this") - if expression.args.get("not_null"): - persisted = " PERSISTED NOT NULL" - elif expression.args.get("persisted"): - persisted = " PERSISTED" - else: - persisted = "" - - return f"AS {this}{persisted}" - - def autoincrementcolumnconstraint_sql(self, _) -> str: - return self.token_sql(TokenType.AUTO_INCREMENT) - - def compresscolumnconstraint_sql( - self, expression: exp.CompressColumnConstraint - ) -> str: - if isinstance(expression.this, list): - this = self.wrap(self.expressions(expression, key="this", flat=True)) - else: - this = self.sql(expression, "this") - - return f"COMPRESS {this}" - - def generatedasidentitycolumnconstraint_sql( - self, expression: exp.GeneratedAsIdentityColumnConstraint - ) -> str: - this = "" - if expression.this is not None: - on_null = " ON NULL" if expression.args.get("on_null") else "" - this = " ALWAYS" if expression.this else f" BY DEFAULT{on_null}" - - start = expression.args.get("start") - start = f"START WITH {start}" if start else "" - increment = expression.args.get("increment") - increment = f" INCREMENT BY {increment}" if increment else "" - minvalue = expression.args.get("minvalue") - minvalue = f" MINVALUE {minvalue}" if minvalue else "" - maxvalue = expression.args.get("maxvalue") - maxvalue = f" MAXVALUE {maxvalue}" if maxvalue else "" - cycle = expression.args.get("cycle") - cycle_sql = "" - - if cycle is not None: - cycle_sql = f"{' NO' if not cycle else ''} CYCLE" - cycle_sql = cycle_sql.strip() if not start and not increment else cycle_sql - - sequence_opts = "" - if start or increment or cycle_sql: - sequence_opts = f"{start}{increment}{minvalue}{maxvalue}{cycle_sql}" - sequence_opts = f" ({sequence_opts.strip()})" - - expr = self.sql(expression, "expression") - expr = f"({expr})" if expr else "IDENTITY" - - return f"GENERATED{this} AS {expr}{sequence_opts}" - - def generatedasrowcolumnconstraint_sql( - self, expression: exp.GeneratedAsRowColumnConstraint - ) -> str: - start = "START" if expression.args.get("start") else "END" - hidden = " HIDDEN" if expression.args.get("hidden") else "" - return f"GENERATED ALWAYS AS ROW {start}{hidden}" - - def periodforsystemtimeconstraint_sql( - self, expression: exp.PeriodForSystemTimeConstraint - ) -> str: - return f"PERIOD FOR SYSTEM_TIME ({self.sql(expression, 'this')}, {self.sql(expression, 'expression')})" - - def notnullcolumnconstraint_sql( - self, expression: exp.NotNullColumnConstraint - ) -> str: - return f"{'' if expression.args.get('allow_null') else 'NOT '}NULL" - - def primarykeycolumnconstraint_sql( - self, expression: exp.PrimaryKeyColumnConstraint - ) -> str: - desc = expression.args.get("desc") - if desc is not None: - return f"PRIMARY KEY{' DESC' if desc else ' ASC'}" - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - return f"PRIMARY KEY{options}" - - def uniquecolumnconstraint_sql(self, expression: exp.UniqueColumnConstraint) -> str: - this = self.sql(expression, "this") - this = f" {this}" if this else "" - index_type = expression.args.get("index_type") - index_type = f" USING {index_type}" if index_type else "" - on_conflict = self.sql(expression, "on_conflict") - on_conflict = f" {on_conflict}" if on_conflict else "" - nulls_sql = " NULLS NOT DISTINCT" if expression.args.get("nulls") else "" - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - return f"UNIQUE{nulls_sql}{this}{index_type}{on_conflict}{options}" - - def createable_sql(self, expression: exp.Create, locations: t.DefaultDict) -> str: - return self.sql(expression, "this") - - def create_sql(self, expression: exp.Create) -> str: - kind = self.sql(expression, "kind") - kind = self.dialect.INVERSE_CREATABLE_KIND_MAPPING.get(kind) or kind - properties = expression.args.get("properties") - properties_locs = ( - self.locate_properties(properties) if properties else defaultdict() - ) - - this = self.createable_sql(expression, properties_locs) - - properties_sql = "" - if properties_locs.get( - exp.Properties.Location.POST_SCHEMA - ) or properties_locs.get(exp.Properties.Location.POST_WITH): - props_ast = exp.Properties( - expressions=[ - *properties_locs[exp.Properties.Location.POST_SCHEMA], - *properties_locs[exp.Properties.Location.POST_WITH], - ] - ) - props_ast.parent = expression - properties_sql = self.sql(props_ast) - - if properties_locs.get(exp.Properties.Location.POST_SCHEMA): - properties_sql = self.sep() + properties_sql - elif not self.pretty: - # Standalone POST_WITH properties need a leading whitespace in non-pretty mode - properties_sql = f" {properties_sql}" - - begin = " BEGIN" if expression.args.get("begin") else "" - end = " END" if expression.args.get("end") else "" - - expression_sql = self.sql(expression, "expression") - if expression_sql: - expression_sql = f"{begin}{self.sep()}{expression_sql}{end}" - - if self.CREATE_FUNCTION_RETURN_AS or not isinstance( - expression.expression, exp.Return - ): - postalias_props_sql = "" - if properties_locs.get(exp.Properties.Location.POST_ALIAS): - postalias_props_sql = self.properties( - exp.Properties( - expressions=properties_locs[ - exp.Properties.Location.POST_ALIAS - ] - ), - wrapped=False, - ) - postalias_props_sql = ( - f" {postalias_props_sql}" if postalias_props_sql else "" - ) - expression_sql = f" AS{postalias_props_sql}{expression_sql}" - - postindex_props_sql = "" - if properties_locs.get(exp.Properties.Location.POST_INDEX): - postindex_props_sql = self.properties( - exp.Properties( - expressions=properties_locs[exp.Properties.Location.POST_INDEX] - ), - wrapped=False, - prefix=" ", - ) - - indexes = self.expressions(expression, key="indexes", indent=False, sep=" ") - indexes = f" {indexes}" if indexes else "" - index_sql = indexes + postindex_props_sql - - replace = " OR REPLACE" if expression.args.get("replace") else "" - refresh = " OR REFRESH" if expression.args.get("refresh") else "" - unique = " UNIQUE" if expression.args.get("unique") else "" - - clustered = expression.args.get("clustered") - if clustered is None: - clustered_sql = "" - elif clustered: - clustered_sql = " CLUSTERED COLUMNSTORE" - else: - clustered_sql = " NONCLUSTERED COLUMNSTORE" - - postcreate_props_sql = "" - if properties_locs.get(exp.Properties.Location.POST_CREATE): - postcreate_props_sql = self.properties( - exp.Properties( - expressions=properties_locs[exp.Properties.Location.POST_CREATE] - ), - sep=" ", - prefix=" ", - wrapped=False, - ) - - modifiers = "".join( - (clustered_sql, replace, refresh, unique, postcreate_props_sql) - ) - - postexpression_props_sql = "" - if properties_locs.get(exp.Properties.Location.POST_EXPRESSION): - postexpression_props_sql = self.properties( - exp.Properties( - expressions=properties_locs[exp.Properties.Location.POST_EXPRESSION] - ), - sep=" ", - prefix=" ", - wrapped=False, - ) - - concurrently = " CONCURRENTLY" if expression.args.get("concurrently") else "" - exists_sql = " IF NOT EXISTS" if expression.args.get("exists") else "" - no_schema_binding = ( - " WITH NO SCHEMA BINDING" - if expression.args.get("no_schema_binding") - else "" - ) - - clone = self.sql(expression, "clone") - clone = f" {clone}" if clone else "" - - if kind in self.EXPRESSION_PRECEDES_PROPERTIES_CREATABLES: - properties_expression = f"{expression_sql}{properties_sql}" - else: - properties_expression = f"{properties_sql}{expression_sql}" - - expression_sql = f"CREATE{modifiers} {kind}{concurrently}{exists_sql} {this}{properties_expression}{postexpression_props_sql}{index_sql}{no_schema_binding}{clone}" - return self.prepend_ctes(expression, expression_sql) - - def sequenceproperties_sql(self, expression: exp.SequenceProperties) -> str: - start = self.sql(expression, "start") - start = f"START WITH {start}" if start else "" - increment = self.sql(expression, "increment") - increment = f" INCREMENT BY {increment}" if increment else "" - minvalue = self.sql(expression, "minvalue") - minvalue = f" MINVALUE {minvalue}" if minvalue else "" - maxvalue = self.sql(expression, "maxvalue") - maxvalue = f" MAXVALUE {maxvalue}" if maxvalue else "" - owned = self.sql(expression, "owned") - owned = f" OWNED BY {owned}" if owned else "" - - cache = expression.args.get("cache") - if cache is None: - cache_str = "" - elif cache is True: - cache_str = " CACHE" - else: - cache_str = f" CACHE {cache}" - - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - - return f"{start}{increment}{minvalue}{maxvalue}{cache_str}{options}{owned}".lstrip() - - def clone_sql(self, expression: exp.Clone) -> str: - this = self.sql(expression, "this") - shallow = "SHALLOW " if expression.args.get("shallow") else "" - keyword = ( - "COPY" - if expression.args.get("copy") and self.SUPPORTS_TABLE_COPY - else "CLONE" - ) - return f"{shallow}{keyword} {this}" - - def describe_sql(self, expression: exp.Describe) -> str: - style = expression.args.get("style") - style = f" {style}" if style else "" - partition = self.sql(expression, "partition") - partition = f" {partition}" if partition else "" - format = self.sql(expression, "format") - format = f" {format}" if format else "" - - return f"DESCRIBE{style}{format} {self.sql(expression, 'this')}{partition}" - - def heredoc_sql(self, expression: exp.Heredoc) -> str: - tag = self.sql(expression, "tag") - return f"${tag}${self.sql(expression, 'this')}${tag}$" - - def prepend_ctes(self, expression: exp.Expression, sql: str) -> str: - with_ = self.sql(expression, "with_") - if with_: - sql = f"{with_}{self.sep()}{sql}" - return sql - - def with_sql(self, expression: exp.With) -> str: - sql = self.expressions(expression, flat=True) - recursive = ( - "RECURSIVE " - if self.CTE_RECURSIVE_KEYWORD_REQUIRED and expression.args.get("recursive") - else "" - ) - search = self.sql(expression, "search") - search = f" {search}" if search else "" - - return f"WITH {recursive}{sql}{search}" - - def cte_sql(self, expression: exp.CTE) -> str: - alias = expression.args.get("alias") - if alias: - alias.add_comments(expression.pop_comments()) - - alias_sql = self.sql(expression, "alias") - - materialized = expression.args.get("materialized") - if materialized is False: - materialized = "NOT MATERIALIZED " - elif materialized: - materialized = "MATERIALIZED " - - key_expressions = self.expressions(expression, key="key_expressions", flat=True) - key_expressions = f" USING KEY ({key_expressions})" if key_expressions else "" - - return f"{alias_sql}{key_expressions} AS {materialized or ''}{self.wrap(expression)}" - - def tablealias_sql(self, expression: exp.TableAlias) -> str: - alias = self.sql(expression, "this") - columns = self.expressions(expression, key="columns", flat=True) - columns = f"({columns})" if columns else "" - - if columns and not self.SUPPORTS_TABLE_ALIAS_COLUMNS: - columns = "" - self.unsupported("Named columns are not supported in table alias.") - - if not alias and not self.dialect.UNNEST_COLUMN_ONLY: - alias = self._next_name() - - return f"{alias}{columns}" - - def bitstring_sql(self, expression: exp.BitString) -> str: - this = self.sql(expression, "this") - if self.dialect.BIT_START: - return f"{self.dialect.BIT_START}{this}{self.dialect.BIT_END}" - return f"{int(this, 2)}" - - def hexstring_sql( - self, expression: exp.HexString, binary_function_repr: t.Optional[str] = None - ) -> str: - this = self.sql(expression, "this") - is_integer_type = expression.args.get("is_integer") - - if (is_integer_type and not self.dialect.HEX_STRING_IS_INTEGER_TYPE) or ( - not self.dialect.HEX_START and not binary_function_repr - ): - # Integer representation will be returned if: - # - The read dialect treats the hex value as integer literal but not the write - # - The transpilation is not supported (write dialect hasn't set HEX_START or the param flag) - return f"{int(this, 16)}" - - if not is_integer_type: - # Read dialect treats the hex value as BINARY/BLOB - if binary_function_repr: - # The write dialect supports the transpilation to its equivalent BINARY/BLOB - return self.func(binary_function_repr, exp.Literal.string(this)) - if self.dialect.HEX_STRING_IS_INTEGER_TYPE: - # The write dialect does not support the transpilation, it'll treat the hex value as INTEGER - self.unsupported( - "Unsupported transpilation from BINARY/BLOB hex string" - ) - - return f"{self.dialect.HEX_START}{this}{self.dialect.HEX_END}" - - def bytestring_sql(self, expression: exp.ByteString) -> str: - this = self.sql(expression, "this") - if self.dialect.BYTE_START: - escaped_byte_string = self.escape_str( - this, - escape_backslash=False, - delimiter=self.dialect.BYTE_END, - escaped_delimiter=self._escaped_byte_quote_end, - ) - is_bytes = expression.args.get("is_bytes", False) - delimited_byte_string = ( - f"{self.dialect.BYTE_START}{escaped_byte_string}{self.dialect.BYTE_END}" - ) - if is_bytes and not self.dialect.BYTE_STRING_IS_BYTES_TYPE: - return self.sql( - exp.cast( - delimited_byte_string, - exp.DataType.Type.BINARY, - dialect=self.dialect, - ) - ) - if not is_bytes and self.dialect.BYTE_STRING_IS_BYTES_TYPE: - return self.sql( - exp.cast( - delimited_byte_string, - exp.DataType.Type.VARCHAR, - dialect=self.dialect, - ) - ) - - return delimited_byte_string - return this - - def unicodestring_sql(self, expression: exp.UnicodeString) -> str: - this = self.sql(expression, "this") - escape = expression.args.get("escape") - - if self.dialect.UNICODE_START: - escape_substitute = r"\\\1" - left_quote, right_quote = ( - self.dialect.UNICODE_START, - self.dialect.UNICODE_END, - ) - else: - escape_substitute = r"\\u\1" - left_quote, right_quote = self.dialect.QUOTE_START, self.dialect.QUOTE_END - - if escape: - escape_pattern = re.compile(rf"{escape.name}(\d+)") - escape_sql = f" UESCAPE {self.sql(escape)}" if self.SUPPORTS_UESCAPE else "" - else: - escape_pattern = ESCAPED_UNICODE_RE - escape_sql = "" - - if not self.dialect.UNICODE_START or (escape and not self.SUPPORTS_UESCAPE): - this = escape_pattern.sub( - self.UNICODE_SUBSTITUTE or escape_substitute, this - ) - - return f"{left_quote}{this}{right_quote}{escape_sql}" - - def rawstring_sql(self, expression: exp.RawString) -> str: - string = expression.this - if "\\" in self.dialect.tokenizer_class.STRING_ESCAPES: - string = string.replace("\\", "\\\\") - - string = self.escape_str(string, escape_backslash=False) - return f"{self.dialect.QUOTE_START}{string}{self.dialect.QUOTE_END}" - - def datatypeparam_sql(self, expression: exp.DataTypeParam) -> str: - this = self.sql(expression, "this") - specifier = self.sql(expression, "expression") - specifier = ( - f" {specifier}" if specifier and self.DATA_TYPE_SPECIFIERS_ALLOWED else "" - ) - return f"{this}{specifier}" - - def datatype_sql(self, expression: exp.DataType) -> str: - nested = "" - values = "" - interior = self.expressions(expression, flat=True) - - type_value = expression.this - if type_value in self.UNSUPPORTED_TYPES: - self.unsupported( - f"Data type {type_value.value} is not supported when targeting {self.dialect.__class__.__name__}" - ) - - if type_value == exp.DataType.Type.USERDEFINED and expression.args.get("kind"): - type_sql = self.sql(expression, "kind") - else: - type_sql = ( - self.TYPE_MAPPING.get(type_value, type_value.value) - if isinstance(type_value, exp.DataType.Type) - else type_value - ) - - if interior: - if expression.args.get("nested"): - nested = ( - f"{self.STRUCT_DELIMITER[0]}{interior}{self.STRUCT_DELIMITER[1]}" - ) - if expression.args.get("values") is not None: - delimiters = ( - ("[", "]") - if type_value == exp.DataType.Type.ARRAY - else ("(", ")") - ) - values = self.expressions(expression, key="values", flat=True) - values = f"{delimiters[0]}{values}{delimiters[1]}" - elif type_value == exp.DataType.Type.INTERVAL: - nested = f" {interior}" - else: - nested = f"({interior})" - - type_sql = f"{type_sql}{nested}{values}" - if self.TZ_TO_WITH_TIME_ZONE and type_value in ( - exp.DataType.Type.TIMETZ, - exp.DataType.Type.TIMESTAMPTZ, - ): - type_sql = f"{type_sql} WITH TIME ZONE" - - return type_sql - - def directory_sql(self, expression: exp.Directory) -> str: - local = "LOCAL " if expression.args.get("local") else "" - row_format = self.sql(expression, "row_format") - row_format = f" {row_format}" if row_format else "" - return f"{local}DIRECTORY {self.sql(expression, 'this')}{row_format}" - - def delete_sql(self, expression: exp.Delete) -> str: - this = self.sql(expression, "this") - this = f" FROM {this}" if this else "" - using = self.expressions(expression, key="using") - using = f" USING {using}" if using else "" - cluster = self.sql(expression, "cluster") - cluster = f" {cluster}" if cluster else "" - where = self.sql(expression, "where") - returning = self.sql(expression, "returning") - order = self.sql(expression, "order") - limit = self.sql(expression, "limit") - tables = self.expressions(expression, key="tables") - tables = f" {tables}" if tables else "" - if self.RETURNING_END: - expression_sql = f"{this}{using}{cluster}{where}{returning}{order}{limit}" - else: - expression_sql = f"{returning}{this}{using}{cluster}{where}{order}{limit}" - return self.prepend_ctes(expression, f"DELETE{tables}{expression_sql}") - - def drop_sql(self, expression: exp.Drop) -> str: - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - expressions = f" ({expressions})" if expressions else "" - kind = expression.args["kind"] - kind = self.dialect.INVERSE_CREATABLE_KIND_MAPPING.get(kind) or kind - exists_sql = " IF EXISTS " if expression.args.get("exists") else " " - concurrently_sql = ( - " CONCURRENTLY" if expression.args.get("concurrently") else "" - ) - on_cluster = self.sql(expression, "cluster") - on_cluster = f" {on_cluster}" if on_cluster else "" - temporary = " TEMPORARY" if expression.args.get("temporary") else "" - materialized = " MATERIALIZED" if expression.args.get("materialized") else "" - cascade = " CASCADE" if expression.args.get("cascade") else "" - constraints = " CONSTRAINTS" if expression.args.get("constraints") else "" - purge = " PURGE" if expression.args.get("purge") else "" - return f"DROP{temporary}{materialized} {kind}{concurrently_sql}{exists_sql}{this}{on_cluster}{expressions}{cascade}{constraints}{purge}" - - def set_operation(self, expression: exp.SetOperation) -> str: - op_type = type(expression) - op_name = op_type.key.upper() - - distinct = expression.args.get("distinct") - if ( - distinct is False - and op_type in (exp.Except, exp.Intersect) - and not self.EXCEPT_INTERSECT_SUPPORT_ALL_CLAUSE - ): - self.unsupported(f"{op_name} ALL is not supported") - - default_distinct = self.dialect.SET_OP_DISTINCT_BY_DEFAULT[op_type] - - if distinct is None: - distinct = default_distinct - if distinct is None: - self.unsupported(f"{op_name} requires DISTINCT or ALL to be specified") - - if distinct is default_distinct: - distinct_or_all = "" - else: - distinct_or_all = " DISTINCT" if distinct else " ALL" - - side_kind = " ".join(filter(None, [expression.side, expression.kind])) - side_kind = f"{side_kind} " if side_kind else "" - - by_name = " BY NAME" if expression.args.get("by_name") else "" - on = self.expressions(expression, key="on", flat=True) - on = f" ON ({on})" if on else "" - - return f"{side_kind}{op_name}{distinct_or_all}{by_name}{on}" - - def set_operations(self, expression: exp.SetOperation) -> str: - if not self.SET_OP_MODIFIERS: - limit = expression.args.get("limit") - order = expression.args.get("order") - - if limit or order: - select = self._move_ctes_to_top_level( - exp.subquery(expression, "_l_0", copy=False).select("*", copy=False) - ) - - if limit: - select = select.limit(limit.pop(), copy=False) - if order: - select = select.order_by(order.pop(), copy=False) - return self.sql(select) - - sqls: t.List[str] = [] - stack: t.List[t.Union[str, exp.Expression]] = [expression] - - while stack: - node = stack.pop() - - if isinstance(node, exp.SetOperation): - stack.append(node.expression) - stack.append( - self.maybe_comment( - self.set_operation(node), comments=node.comments, separated=True - ) - ) - stack.append(node.this) - else: - sqls.append(self.sql(node)) - - this = self.sep().join(sqls) - this = self.query_modifiers(expression, this) - return self.prepend_ctes(expression, this) - - def fetch_sql(self, expression: exp.Fetch) -> str: - direction = expression.args.get("direction") - direction = f" {direction}" if direction else "" - count = self.sql(expression, "count") - count = f" {count}" if count else "" - limit_options = self.sql(expression, "limit_options") - limit_options = f"{limit_options}" if limit_options else " ROWS ONLY" - return f"{self.seg('FETCH')}{direction}{count}{limit_options}" - - def limitoptions_sql(self, expression: exp.LimitOptions) -> str: - percent = " PERCENT" if expression.args.get("percent") else "" - rows = " ROWS" if expression.args.get("rows") else "" - with_ties = " WITH TIES" if expression.args.get("with_ties") else "" - if not with_ties and rows: - with_ties = " ONLY" - return f"{percent}{rows}{with_ties}" - - def filter_sql(self, expression: exp.Filter) -> str: - if self.AGGREGATE_FILTER_SUPPORTED: - this = self.sql(expression, "this") - where = self.sql(expression, "expression").strip() - return f"{this} FILTER({where})" - - agg = expression.this - agg_arg = agg.this - cond = expression.expression.this - agg_arg.replace(exp.If(this=cond.copy(), true=agg_arg.copy())) - return self.sql(agg) - - def hint_sql(self, expression: exp.Hint) -> str: - if not self.QUERY_HINTS: - self.unsupported("Hints are not supported") - return "" - - return ( - f" /*+ {self.expressions(expression, sep=self.QUERY_HINT_SEP).strip()} */" - ) - - def indexparameters_sql(self, expression: exp.IndexParameters) -> str: - using = self.sql(expression, "using") - using = f" USING {using}" if using else "" - columns = self.expressions(expression, key="columns", flat=True) - columns = f"({columns})" if columns else "" - partition_by = self.expressions(expression, key="partition_by", flat=True) - partition_by = f" PARTITION BY {partition_by}" if partition_by else "" - where = self.sql(expression, "where") - include = self.expressions(expression, key="include", flat=True) - if include: - include = f" INCLUDE ({include})" - with_storage = self.expressions(expression, key="with_storage", flat=True) - with_storage = f" WITH ({with_storage})" if with_storage else "" - tablespace = self.sql(expression, "tablespace") - tablespace = f" USING INDEX TABLESPACE {tablespace}" if tablespace else "" - on = self.sql(expression, "on") - on = f" ON {on}" if on else "" - - return f"{using}{columns}{include}{with_storage}{tablespace}{partition_by}{where}{on}" - - def index_sql(self, expression: exp.Index) -> str: - unique = "UNIQUE " if expression.args.get("unique") else "" - primary = "PRIMARY " if expression.args.get("primary") else "" - amp = "AMP " if expression.args.get("amp") else "" - name = self.sql(expression, "this") - name = f"{name} " if name else "" - table = self.sql(expression, "table") - table = f"{self.INDEX_ON} {table}" if table else "" - - index = "INDEX " if not table else "" - - params = self.sql(expression, "params") - return f"{unique}{primary}{amp}{index}{name}{table}{params}" - - def identifier_sql(self, expression: exp.Identifier) -> str: - text = expression.name - lower = text.lower() - text = lower if self.normalize and not expression.quoted else text - text = text.replace(self._identifier_end, self._escaped_identifier_end) - if ( - expression.quoted - or self.dialect.can_quote(expression, self.identify) - or lower in self.RESERVED_KEYWORDS - or ( - not self.dialect.IDENTIFIERS_CAN_START_WITH_DIGIT and text[:1].isdigit() - ) - ): - text = f"{self._identifier_start}{text}{self._identifier_end}" - return text - - def hex_sql(self, expression: exp.Hex) -> str: - text = self.func(self.HEX_FUNC, self.sql(expression, "this")) - if self.dialect.HEX_LOWERCASE: - text = self.func("LOWER", text) - - return text - - def lowerhex_sql(self, expression: exp.LowerHex) -> str: - text = self.func(self.HEX_FUNC, self.sql(expression, "this")) - if not self.dialect.HEX_LOWERCASE: - text = self.func("LOWER", text) - return text - - def inputoutputformat_sql(self, expression: exp.InputOutputFormat) -> str: - input_format = self.sql(expression, "input_format") - input_format = f"INPUTFORMAT {input_format}" if input_format else "" - output_format = self.sql(expression, "output_format") - output_format = f"OUTPUTFORMAT {output_format}" if output_format else "" - return self.sep().join((input_format, output_format)) - - def national_sql(self, expression: exp.National, prefix: str = "N") -> str: - string = self.sql(exp.Literal.string(expression.name)) - return f"{prefix}{string}" - - def partition_sql(self, expression: exp.Partition) -> str: - partition_keyword = ( - "SUBPARTITION" if expression.args.get("subpartition") else "PARTITION" - ) - return f"{partition_keyword}({self.expressions(expression, flat=True)})" - - def properties_sql(self, expression: exp.Properties) -> str: - root_properties = [] - with_properties = [] - - for p in expression.expressions: - p_loc = self.PROPERTIES_LOCATION[p.__class__] - if p_loc == exp.Properties.Location.POST_WITH: - with_properties.append(p) - elif p_loc == exp.Properties.Location.POST_SCHEMA: - root_properties.append(p) - - root_props_ast = exp.Properties(expressions=root_properties) - root_props_ast.parent = expression.parent - - with_props_ast = exp.Properties(expressions=with_properties) - with_props_ast.parent = expression.parent - - root_props = self.root_properties(root_props_ast) - with_props = self.with_properties(with_props_ast) - - if root_props and with_props and not self.pretty: - with_props = " " + with_props - - return root_props + with_props - - def root_properties(self, properties: exp.Properties) -> str: - if properties.expressions: - return self.expressions(properties, indent=False, sep=" ") - return "" - - def properties( - self, - properties: exp.Properties, - prefix: str = "", - sep: str = ", ", - suffix: str = "", - wrapped: bool = True, - ) -> str: - if properties.expressions: - expressions = self.expressions(properties, sep=sep, indent=False) - if expressions: - expressions = self.wrap(expressions) if wrapped else expressions - return f"{prefix}{' ' if prefix.strip() else ''}{expressions}{suffix}" - return "" - - def with_properties(self, properties: exp.Properties) -> str: - return self.properties( - properties, prefix=self.seg(self.WITH_PROPERTIES_PREFIX, sep="") - ) - - def locate_properties(self, properties: exp.Properties) -> t.DefaultDict: - properties_locs = defaultdict(list) - for p in properties.expressions: - p_loc = self.PROPERTIES_LOCATION[p.__class__] - if p_loc != exp.Properties.Location.UNSUPPORTED: - properties_locs[p_loc].append(p) - else: - self.unsupported(f"Unsupported property {p.key}") - - return properties_locs - - def property_name(self, expression: exp.Property, string_key: bool = False) -> str: - if isinstance(expression.this, exp.Dot): - return self.sql(expression, "this") - return f"'{expression.name}'" if string_key else expression.name - - def property_sql(self, expression: exp.Property) -> str: - property_cls = expression.__class__ - if property_cls == exp.Property: - return f"{self.property_name(expression)}={self.sql(expression, 'value')}" - - property_name = exp.Properties.PROPERTY_TO_NAME.get(property_cls) - if not property_name: - self.unsupported(f"Unsupported property {expression.key}") - - return f"{property_name}={self.sql(expression, 'this')}" - - def likeproperty_sql(self, expression: exp.LikeProperty) -> str: - if self.SUPPORTS_CREATE_TABLE_LIKE: - options = " ".join( - f"{e.name} {self.sql(e, 'value')}" for e in expression.expressions - ) - options = f" {options}" if options else "" - - like = f"LIKE {self.sql(expression, 'this')}{options}" - if self.LIKE_PROPERTY_INSIDE_SCHEMA and not isinstance( - expression.parent, exp.Schema - ): - like = f"({like})" - - return like - - if expression.expressions: - self.unsupported("Transpilation of LIKE property options is unsupported") - - select = exp.select("*").from_(expression.this).limit(0) - return f"AS {self.sql(select)}" - - def fallbackproperty_sql(self, expression: exp.FallbackProperty) -> str: - no = "NO " if expression.args.get("no") else "" - protection = " PROTECTION" if expression.args.get("protection") else "" - return f"{no}FALLBACK{protection}" - - def journalproperty_sql(self, expression: exp.JournalProperty) -> str: - no = "NO " if expression.args.get("no") else "" - local = expression.args.get("local") - local = f"{local} " if local else "" - dual = "DUAL " if expression.args.get("dual") else "" - before = "BEFORE " if expression.args.get("before") else "" - after = "AFTER " if expression.args.get("after") else "" - return f"{no}{local}{dual}{before}{after}JOURNAL" - - def freespaceproperty_sql(self, expression: exp.FreespaceProperty) -> str: - freespace = self.sql(expression, "this") - percent = " PERCENT" if expression.args.get("percent") else "" - return f"FREESPACE={freespace}{percent}" - - def checksumproperty_sql(self, expression: exp.ChecksumProperty) -> str: - if expression.args.get("default"): - property = "DEFAULT" - elif expression.args.get("on"): - property = "ON" - else: - property = "OFF" - return f"CHECKSUM={property}" - - def mergeblockratioproperty_sql( - self, expression: exp.MergeBlockRatioProperty - ) -> str: - if expression.args.get("no"): - return "NO MERGEBLOCKRATIO" - if expression.args.get("default"): - return "DEFAULT MERGEBLOCKRATIO" - - percent = " PERCENT" if expression.args.get("percent") else "" - return f"MERGEBLOCKRATIO={self.sql(expression, 'this')}{percent}" - - def datablocksizeproperty_sql(self, expression: exp.DataBlocksizeProperty) -> str: - default = expression.args.get("default") - minimum = expression.args.get("minimum") - maximum = expression.args.get("maximum") - if default or minimum or maximum: - if default: - prop = "DEFAULT" - elif minimum: - prop = "MINIMUM" - else: - prop = "MAXIMUM" - return f"{prop} DATABLOCKSIZE" - units = expression.args.get("units") - units = f" {units}" if units else "" - return f"DATABLOCKSIZE={self.sql(expression, 'size')}{units}" - - def blockcompressionproperty_sql( - self, expression: exp.BlockCompressionProperty - ) -> str: - autotemp = expression.args.get("autotemp") - always = expression.args.get("always") - default = expression.args.get("default") - manual = expression.args.get("manual") - never = expression.args.get("never") - - if autotemp is not None: - prop = f"AUTOTEMP({self.expressions(autotemp)})" - elif always: - prop = "ALWAYS" - elif default: - prop = "DEFAULT" - elif manual: - prop = "MANUAL" - elif never: - prop = "NEVER" - return f"BLOCKCOMPRESSION={prop}" - - def isolatedloadingproperty_sql( - self, expression: exp.IsolatedLoadingProperty - ) -> str: - no = expression.args.get("no") - no = " NO" if no else "" - concurrent = expression.args.get("concurrent") - concurrent = " CONCURRENT" if concurrent else "" - target = self.sql(expression, "target") - target = f" {target}" if target else "" - return f"WITH{no}{concurrent} ISOLATED LOADING{target}" - - def partitionboundspec_sql(self, expression: exp.PartitionBoundSpec) -> str: - if isinstance(expression.this, list): - return f"IN ({self.expressions(expression, key='this', flat=True)})" - if expression.this: - modulus = self.sql(expression, "this") - remainder = self.sql(expression, "expression") - return f"WITH (MODULUS {modulus}, REMAINDER {remainder})" - - from_expressions = self.expressions( - expression, key="from_expressions", flat=True - ) - to_expressions = self.expressions(expression, key="to_expressions", flat=True) - return f"FROM ({from_expressions}) TO ({to_expressions})" - - def partitionedofproperty_sql(self, expression: exp.PartitionedOfProperty) -> str: - this = self.sql(expression, "this") - - for_values_or_default = expression.expression - if isinstance(for_values_or_default, exp.PartitionBoundSpec): - for_values_or_default = f" FOR VALUES {self.sql(for_values_or_default)}" - else: - for_values_or_default = " DEFAULT" - - return f"PARTITION OF {this}{for_values_or_default}" - - def lockingproperty_sql(self, expression: exp.LockingProperty) -> str: - kind = expression.args.get("kind") - this = f" {self.sql(expression, 'this')}" if expression.this else "" - for_or_in = expression.args.get("for_or_in") - for_or_in = f" {for_or_in}" if for_or_in else "" - lock_type = expression.args.get("lock_type") - override = " OVERRIDE" if expression.args.get("override") else "" - return f"LOCKING {kind}{this}{for_or_in} {lock_type}{override}" - - def withdataproperty_sql(self, expression: exp.WithDataProperty) -> str: - data_sql = f"WITH {'NO ' if expression.args.get('no') else ''}DATA" - statistics = expression.args.get("statistics") - statistics_sql = "" - if statistics is not None: - statistics_sql = f" AND {'NO ' if not statistics else ''}STATISTICS" - return f"{data_sql}{statistics_sql}" - - def withsystemversioningproperty_sql( - self, expression: exp.WithSystemVersioningProperty - ) -> str: - this = self.sql(expression, "this") - this = f"HISTORY_TABLE={this}" if this else "" - data_consistency: t.Optional[str] = self.sql(expression, "data_consistency") - data_consistency = ( - f"DATA_CONSISTENCY_CHECK={data_consistency}" if data_consistency else None - ) - retention_period: t.Optional[str] = self.sql(expression, "retention_period") - retention_period = ( - f"HISTORY_RETENTION_PERIOD={retention_period}" if retention_period else None - ) - - if this: - on_sql = self.func("ON", this, data_consistency, retention_period) - else: - on_sql = "ON" if expression.args.get("on") else "OFF" - - sql = f"SYSTEM_VERSIONING={on_sql}" - - return f"WITH({sql})" if expression.args.get("with_") else sql - - def insert_sql(self, expression: exp.Insert) -> str: - hint = self.sql(expression, "hint") - overwrite = expression.args.get("overwrite") - - if isinstance(expression.this, exp.Directory): - this = " OVERWRITE" if overwrite else " INTO" - else: - this = self.INSERT_OVERWRITE if overwrite else " INTO" - - stored = self.sql(expression, "stored") - stored = f" {stored}" if stored else "" - alternative = expression.args.get("alternative") - alternative = f" OR {alternative}" if alternative else "" - ignore = " IGNORE" if expression.args.get("ignore") else "" - is_function = expression.args.get("is_function") - if is_function: - this = f"{this} FUNCTION" - this = f"{this} {self.sql(expression, 'this')}" - - exists = " IF EXISTS" if expression.args.get("exists") else "" - where = self.sql(expression, "where") - where = f"{self.sep()}REPLACE WHERE {where}" if where else "" - expression_sql = f"{self.sep()}{self.sql(expression, 'expression')}" - on_conflict = self.sql(expression, "conflict") - on_conflict = f" {on_conflict}" if on_conflict else "" - by_name = " BY NAME" if expression.args.get("by_name") else "" - default_values = "DEFAULT VALUES" if expression.args.get("default") else "" - returning = self.sql(expression, "returning") - - if self.RETURNING_END: - expression_sql = f"{expression_sql}{on_conflict}{default_values}{returning}" - else: - expression_sql = f"{returning}{expression_sql}{on_conflict}" - - partition_by = self.sql(expression, "partition") - partition_by = f" {partition_by}" if partition_by else "" - settings = self.sql(expression, "settings") - settings = f" {settings}" if settings else "" - - source = self.sql(expression, "source") - source = f"TABLE {source}" if source else "" - - sql = f"INSERT{hint}{alternative}{ignore}{this}{stored}{by_name}{exists}{partition_by}{settings}{where}{expression_sql}{source}" - return self.prepend_ctes(expression, sql) - - def introducer_sql(self, expression: exp.Introducer) -> str: - return f"{self.sql(expression, 'this')} {self.sql(expression, 'expression')}" - - def kill_sql(self, expression: exp.Kill) -> str: - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - this = self.sql(expression, "this") - this = f" {this}" if this else "" - return f"KILL{kind}{this}" - - def pseudotype_sql(self, expression: exp.PseudoType) -> str: - return expression.name - - def objectidentifier_sql(self, expression: exp.ObjectIdentifier) -> str: - return expression.name - - def onconflict_sql(self, expression: exp.OnConflict) -> str: - conflict = ( - "ON DUPLICATE KEY" if expression.args.get("duplicate") else "ON CONFLICT" - ) - - constraint = self.sql(expression, "constraint") - constraint = f" ON CONSTRAINT {constraint}" if constraint else "" - - conflict_keys = self.expressions(expression, key="conflict_keys", flat=True) - conflict_keys = f"({conflict_keys}) " if conflict_keys else " " - action = self.sql(expression, "action") - - expressions = self.expressions(expression, flat=True) - if expressions: - set_keyword = "SET " if self.DUPLICATE_KEY_UPDATE_WITH_SET else "" - expressions = f" {set_keyword}{expressions}" - - where = self.sql(expression, "where") - return f"{conflict}{constraint}{conflict_keys}{action}{expressions}{where}" - - def returning_sql(self, expression: exp.Returning) -> str: - return f"{self.seg('RETURNING')} {self.expressions(expression, flat=True)}" - - def rowformatdelimitedproperty_sql( - self, expression: exp.RowFormatDelimitedProperty - ) -> str: - fields = self.sql(expression, "fields") - fields = f" FIELDS TERMINATED BY {fields}" if fields else "" - escaped = self.sql(expression, "escaped") - escaped = f" ESCAPED BY {escaped}" if escaped else "" - items = self.sql(expression, "collection_items") - items = f" COLLECTION ITEMS TERMINATED BY {items}" if items else "" - keys = self.sql(expression, "map_keys") - keys = f" MAP KEYS TERMINATED BY {keys}" if keys else "" - lines = self.sql(expression, "lines") - lines = f" LINES TERMINATED BY {lines}" if lines else "" - null = self.sql(expression, "null") - null = f" NULL DEFINED AS {null}" if null else "" - return f"ROW FORMAT DELIMITED{fields}{escaped}{items}{keys}{lines}{null}" - - def withtablehint_sql(self, expression: exp.WithTableHint) -> str: - return f"WITH ({self.expressions(expression, flat=True)})" - - def indextablehint_sql(self, expression: exp.IndexTableHint) -> str: - this = f"{self.sql(expression, 'this')} INDEX" - target = self.sql(expression, "target") - target = f" FOR {target}" if target else "" - return f"{this}{target} ({self.expressions(expression, flat=True)})" - - def historicaldata_sql(self, expression: exp.HistoricalData) -> str: - this = self.sql(expression, "this") - kind = self.sql(expression, "kind") - expr = self.sql(expression, "expression") - return f"{this} ({kind} => {expr})" - - def table_parts(self, expression: exp.Table) -> str: - return ".".join( - self.sql(part) - for part in ( - expression.args.get("catalog"), - expression.args.get("db"), - expression.args.get("this"), - ) - if part is not None - ) - - def table_sql(self, expression: exp.Table, sep: str = " AS ") -> str: - table = self.table_parts(expression) - only = "ONLY " if expression.args.get("only") else "" - partition = self.sql(expression, "partition") - partition = f" {partition}" if partition else "" - version = self.sql(expression, "version") - version = f" {version}" if version else "" - alias = self.sql(expression, "alias") - alias = f"{sep}{alias}" if alias else "" - - sample = self.sql(expression, "sample") - if self.dialect.ALIAS_POST_TABLESAMPLE: - sample_pre_alias = sample - sample_post_alias = "" - else: - sample_pre_alias = "" - sample_post_alias = sample - - hints = self.expressions(expression, key="hints", sep=" ") - hints = f" {hints}" if hints and self.TABLE_HINTS else "" - pivots = self.expressions(expression, key="pivots", sep="", flat=True) - joins = self.indent( - self.expressions(expression, key="joins", sep="", flat=True), - skip_first=True, - ) - laterals = self.expressions(expression, key="laterals", sep="") - - file_format = self.sql(expression, "format") - if file_format: - pattern = self.sql(expression, "pattern") - pattern = f", PATTERN => {pattern}" if pattern else "" - file_format = f" (FILE_FORMAT => {file_format}{pattern})" - - ordinality = expression.args.get("ordinality") or "" - if ordinality: - ordinality = f" WITH ORDINALITY{alias}" - alias = "" - - when = self.sql(expression, "when") - if when: - table = f"{table} {when}" - - changes = self.sql(expression, "changes") - changes = f" {changes}" if changes else "" - - rows_from = self.expressions(expression, key="rows_from") - if rows_from: - table = f"ROWS FROM {self.wrap(rows_from)}" - - indexed = expression.args.get("indexed") - if indexed is not None: - indexed = f" INDEXED BY {self.sql(indexed)}" if indexed else " NOT INDEXED" - else: - indexed = "" - - # Workaround https://github.com/tobymao/sqlglot/issues/7073 - return f"{only}{table}{changes}{alias}{partition}{version}{file_format}{sample_pre_alias}{indexed}{hints}{pivots}{sample_post_alias}{joins}{laterals}{ordinality}" - - def tablefromrows_sql(self, expression: exp.TableFromRows) -> str: - table = self.func("TABLE", expression.this) - alias = self.sql(expression, "alias") - alias = f" AS {alias}" if alias else "" - sample = self.sql(expression, "sample") - pivots = self.expressions(expression, key="pivots", sep="", flat=True) - joins = self.indent( - self.expressions(expression, key="joins", sep="", flat=True), - skip_first=True, - ) - return f"{table}{alias}{pivots}{sample}{joins}" - - def tablesample_sql( - self, - expression: exp.TableSample, - tablesample_keyword: t.Optional[str] = None, - ) -> str: - method = self.sql(expression, "method") - method = f"{method} " if method and self.TABLESAMPLE_WITH_METHOD else "" - numerator = self.sql(expression, "bucket_numerator") - denominator = self.sql(expression, "bucket_denominator") - field = self.sql(expression, "bucket_field") - field = f" ON {field}" if field else "" - bucket = f"BUCKET {numerator} OUT OF {denominator}{field}" if numerator else "" - seed = self.sql(expression, "seed") - seed = f" {self.TABLESAMPLE_SEED_KEYWORD} ({seed})" if seed else "" - - size = self.sql(expression, "size") - if size and self.TABLESAMPLE_SIZE_IS_ROWS: - size = f"{size} ROWS" - - percent = self.sql(expression, "percent") - if percent and not self.dialect.TABLESAMPLE_SIZE_IS_PERCENT: - percent = f"{percent} PERCENT" - - expr = f"{bucket}{percent}{size}" - if self.TABLESAMPLE_REQUIRES_PARENS: - expr = f"({expr})" - - return ( - f" {tablesample_keyword or self.TABLESAMPLE_KEYWORDS} {method}{expr}{seed}" - ) - - def pivot_sql(self, expression: exp.Pivot) -> str: - expressions = self.expressions(expression, flat=True) - direction = "UNPIVOT" if expression.unpivot else "PIVOT" - - group = self.sql(expression, "group") - - if expression.this: - this = self.sql(expression, "this") - if not expressions: - sql = f"UNPIVOT {this}" - else: - on = f"{self.seg('ON')} {expressions}" - into = self.sql(expression, "into") - into = f"{self.seg('INTO')} {into}" if into else "" - using = self.expressions(expression, key="using", flat=True) - using = f"{self.seg('USING')} {using}" if using else "" - sql = f"{direction} {this}{on}{into}{using}{group}" - return self.prepend_ctes(expression, sql) - - alias = self.sql(expression, "alias") - alias = f" AS {alias}" if alias else "" - - fields = self.expressions( - expression, - "fields", - sep=" ", - dynamic=True, - new_line=True, - skip_first=True, - skip_last=True, - ) - - include_nulls = expression.args.get("include_nulls") - if include_nulls is not None: - nulls = " INCLUDE NULLS " if include_nulls else " EXCLUDE NULLS " - else: - nulls = "" - - default_on_null = self.sql(expression, "default_on_null") - default_on_null = ( - f" DEFAULT ON NULL ({default_on_null})" if default_on_null else "" - ) - sql = f"{self.seg(direction)}{nulls}({expressions} FOR {fields}{default_on_null}{group}){alias}" - return self.prepend_ctes(expression, sql) - - def version_sql(self, expression: exp.Version) -> str: - this = f"FOR {expression.name}" - kind = expression.text("kind") - expr = self.sql(expression, "expression") - return f"{this} {kind} {expr}" - - def tuple_sql(self, expression: exp.Tuple) -> str: - return f"({self.expressions(expression, dynamic=True, new_line=True, skip_first=True, skip_last=True)})" - - def update_sql(self, expression: exp.Update) -> str: - this = self.sql(expression, "this") - set_sql = self.expressions(expression, flat=True) - from_sql = self.sql(expression, "from_") - where_sql = self.sql(expression, "where") - returning = self.sql(expression, "returning") - order = self.sql(expression, "order") - limit = self.sql(expression, "limit") - if self.RETURNING_END: - expression_sql = f"{from_sql}{where_sql}{returning}" - else: - expression_sql = f"{returning}{from_sql}{where_sql}" - options = self.expressions(expression, key="options") - options = f" OPTION({options})" if options else "" - sql = f"UPDATE {this} SET {set_sql}{expression_sql}{order}{limit}{options}" - return self.prepend_ctes(expression, sql) - - def values_sql(self, expression: exp.Values, values_as_table: bool = True) -> str: - values_as_table = values_as_table and self.VALUES_AS_TABLE - - # The VALUES clause is still valid in an `INSERT INTO ..` statement, for example - if values_as_table or not expression.find_ancestor(exp.From, exp.Join): - args = self.expressions(expression) - alias = self.sql(expression, "alias") - values = f"VALUES{self.seg('')}{args}" - values = ( - f"({values})" - if self.WRAP_DERIVED_VALUES - and (alias or isinstance(expression.parent, (exp.From, exp.Table))) - else values - ) - values = self.query_modifiers(expression, values) - return f"{values} AS {alias}" if alias else values - - # Converts `VALUES...` expression into a series of select unions. - alias_node = expression.args.get("alias") - column_names = alias_node and alias_node.columns - - selects: t.List[exp.Query] = [] - - for i, tup in enumerate(expression.expressions): - row = tup.expressions - - if i == 0 and column_names: - row = [ - exp.alias_(value, column_name) - for value, column_name in zip(row, column_names) - ] - - selects.append(exp.Select(expressions=row)) - - if self.pretty: - # This may result in poor performance for large-cardinality `VALUES` tables, due to - # the deep nesting of the resulting exp.Unions. If this is a problem, either increase - # `sys.setrecursionlimit` to avoid RecursionErrors, or don't set `pretty`. - query = reduce( - lambda x, y: exp.union(x, y, distinct=False, copy=False), selects - ) - return self.subquery_sql( - query.subquery(alias_node and alias_node.this, copy=False) - ) - - alias = f" AS {self.sql(alias_node, 'this')}" if alias_node else "" - unions = " UNION ALL ".join(self.sql(select) for select in selects) - return f"({unions}){alias}" - - def var_sql(self, expression: exp.Var) -> str: - return self.sql(expression, "this") - - @unsupported_args("expressions") - def into_sql(self, expression: exp.Into) -> str: - temporary = " TEMPORARY" if expression.args.get("temporary") else "" - unlogged = " UNLOGGED" if expression.args.get("unlogged") else "" - return ( - f"{self.seg('INTO')}{temporary or unlogged} {self.sql(expression, 'this')}" - ) - - def from_sql(self, expression: exp.From) -> str: - return f"{self.seg('FROM')} {self.sql(expression, 'this')}" - - def groupingsets_sql(self, expression: exp.GroupingSets) -> str: - grouping_sets = self.expressions(expression, indent=False) - return f"GROUPING SETS {self.wrap(grouping_sets)}" - - def rollup_sql(self, expression: exp.Rollup) -> str: - expressions = self.expressions(expression, indent=False) - return f"ROLLUP {self.wrap(expressions)}" if expressions else "WITH ROLLUP" - - def cube_sql(self, expression: exp.Cube) -> str: - expressions = self.expressions(expression, indent=False) - return f"CUBE {self.wrap(expressions)}" if expressions else "WITH CUBE" - - def group_sql(self, expression: exp.Group) -> str: - group_by_all = expression.args.get("all") - if group_by_all is True: - modifier = " ALL" - elif group_by_all is False: - modifier = " DISTINCT" - else: - modifier = "" - - group_by = self.op_expressions(f"GROUP BY{modifier}", expression) - - grouping_sets = self.expressions(expression, key="grouping_sets") - cube = self.expressions(expression, key="cube") - rollup = self.expressions(expression, key="rollup") - - groupings = csv( - self.seg(grouping_sets) if grouping_sets else "", - self.seg(cube) if cube else "", - self.seg(rollup) if rollup else "", - self.seg("WITH TOTALS") if expression.args.get("totals") else "", - sep=self.GROUPINGS_SEP, - ) - - if ( - expression.expressions - and groupings - and groupings.strip() not in ("WITH CUBE", "WITH ROLLUP") - ): - group_by = f"{group_by}{self.GROUPINGS_SEP}" - - return f"{group_by}{groupings}" - - def having_sql(self, expression: exp.Having) -> str: - this = self.indent(self.sql(expression, "this")) - return f"{self.seg('HAVING')}{self.sep()}{this}" - - def connect_sql(self, expression: exp.Connect) -> str: - start = self.sql(expression, "start") - start = self.seg(f"START WITH {start}") if start else "" - nocycle = " NOCYCLE" if expression.args.get("nocycle") else "" - connect = self.sql(expression, "connect") - connect = self.seg(f"CONNECT BY{nocycle} {connect}") - return start + connect - - def prior_sql(self, expression: exp.Prior) -> str: - return f"PRIOR {self.sql(expression, 'this')}" - - def join_sql(self, expression: exp.Join) -> str: - if not self.SEMI_ANTI_JOIN_WITH_SIDE and expression.kind in ("SEMI", "ANTI"): - side = None - else: - side = expression.side - - op_sql = " ".join( - op - for op in ( - expression.method, - "GLOBAL" if expression.args.get("global_") else None, - side, - expression.kind, - expression.hint if self.JOIN_HINTS else None, - ) - if op - ) - match_cond = self.sql(expression, "match_condition") - match_cond = f" MATCH_CONDITION ({match_cond})" if match_cond else "" - on_sql = self.sql(expression, "on") - using = expression.args.get("using") - - if not on_sql and using: - on_sql = csv(*(self.sql(column) for column in using)) - - this = expression.this - this_sql = self.sql(this) - - exprs = self.expressions(expression) - if exprs: - this_sql = f"{this_sql},{self.seg(exprs)}" - - if on_sql: - on_sql = self.indent(on_sql, skip_first=True) - space = self.seg(" " * self.pad) if self.pretty else " " - if using: - on_sql = f"{space}USING ({on_sql})" - else: - on_sql = f"{space}ON {on_sql}" - elif not op_sql: - if ( - isinstance(this, exp.Lateral) - and this.args.get("cross_apply") is not None - ): - return f" {this_sql}" - - return f", {this_sql}" - - if op_sql != "STRAIGHT_JOIN": - op_sql = f"{op_sql} JOIN" if op_sql else "JOIN" - - pivots = self.expressions(expression, key="pivots", sep="", flat=True) - return f"{self.seg(op_sql)} {this_sql}{match_cond}{on_sql}{pivots}" - - def lambda_sql( - self, expression: exp.Lambda, arrow_sep: str = "->", wrap: bool = True - ) -> str: - args = self.expressions(expression, flat=True) - args = f"({args})" if wrap and len(args.split(",")) > 1 else args - return f"{args} {arrow_sep} {self.sql(expression, 'this')}" - - def lateral_op(self, expression: exp.Lateral) -> str: - cross_apply = expression.args.get("cross_apply") - - # https://www.mssqltips.com/sqlservertip/1958/sql-server-cross-apply-and-outer-apply/ - if cross_apply is True: - op = "INNER JOIN " - elif cross_apply is False: - op = "LEFT JOIN " - else: - op = "" - - return f"{op}LATERAL" - - def lateral_sql(self, expression: exp.Lateral) -> str: - this = self.sql(expression, "this") - - if expression.args.get("view"): - alias = expression.args["alias"] - columns = self.expressions(alias, key="columns", flat=True) - table = f" {alias.name}" if alias.name else "" - columns = f" AS {columns}" if columns else "" - op_sql = self.seg( - f"LATERAL VIEW{' OUTER' if expression.args.get('outer') else ''}" - ) - return f"{op_sql}{self.sep()}{this}{table}{columns}" - - alias = self.sql(expression, "alias") - alias = f" AS {alias}" if alias else "" - - ordinality = expression.args.get("ordinality") or "" - if ordinality: - ordinality = f" WITH ORDINALITY{alias}" - alias = "" - - return f"{self.lateral_op(expression)} {this}{alias}{ordinality}" - - def limit_sql(self, expression: exp.Limit, top: bool = False) -> str: - this = self.sql(expression, "this") - - args = [ - self._simplify_unless_literal(e) if self.LIMIT_ONLY_LITERALS else e - for e in (expression.args.get(k) for k in ("offset", "expression")) - if e - ] - - args_sql = ", ".join(self.sql(e) for e in args) - args_sql = ( - f"({args_sql})" if top and any(not e.is_number for e in args) else args_sql - ) - expressions = self.expressions(expression, flat=True) - limit_options = self.sql(expression, "limit_options") - expressions = f" BY {expressions}" if expressions else "" - - return f"{this}{self.seg('TOP' if top else 'LIMIT')} {args_sql}{limit_options}{expressions}" - - def offset_sql(self, expression: exp.Offset) -> str: - this = self.sql(expression, "this") - value = expression.expression - value = ( - self._simplify_unless_literal(value) if self.LIMIT_ONLY_LITERALS else value - ) - expressions = self.expressions(expression, flat=True) - expressions = f" BY {expressions}" if expressions else "" - return f"{this}{self.seg('OFFSET')} {self.sql(value)}{expressions}" - - def setitem_sql(self, expression: exp.SetItem) -> str: - kind = self.sql(expression, "kind") - if not self.SET_ASSIGNMENT_REQUIRES_VARIABLE_KEYWORD and kind == "VARIABLE": - kind = "" - else: - kind = f"{kind} " if kind else "" - this = self.sql(expression, "this") - expressions = self.expressions(expression) - collate = self.sql(expression, "collate") - collate = f" COLLATE {collate}" if collate else "" - global_ = "GLOBAL " if expression.args.get("global_") else "" - return f"{global_}{kind}{this}{expressions}{collate}" - - def set_sql(self, expression: exp.Set) -> str: - expressions = f" {self.expressions(expression, flat=True)}" - tag = " TAG" if expression.args.get("tag") else "" - return f"{'UNSET' if expression.args.get('unset') else 'SET'}{tag}{expressions}" - - def queryband_sql(self, expression: exp.QueryBand) -> str: - this = self.sql(expression, "this") - update = " UPDATE" if expression.args.get("update") else "" - scope = self.sql(expression, "scope") - scope = f" FOR {scope}" if scope else "" - - return f"QUERY_BAND = {this}{update}{scope}" - - def pragma_sql(self, expression: exp.Pragma) -> str: - return f"PRAGMA {self.sql(expression, 'this')}" - - def lock_sql(self, expression: exp.Lock) -> str: - if not self.LOCKING_READS_SUPPORTED: - self.unsupported("Locking reads using 'FOR UPDATE/SHARE' are not supported") - return "" - - update = expression.args["update"] - key = expression.args.get("key") - if update: - lock_type = "FOR NO KEY UPDATE" if key else "FOR UPDATE" - else: - lock_type = "FOR KEY SHARE" if key else "FOR SHARE" - expressions = self.expressions(expression, flat=True) - expressions = f" OF {expressions}" if expressions else "" - wait = expression.args.get("wait") - - if wait is not None: - if isinstance(wait, exp.Literal): - wait = f" WAIT {self.sql(wait)}" - else: - wait = " NOWAIT" if wait else " SKIP LOCKED" - - return f"{lock_type}{expressions}{wait or ''}" - - def literal_sql(self, expression: exp.Literal) -> str: - text = expression.this or "" - if expression.is_string: - text = f"{self.dialect.QUOTE_START}{self.escape_str(text)}{self.dialect.QUOTE_END}" - return text - - def escape_str( - self, - text: str, - escape_backslash: bool = True, - delimiter: t.Optional[str] = None, - escaped_delimiter: t.Optional[str] = None, - ) -> str: - if self.dialect.ESCAPED_SEQUENCES: - to_escaped = self.dialect.ESCAPED_SEQUENCES - text = "".join( - to_escaped.get(ch, ch) if escape_backslash or ch != "\\" else ch - for ch in text - ) - - delimiter = delimiter or self.dialect.QUOTE_END - escaped_delimiter = escaped_delimiter or self._escaped_quote_end - - return self._replace_line_breaks(text).replace(delimiter, escaped_delimiter) - - def loaddata_sql(self, expression: exp.LoadData) -> str: - local = " LOCAL" if expression.args.get("local") else "" - inpath = f" INPATH {self.sql(expression, 'inpath')}" - overwrite = " OVERWRITE" if expression.args.get("overwrite") else "" - this = f" INTO TABLE {self.sql(expression, 'this')}" - partition = self.sql(expression, "partition") - partition = f" {partition}" if partition else "" - input_format = self.sql(expression, "input_format") - input_format = f" INPUTFORMAT {input_format}" if input_format else "" - serde = self.sql(expression, "serde") - serde = f" SERDE {serde}" if serde else "" - return ( - f"LOAD DATA{local}{inpath}{overwrite}{this}{partition}{input_format}{serde}" - ) - - def null_sql(self, *_) -> str: - return "NULL" - - def boolean_sql(self, expression: exp.Boolean) -> str: - return "TRUE" if expression.this else "FALSE" - - def booland_sql(self, expression: exp.Booland) -> str: - return f"(({self.sql(expression, 'this')}) AND ({self.sql(expression, 'expression')}))" - - def boolor_sql(self, expression: exp.Boolor) -> str: - return f"(({self.sql(expression, 'this')}) OR ({self.sql(expression, 'expression')}))" - - def order_sql(self, expression: exp.Order, flat: bool = False) -> str: - this = self.sql(expression, "this") - this = f"{this} " if this else this - siblings = "SIBLINGS " if expression.args.get("siblings") else "" - return self.op_expressions( - f"{this}ORDER {siblings}BY", expression, flat=this or flat - ) # type: ignore - - def withfill_sql(self, expression: exp.WithFill) -> str: - from_sql = self.sql(expression, "from_") - from_sql = f" FROM {from_sql}" if from_sql else "" - to_sql = self.sql(expression, "to") - to_sql = f" TO {to_sql}" if to_sql else "" - step_sql = self.sql(expression, "step") - step_sql = f" STEP {step_sql}" if step_sql else "" - interpolated_values = [ - f"{self.sql(e, 'alias')} AS {self.sql(e, 'this')}" - if isinstance(e, exp.Alias) - else self.sql(e, "this") - for e in expression.args.get("interpolate") or [] - ] - interpolate = ( - f" INTERPOLATE ({', '.join(interpolated_values)})" - if interpolated_values - else "" - ) - return f"WITH FILL{from_sql}{to_sql}{step_sql}{interpolate}" - - def cluster_sql(self, expression: exp.Cluster) -> str: - return self.op_expressions("CLUSTER BY", expression) - - def distribute_sql(self, expression: exp.Distribute) -> str: - return self.op_expressions("DISTRIBUTE BY", expression) - - def sort_sql(self, expression: exp.Sort) -> str: - return self.op_expressions("SORT BY", expression) - - def ordered_sql(self, expression: exp.Ordered) -> str: - desc = expression.args.get("desc") - asc = not desc - - nulls_first = expression.args.get("nulls_first") - nulls_last = not nulls_first - nulls_are_large = self.dialect.NULL_ORDERING == "nulls_are_large" - nulls_are_small = self.dialect.NULL_ORDERING == "nulls_are_small" - nulls_are_last = self.dialect.NULL_ORDERING == "nulls_are_last" - - this = self.sql(expression, "this") - - sort_order = " DESC" if desc else (" ASC" if desc is False else "") - nulls_sort_change = "" - if nulls_first and ( - (asc and nulls_are_large) or (desc and nulls_are_small) or nulls_are_last - ): - nulls_sort_change = " NULLS FIRST" - elif ( - nulls_last - and ((asc and nulls_are_small) or (desc and nulls_are_large)) - and not nulls_are_last - ): - nulls_sort_change = " NULLS LAST" - - # If the NULLS FIRST/LAST clause is unsupported, we add another sort key to simulate it - if nulls_sort_change and not self.NULL_ORDERING_SUPPORTED: - window = expression.find_ancestor(exp.Window, exp.Select) - if isinstance(window, exp.Window) and window.args.get("spec"): - self.unsupported( - f"'{nulls_sort_change.strip()}' translation not supported in window functions" - ) - nulls_sort_change = "" - elif self.NULL_ORDERING_SUPPORTED is False and ( - (asc and nulls_sort_change == " NULLS LAST") - or (desc and nulls_sort_change == " NULLS FIRST") - ): - # BigQuery does not allow these ordering/nulls combinations when used under - # an aggregation func or under a window containing one - ancestor = expression.find_ancestor(exp.AggFunc, exp.Window, exp.Select) - - if isinstance(ancestor, exp.Window): - ancestor = ancestor.this - if isinstance(ancestor, exp.AggFunc): - self.unsupported( - f"'{nulls_sort_change.strip()}' translation not supported for aggregate functions with {sort_order} sort order" - ) - nulls_sort_change = "" - elif self.NULL_ORDERING_SUPPORTED is None: - if expression.this.is_int: - self.unsupported( - f"'{nulls_sort_change.strip()}' translation not supported with positional ordering" - ) - elif not isinstance(expression.this, exp.Rand): - null_sort_order = ( - " DESC" if nulls_sort_change == " NULLS FIRST" else "" - ) - this = f"CASE WHEN {this} IS NULL THEN 1 ELSE 0 END{null_sort_order}, {this}" - nulls_sort_change = "" - - with_fill = self.sql(expression, "with_fill") - with_fill = f" {with_fill}" if with_fill else "" - - return f"{this}{sort_order}{nulls_sort_change}{with_fill}" - - def matchrecognizemeasure_sql(self, expression: exp.MatchRecognizeMeasure) -> str: - window_frame = self.sql(expression, "window_frame") - window_frame = f"{window_frame} " if window_frame else "" - - this = self.sql(expression, "this") - - return f"{window_frame}{this}" - - def matchrecognize_sql(self, expression: exp.MatchRecognize) -> str: - partition = self.partition_by_sql(expression) - order = self.sql(expression, "order") - measures = self.expressions(expression, key="measures") - measures = self.seg(f"MEASURES{self.seg(measures)}") if measures else "" - rows = self.sql(expression, "rows") - rows = self.seg(rows) if rows else "" - after = self.sql(expression, "after") - after = self.seg(after) if after else "" - pattern = self.sql(expression, "pattern") - pattern = self.seg(f"PATTERN ({pattern})") if pattern else "" - definition_sqls = [ - f"{self.sql(definition, 'alias')} AS {self.sql(definition, 'this')}" - for definition in expression.args.get("define", []) - ] - definitions = self.expressions(sqls=definition_sqls) - define = self.seg(f"DEFINE{self.seg(definitions)}") if definitions else "" - body = "".join( - ( - partition, - order, - measures, - rows, - after, - pattern, - define, - ) - ) - alias = self.sql(expression, "alias") - alias = f" {alias}" if alias else "" - return f"{self.seg('MATCH_RECOGNIZE')} {self.wrap(body)}{alias}" - - def query_modifiers(self, expression: exp.Expression, *sqls: str) -> str: - limit = expression.args.get("limit") - - if self.LIMIT_FETCH == "LIMIT" and isinstance(limit, exp.Fetch): - limit = exp.Limit(expression=exp.maybe_copy(limit.args.get("count"))) - elif self.LIMIT_FETCH == "FETCH" and isinstance(limit, exp.Limit): - limit = exp.Fetch(direction="FIRST", count=exp.maybe_copy(limit.expression)) - - return csv( - *sqls, - *[self.sql(join) for join in expression.args.get("joins") or []], - self.sql(expression, "match"), - *[self.sql(lateral) for lateral in expression.args.get("laterals") or []], - self.sql(expression, "prewhere"), - self.sql(expression, "where"), - self.sql(expression, "connect"), - self.sql(expression, "group"), - self.sql(expression, "having"), - *[ - gen(self, expression) - for gen in self.AFTER_HAVING_MODIFIER_TRANSFORMS.values() - ], - self.sql(expression, "order"), - *self.offset_limit_modifiers( - expression, isinstance(limit, exp.Fetch), limit - ), - *self.after_limit_modifiers(expression), - self.options_modifier(expression), - self.for_modifiers(expression), - sep="", - ) - - def options_modifier(self, expression: exp.Expression) -> str: - options = self.expressions(expression, key="options") - return f" {options}" if options else "" - - def for_modifiers(self, expression: exp.Expression) -> str: - for_modifiers = self.expressions(expression, key="for_") - return f"{self.sep()}FOR XML{self.seg(for_modifiers)}" if for_modifiers else "" - - def queryoption_sql(self, expression: exp.QueryOption) -> str: - self.unsupported("Unsupported query option.") - return "" - - def offset_limit_modifiers( - self, - expression: exp.Expression, - fetch: bool, - limit: t.Optional[exp.Fetch | exp.Limit], - ) -> t.List[str]: - return [ - self.sql(expression, "offset") if fetch else self.sql(limit), - self.sql(limit) if fetch else self.sql(expression, "offset"), - ] - - def after_limit_modifiers(self, expression: exp.Expression) -> t.List[str]: - locks = self.expressions(expression, key="locks", sep=" ") - locks = f" {locks}" if locks else "" - return [locks, self.sql(expression, "sample")] - - def select_sql(self, expression: exp.Select) -> str: - into = expression.args.get("into") - if not self.SUPPORTS_SELECT_INTO and into: - into.pop() - - hint = self.sql(expression, "hint") - distinct = self.sql(expression, "distinct") - distinct = f" {distinct}" if distinct else "" - kind = self.sql(expression, "kind") - - limit = expression.args.get("limit") - if isinstance(limit, exp.Limit) and self.LIMIT_IS_TOP: - top = self.limit_sql(limit, top=True) - limit.pop() - else: - top = "" - - expressions = self.expressions(expression) - - if kind: - if kind in self.SELECT_KINDS: - kind = f" AS {kind}" - else: - if kind == "STRUCT": - expressions = self.expressions( - sqls=[ - self.sql( - exp.Struct( - expressions=[ - exp.PropertyEQ( - this=e.args.get("alias"), expression=e.this - ) - if isinstance(e, exp.Alias) - else e - for e in expression.expressions - ] - ) - ) - ] - ) - kind = "" - - operation_modifiers = self.expressions( - expression, key="operation_modifiers", sep=" " - ) - operation_modifiers = ( - f"{self.sep()}{operation_modifiers}" if operation_modifiers else "" - ) - - # We use LIMIT_IS_TOP as a proxy for whether DISTINCT should go first because tsql and Teradata - # are the only dialects that use LIMIT_IS_TOP and both place DISTINCT first. - top_distinct = ( - f"{distinct}{hint}{top}" if self.LIMIT_IS_TOP else f"{top}{hint}{distinct}" - ) - expressions = f"{self.sep()}{expressions}" if expressions else expressions - sql = self.query_modifiers( - expression, - f"SELECT{top_distinct}{operation_modifiers}{kind}{expressions}", - self.sql(expression, "into", comment=False), - self.sql(expression, "from_", comment=False), - ) - - # If both the CTE and SELECT clauses have comments, generate the latter earlier - if expression.args.get("with_"): - sql = self.maybe_comment(sql, expression) - expression.pop_comments() - - sql = self.prepend_ctes(expression, sql) - - if not self.SUPPORTS_SELECT_INTO and into: - if into.args.get("temporary"): - table_kind = " TEMPORARY" - elif self.SUPPORTS_UNLOGGED_TABLES and into.args.get("unlogged"): - table_kind = " UNLOGGED" - else: - table_kind = "" - sql = f"CREATE{table_kind} TABLE {self.sql(into.this)} AS {sql}" - - return sql - - def schema_sql(self, expression: exp.Schema) -> str: - this = self.sql(expression, "this") - sql = self.schema_columns_sql(expression) - return f"{this} {sql}" if this and sql else this or sql - - def schema_columns_sql(self, expression: exp.Schema) -> str: - if expression.expressions: - return ( - f"({self.sep('')}{self.expressions(expression)}{self.seg(')', sep='')}" - ) - return "" - - def star_sql(self, expression: exp.Star) -> str: - except_ = self.expressions(expression, key="except_", flat=True) - except_ = f"{self.seg(self.STAR_EXCEPT)} ({except_})" if except_ else "" - replace = self.expressions(expression, key="replace", flat=True) - replace = f"{self.seg('REPLACE')} ({replace})" if replace else "" - rename = self.expressions(expression, key="rename", flat=True) - rename = f"{self.seg('RENAME')} ({rename})" if rename else "" - return f"*{except_}{replace}{rename}" - - def parameter_sql(self, expression: exp.Parameter) -> str: - this = self.sql(expression, "this") - return f"{self.PARAMETER_TOKEN}{this}" - - def sessionparameter_sql(self, expression: exp.SessionParameter) -> str: - this = self.sql(expression, "this") - kind = expression.text("kind") - if kind: - kind = f"{kind}." - return f"@@{kind}{this}" - - def placeholder_sql(self, expression: exp.Placeholder) -> str: - return ( - f"{self.NAMED_PLACEHOLDER_TOKEN}{expression.name}" - if expression.this - else "?" - ) - - def subquery_sql(self, expression: exp.Subquery, sep: str = " AS ") -> str: - alias = self.sql(expression, "alias") - alias = f"{sep}{alias}" if alias else "" - sample = self.sql(expression, "sample") - if self.dialect.ALIAS_POST_TABLESAMPLE and sample: - alias = f"{sample}{alias}" - - # Set to None so it's not generated again by self.query_modifiers() - expression.set("sample", None) - - pivots = self.expressions(expression, key="pivots", sep="", flat=True) - sql = self.query_modifiers(expression, self.wrap(expression), alias, pivots) - return self.prepend_ctes(expression, sql) - - def qualify_sql(self, expression: exp.Qualify) -> str: - this = self.indent(self.sql(expression, "this")) - return f"{self.seg('QUALIFY')}{self.sep()}{this}" - - def unnest_sql(self, expression: exp.Unnest) -> str: - args = self.expressions(expression, flat=True) - - alias = expression.args.get("alias") - offset = expression.args.get("offset") - - if self.UNNEST_WITH_ORDINALITY: - if alias and isinstance(offset, exp.Expression): - alias.append("columns", offset) - - if alias and self.dialect.UNNEST_COLUMN_ONLY: - columns = alias.columns - alias = self.sql(columns[0]) if columns else "" - else: - alias = self.sql(alias) - - alias = f" AS {alias}" if alias else alias - if self.UNNEST_WITH_ORDINALITY: - suffix = f" WITH ORDINALITY{alias}" if offset else alias - else: - if isinstance(offset, exp.Expression): - suffix = f"{alias} WITH OFFSET AS {self.sql(offset)}" - elif offset: - suffix = f"{alias} WITH OFFSET" - else: - suffix = alias - - return f"UNNEST({args}){suffix}" - - def prewhere_sql(self, expression: exp.PreWhere) -> str: - return "" - - def where_sql(self, expression: exp.Where) -> str: - this = self.indent(self.sql(expression, "this")) - return f"{self.seg('WHERE')}{self.sep()}{this}" - - def window_sql(self, expression: exp.Window) -> str: - this = self.sql(expression, "this") - partition = self.partition_by_sql(expression) - order = expression.args.get("order") - order = self.order_sql(order, flat=True) if order else "" - spec = self.sql(expression, "spec") - alias = self.sql(expression, "alias") - over = self.sql(expression, "over") or "OVER" - - this = f"{this} {'AS' if expression.arg_key == 'windows' else over}" - - first = expression.args.get("first") - if first is None: - first = "" - else: - first = "FIRST" if first else "LAST" - - if not partition and not order and not spec and alias: - return f"{this} {alias}" - - args = self.format_args( - *[arg for arg in (alias, first, partition, order, spec) if arg], sep=" " - ) - return f"{this} ({args})" - - def partition_by_sql(self, expression: exp.Window | exp.MatchRecognize) -> str: - partition = self.expressions(expression, key="partition_by", flat=True) - return f"PARTITION BY {partition}" if partition else "" - - def windowspec_sql(self, expression: exp.WindowSpec) -> str: - kind = self.sql(expression, "kind") - start = csv( - self.sql(expression, "start"), self.sql(expression, "start_side"), sep=" " - ) - end = ( - csv(self.sql(expression, "end"), self.sql(expression, "end_side"), sep=" ") - or "CURRENT ROW" - ) - - window_spec = f"{kind} BETWEEN {start} AND {end}" - - exclude = self.sql(expression, "exclude") - if exclude: - if self.SUPPORTS_WINDOW_EXCLUDE: - window_spec += f" EXCLUDE {exclude}" - else: - self.unsupported("EXCLUDE clause is not supported in the WINDOW clause") - - return window_spec - - def withingroup_sql(self, expression: exp.WithinGroup) -> str: - this = self.sql(expression, "this") - expression_sql = self.sql(expression, "expression")[ - 1: - ] # order has a leading space - return f"{this} WITHIN GROUP ({expression_sql})" - - def between_sql(self, expression: exp.Between) -> str: - this = self.sql(expression, "this") - low = self.sql(expression, "low") - high = self.sql(expression, "high") - symmetric = expression.args.get("symmetric") - - if symmetric and not self.SUPPORTS_BETWEEN_FLAGS: - return ( - f"({this} BETWEEN {low} AND {high} OR {this} BETWEEN {high} AND {low})" - ) - - flag = ( - " SYMMETRIC" - if symmetric - else " ASYMMETRIC" - if symmetric is False and self.SUPPORTS_BETWEEN_FLAGS - else "" # silently drop ASYMMETRIC – semantics identical - ) - return f"{this} BETWEEN{flag} {low} AND {high}" - - def bracket_offset_expressions( - self, expression: exp.Bracket, index_offset: t.Optional[int] = None - ) -> t.List[exp.Expression]: - return apply_index_offset( - expression.this, - expression.expressions, - (index_offset or self.dialect.INDEX_OFFSET) - - expression.args.get("offset", 0), - dialect=self.dialect, - ) - - def bracket_sql(self, expression: exp.Bracket) -> str: - expressions = self.bracket_offset_expressions(expression) - expressions_sql = ", ".join(self.sql(e) for e in expressions) - return f"{self.sql(expression, 'this')}[{expressions_sql}]" - - def all_sql(self, expression: exp.All) -> str: - this = self.sql(expression, "this") - if not isinstance(expression.this, (exp.Tuple, exp.Paren)): - this = self.wrap(this) - return f"ALL {this}" - - def any_sql(self, expression: exp.Any) -> str: - this = self.sql(expression, "this") - if isinstance(expression.this, (*exp.UNWRAPPED_QUERIES, exp.Paren)): - if isinstance(expression.this, exp.UNWRAPPED_QUERIES): - this = self.wrap(this) - return f"ANY{this}" - return f"ANY {this}" - - def exists_sql(self, expression: exp.Exists) -> str: - return f"EXISTS{self.wrap(expression)}" - - def case_sql(self, expression: exp.Case) -> str: - this = self.sql(expression, "this") - statements = [f"CASE {this}" if this else "CASE"] - - for e in expression.args["ifs"]: - statements.append(f"WHEN {self.sql(e, 'this')}") - statements.append(f"THEN {self.sql(e, 'true')}") - - default = self.sql(expression, "default") - - if default: - statements.append(f"ELSE {default}") - - statements.append("END") - - if self.pretty and self.too_wide(statements): - return self.indent("\n".join(statements), skip_first=True, skip_last=True) - - return " ".join(statements) - - def constraint_sql(self, expression: exp.Constraint) -> str: - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - return f"CONSTRAINT {this} {expressions}" - - def nextvaluefor_sql(self, expression: exp.NextValueFor) -> str: - order = expression.args.get("order") - order = f" OVER ({self.order_sql(order, flat=True)})" if order else "" - return f"NEXT VALUE FOR {self.sql(expression, 'this')}{order}" - - def extract_sql(self, expression: exp.Extract) -> str: - from bigframes_vendored.sqlglot.dialects.dialect import map_date_part - - this = ( - map_date_part(expression.this, self.dialect) - if self.NORMALIZE_EXTRACT_DATE_PARTS - else expression.this - ) - this_sql = self.sql(this) if self.EXTRACT_ALLOWS_QUOTES else this.name - expression_sql = self.sql(expression, "expression") - - return f"EXTRACT({this_sql} FROM {expression_sql})" - - def trim_sql(self, expression: exp.Trim) -> str: - trim_type = self.sql(expression, "position") - - if trim_type == "LEADING": - func_name = "LTRIM" - elif trim_type == "TRAILING": - func_name = "RTRIM" - else: - func_name = "TRIM" - - return self.func(func_name, expression.this, expression.expression) - - def convert_concat_args( - self, expression: exp.Concat | exp.ConcatWs - ) -> t.List[exp.Expression]: - args = expression.expressions - if isinstance(expression, exp.ConcatWs): - args = args[1:] # Skip the delimiter - - if self.dialect.STRICT_STRING_CONCAT and expression.args.get("safe"): - args = [exp.cast(e, exp.DataType.Type.TEXT) for e in args] - - if not self.dialect.CONCAT_COALESCE and expression.args.get("coalesce"): - - def _wrap_with_coalesce(e: exp.Expression) -> exp.Expression: - if not e.type: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - e = annotate_types(e, dialect=self.dialect) - - if e.is_string or e.is_type(exp.DataType.Type.ARRAY): - return e - - return exp.func("coalesce", e, exp.Literal.string("")) - - args = [_wrap_with_coalesce(e) for e in args] - - return args - - def concat_sql(self, expression: exp.Concat) -> str: - if self.dialect.CONCAT_COALESCE and not expression.args.get("coalesce"): - # Dialect's CONCAT function coalesces NULLs to empty strings, but the expression does not. - # Transpile to double pipe operators, which typically returns NULL if any args are NULL - # instead of coalescing them to empty string. - from bigframes_vendored.sqlglot.dialects.dialect import concat_to_dpipe_sql - - return concat_to_dpipe_sql(self, expression) - - expressions = self.convert_concat_args(expression) - - # Some dialects don't allow a single-argument CONCAT call - if not self.SUPPORTS_SINGLE_ARG_CONCAT and len(expressions) == 1: - return self.sql(expressions[0]) - - return self.func("CONCAT", *expressions) - - def concatws_sql(self, expression: exp.ConcatWs) -> str: - return self.func( - "CONCAT_WS", - seq_get(expression.expressions, 0), - *self.convert_concat_args(expression), - ) - - def check_sql(self, expression: exp.Check) -> str: - this = self.sql(expression, key="this") - return f"CHECK ({this})" - - def foreignkey_sql(self, expression: exp.ForeignKey) -> str: - expressions = self.expressions(expression, flat=True) - expressions = f" ({expressions})" if expressions else "" - reference = self.sql(expression, "reference") - reference = f" {reference}" if reference else "" - delete = self.sql(expression, "delete") - delete = f" ON DELETE {delete}" if delete else "" - update = self.sql(expression, "update") - update = f" ON UPDATE {update}" if update else "" - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - return f"FOREIGN KEY{expressions}{reference}{delete}{update}{options}" - - def primarykey_sql(self, expression: exp.PrimaryKey) -> str: - this = self.sql(expression, "this") - this = f" {this}" if this else "" - expressions = self.expressions(expression, flat=True) - include = self.sql(expression, "include") - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - return f"PRIMARY KEY{this} ({expressions}){include}{options}" - - def if_sql(self, expression: exp.If) -> str: - return self.case_sql( - exp.Case(ifs=[expression], default=expression.args.get("false")) - ) - - def matchagainst_sql(self, expression: exp.MatchAgainst) -> str: - if self.MATCH_AGAINST_TABLE_PREFIX: - expressions = [] - for expr in expression.expressions: - if isinstance(expr, exp.Table): - expressions.append(f"TABLE {self.sql(expr)}") - else: - expressions.append(expr) - else: - expressions = expression.expressions - - modifier = expression.args.get("modifier") - modifier = f" {modifier}" if modifier else "" - return f"{self.func('MATCH', *expressions)} AGAINST({self.sql(expression, 'this')}{modifier})" - - def jsonkeyvalue_sql(self, expression: exp.JSONKeyValue) -> str: - return f"{self.sql(expression, 'this')}{self.JSON_KEY_VALUE_PAIR_SEP} {self.sql(expression, 'expression')}" - - def jsonpath_sql(self, expression: exp.JSONPath) -> str: - path = self.expressions(expression, sep="", flat=True).lstrip(".") - - if expression.args.get("escape"): - path = self.escape_str(path) - - if self.QUOTE_JSON_PATH: - path = f"{self.dialect.QUOTE_START}{path}{self.dialect.QUOTE_END}" - - return path - - def json_path_part(self, expression: int | str | exp.JSONPathPart) -> str: - if isinstance(expression, exp.JSONPathPart): - transform = self.TRANSFORMS.get(expression.__class__) - if not callable(transform): - self.unsupported( - f"Unsupported JSONPathPart type {expression.__class__.__name__}" - ) - return "" - - return transform(self, expression) - - if isinstance(expression, int): - return str(expression) - - if ( - self._quote_json_path_key_using_brackets - and self.JSON_PATH_SINGLE_QUOTE_ESCAPE - ): - escaped = expression.replace("'", "\\'") - escaped = f"\\'{expression}\\'" - else: - escaped = expression.replace('"', '\\"') - escaped = f'"{escaped}"' - - return escaped - - def formatjson_sql(self, expression: exp.FormatJson) -> str: - return f"{self.sql(expression, 'this')} FORMAT JSON" - - def formatphrase_sql(self, expression: exp.FormatPhrase) -> str: - # Output the Teradata column FORMAT override. - # https://docs.teradata.com/r/Enterprise_IntelliFlex_VMware/SQL-Data-Types-and-Literals/Data-Type-Formats-and-Format-Phrases/FORMAT - this = self.sql(expression, "this") - fmt = self.sql(expression, "format") - return f"{this} (FORMAT {fmt})" - - def jsonobject_sql(self, expression: exp.JSONObject | exp.JSONObjectAgg) -> str: - null_handling = expression.args.get("null_handling") - null_handling = f" {null_handling}" if null_handling else "" - - unique_keys = expression.args.get("unique_keys") - if unique_keys is not None: - unique_keys = f" {'WITH' if unique_keys else 'WITHOUT'} UNIQUE KEYS" - else: - unique_keys = "" - - return_type = self.sql(expression, "return_type") - return_type = f" RETURNING {return_type}" if return_type else "" - encoding = self.sql(expression, "encoding") - encoding = f" ENCODING {encoding}" if encoding else "" - - return self.func( - "JSON_OBJECT" - if isinstance(expression, exp.JSONObject) - else "JSON_OBJECTAGG", - *expression.expressions, - suffix=f"{null_handling}{unique_keys}{return_type}{encoding})", - ) - - def jsonobjectagg_sql(self, expression: exp.JSONObjectAgg) -> str: - return self.jsonobject_sql(expression) - - def jsonarray_sql(self, expression: exp.JSONArray) -> str: - null_handling = expression.args.get("null_handling") - null_handling = f" {null_handling}" if null_handling else "" - return_type = self.sql(expression, "return_type") - return_type = f" RETURNING {return_type}" if return_type else "" - strict = " STRICT" if expression.args.get("strict") else "" - return self.func( - "JSON_ARRAY", - *expression.expressions, - suffix=f"{null_handling}{return_type}{strict})", - ) - - def jsonarrayagg_sql(self, expression: exp.JSONArrayAgg) -> str: - this = self.sql(expression, "this") - order = self.sql(expression, "order") - null_handling = expression.args.get("null_handling") - null_handling = f" {null_handling}" if null_handling else "" - return_type = self.sql(expression, "return_type") - return_type = f" RETURNING {return_type}" if return_type else "" - strict = " STRICT" if expression.args.get("strict") else "" - return self.func( - "JSON_ARRAYAGG", - this, - suffix=f"{order}{null_handling}{return_type}{strict})", - ) - - def jsoncolumndef_sql(self, expression: exp.JSONColumnDef) -> str: - path = self.sql(expression, "path") - path = f" PATH {path}" if path else "" - nested_schema = self.sql(expression, "nested_schema") - - if nested_schema: - return f"NESTED{path} {nested_schema}" - - this = self.sql(expression, "this") - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - - ordinality = " FOR ORDINALITY" if expression.args.get("ordinality") else "" - return f"{this}{kind}{path}{ordinality}" - - def jsonschema_sql(self, expression: exp.JSONSchema) -> str: - return self.func("COLUMNS", *expression.expressions) - - def jsontable_sql(self, expression: exp.JSONTable) -> str: - this = self.sql(expression, "this") - path = self.sql(expression, "path") - path = f", {path}" if path else "" - error_handling = expression.args.get("error_handling") - error_handling = f" {error_handling}" if error_handling else "" - empty_handling = expression.args.get("empty_handling") - empty_handling = f" {empty_handling}" if empty_handling else "" - schema = self.sql(expression, "schema") - return self.func( - "JSON_TABLE", - this, - suffix=f"{path}{error_handling}{empty_handling} {schema})", - ) - - def openjsoncolumndef_sql(self, expression: exp.OpenJSONColumnDef) -> str: - this = self.sql(expression, "this") - kind = self.sql(expression, "kind") - path = self.sql(expression, "path") - path = f" {path}" if path else "" - as_json = " AS JSON" if expression.args.get("as_json") else "" - return f"{this} {kind}{path}{as_json}" - - def openjson_sql(self, expression: exp.OpenJSON) -> str: - this = self.sql(expression, "this") - path = self.sql(expression, "path") - path = f", {path}" if path else "" - expressions = self.expressions(expression) - with_ = ( - f" WITH ({self.seg(self.indent(expressions), sep='')}{self.seg(')', sep='')}" - if expressions - else "" - ) - return f"OPENJSON({this}{path}){with_}" - - def in_sql(self, expression: exp.In) -> str: - query = expression.args.get("query") - unnest = expression.args.get("unnest") - field = expression.args.get("field") - is_global = " GLOBAL" if expression.args.get("is_global") else "" - - if query: - in_sql = self.sql(query) - elif unnest: - in_sql = self.in_unnest_op(unnest) - elif field: - in_sql = self.sql(field) - else: - in_sql = f"({self.expressions(expression, dynamic=True, new_line=True, skip_first=True, skip_last=True)})" - - return f"{self.sql(expression, 'this')}{is_global} IN {in_sql}" - - def in_unnest_op(self, unnest: exp.Unnest) -> str: - return f"(SELECT {self.sql(unnest)})" - - def interval_sql(self, expression: exp.Interval) -> str: - unit_expression = expression.args.get("unit") - unit = self.sql(unit_expression) if unit_expression else "" - if not self.INTERVAL_ALLOWS_PLURAL_FORM: - unit = self.TIME_PART_SINGULARS.get(unit, unit) - unit = f" {unit}" if unit else "" - - if self.SINGLE_STRING_INTERVAL: - this = expression.this.name if expression.this else "" - if this: - if unit_expression and isinstance(unit_expression, exp.IntervalSpan): - return f"INTERVAL '{this}'{unit}" - return f"INTERVAL '{this}{unit}'" - return f"INTERVAL{unit}" - - this = self.sql(expression, "this") - if this: - unwrapped = isinstance(expression.this, self.UNWRAPPED_INTERVAL_VALUES) - this = f" {this}" if unwrapped else f" ({this})" - - return f"INTERVAL{this}{unit}" - - def return_sql(self, expression: exp.Return) -> str: - return f"RETURN {self.sql(expression, 'this')}" - - def reference_sql(self, expression: exp.Reference) -> str: - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - expressions = f"({expressions})" if expressions else "" - options = self.expressions(expression, key="options", flat=True, sep=" ") - options = f" {options}" if options else "" - return f"REFERENCES {this}{expressions}{options}" - - def anonymous_sql(self, expression: exp.Anonymous) -> str: - # We don't normalize qualified functions such as a.b.foo(), because they can be case-sensitive - parent = expression.parent - is_qualified = isinstance(parent, exp.Dot) and expression is parent.expression - return self.func( - self.sql(expression, "this"), - *expression.expressions, - normalize=not is_qualified, - ) - - def paren_sql(self, expression: exp.Paren) -> str: - sql = self.seg(self.indent(self.sql(expression, "this")), sep="") - return f"({sql}{self.seg(')', sep='')}" - - def neg_sql(self, expression: exp.Neg) -> str: - # This makes sure we don't convert "- - 5" to "--5", which is a comment - this_sql = self.sql(expression, "this") - sep = " " if this_sql[0] == "-" else "" - return f"-{sep}{this_sql}" - - def not_sql(self, expression: exp.Not) -> str: - return f"NOT {self.sql(expression, 'this')}" - - def alias_sql(self, expression: exp.Alias) -> str: - alias = self.sql(expression, "alias") - alias = f" AS {alias}" if alias else "" - return f"{self.sql(expression, 'this')}{alias}" - - def pivotalias_sql(self, expression: exp.PivotAlias) -> str: - alias = expression.args["alias"] - - parent = expression.parent - pivot = parent and parent.parent - - if isinstance(pivot, exp.Pivot) and pivot.unpivot: - identifier_alias = isinstance(alias, exp.Identifier) - literal_alias = isinstance(alias, exp.Literal) - - if identifier_alias and not self.UNPIVOT_ALIASES_ARE_IDENTIFIERS: - alias.replace(exp.Literal.string(alias.output_name)) - elif ( - not identifier_alias - and literal_alias - and self.UNPIVOT_ALIASES_ARE_IDENTIFIERS - ): - alias.replace(exp.to_identifier(alias.output_name)) - - return self.alias_sql(expression) - - def aliases_sql(self, expression: exp.Aliases) -> str: - return f"{self.sql(expression, 'this')} AS ({self.expressions(expression, flat=True)})" - - def atindex_sql(self, expression: exp.AtTimeZone) -> str: - this = self.sql(expression, "this") - index = self.sql(expression, "expression") - return f"{this} AT {index}" - - def attimezone_sql(self, expression: exp.AtTimeZone) -> str: - this = self.sql(expression, "this") - zone = self.sql(expression, "zone") - return f"{this} AT TIME ZONE {zone}" - - def fromtimezone_sql(self, expression: exp.FromTimeZone) -> str: - this = self.sql(expression, "this") - zone = self.sql(expression, "zone") - return f"{this} AT TIME ZONE {zone} AT TIME ZONE 'UTC'" - - def add_sql(self, expression: exp.Add) -> str: - return self.binary(expression, "+") - - def and_sql( - self, - expression: exp.And, - stack: t.Optional[t.List[str | exp.Expression]] = None, - ) -> str: - return self.connector_sql(expression, "AND", stack) - - def or_sql( - self, expression: exp.Or, stack: t.Optional[t.List[str | exp.Expression]] = None - ) -> str: - return self.connector_sql(expression, "OR", stack) - - def xor_sql( - self, - expression: exp.Xor, - stack: t.Optional[t.List[str | exp.Expression]] = None, - ) -> str: - return self.connector_sql(expression, "XOR", stack) - - def connector_sql( - self, - expression: exp.Connector, - op: str, - stack: t.Optional[t.List[str | exp.Expression]] = None, - ) -> str: - if stack is not None: - if expression.expressions: - stack.append(self.expressions(expression, sep=f" {op} ")) - else: - stack.append(expression.right) - if expression.comments and self.comments: - for comment in expression.comments: - if comment: - op += f" /*{self.sanitize_comment(comment)}*/" - stack.extend((op, expression.left)) - return op - - stack = [expression] - sqls: t.List[str] = [] - ops = set() - - while stack: - node = stack.pop() - if isinstance(node, exp.Connector): - ops.add(getattr(self, f"{node.key}_sql")(node, stack)) - else: - sql = self.sql(node) - if sqls and sqls[-1] in ops: - sqls[-1] += f" {sql}" - else: - sqls.append(sql) - - sep = "\n" if self.pretty and self.too_wide(sqls) else " " - return sep.join(sqls) - - def bitwiseand_sql(self, expression: exp.BitwiseAnd) -> str: - return self.binary(expression, "&") - - def bitwiseleftshift_sql(self, expression: exp.BitwiseLeftShift) -> str: - return self.binary(expression, "<<") - - def bitwisenot_sql(self, expression: exp.BitwiseNot) -> str: - return f"~{self.sql(expression, 'this')}" - - def bitwiseor_sql(self, expression: exp.BitwiseOr) -> str: - return self.binary(expression, "|") - - def bitwiserightshift_sql(self, expression: exp.BitwiseRightShift) -> str: - return self.binary(expression, ">>") - - def bitwisexor_sql(self, expression: exp.BitwiseXor) -> str: - return self.binary(expression, "^") - - def cast_sql( - self, expression: exp.Cast, safe_prefix: t.Optional[str] = None - ) -> str: - format_sql = self.sql(expression, "format") - format_sql = f" FORMAT {format_sql}" if format_sql else "" - to_sql = self.sql(expression, "to") - to_sql = f" {to_sql}" if to_sql else "" - action = self.sql(expression, "action") - action = f" {action}" if action else "" - default = self.sql(expression, "default") - default = f" DEFAULT {default} ON CONVERSION ERROR" if default else "" - return f"{safe_prefix or ''}CAST({self.sql(expression, 'this')} AS{to_sql}{default}{format_sql}{action})" - - # Base implementation that excludes safe, zone, and target_type metadata args - def strtotime_sql(self, expression: exp.StrToTime) -> str: - return self.func("STR_TO_TIME", expression.this, expression.args.get("format")) - - def currentdate_sql(self, expression: exp.CurrentDate) -> str: - zone = self.sql(expression, "this") - return f"CURRENT_DATE({zone})" if zone else "CURRENT_DATE" - - def collate_sql(self, expression: exp.Collate) -> str: - if self.COLLATE_IS_FUNC: - return self.function_fallback_sql(expression) - return self.binary(expression, "COLLATE") - - def command_sql(self, expression: exp.Command) -> str: - return f"{self.sql(expression, 'this')} {expression.text('expression').strip()}" - - def comment_sql(self, expression: exp.Comment) -> str: - this = self.sql(expression, "this") - kind = expression.args["kind"] - materialized = " MATERIALIZED" if expression.args.get("materialized") else "" - exists_sql = " IF EXISTS " if expression.args.get("exists") else " " - expression_sql = self.sql(expression, "expression") - return f"COMMENT{exists_sql}ON{materialized} {kind} {this} IS {expression_sql}" - - def mergetreettlaction_sql(self, expression: exp.MergeTreeTTLAction) -> str: - this = self.sql(expression, "this") - delete = " DELETE" if expression.args.get("delete") else "" - recompress = self.sql(expression, "recompress") - recompress = f" RECOMPRESS {recompress}" if recompress else "" - to_disk = self.sql(expression, "to_disk") - to_disk = f" TO DISK {to_disk}" if to_disk else "" - to_volume = self.sql(expression, "to_volume") - to_volume = f" TO VOLUME {to_volume}" if to_volume else "" - return f"{this}{delete}{recompress}{to_disk}{to_volume}" - - def mergetreettl_sql(self, expression: exp.MergeTreeTTL) -> str: - where = self.sql(expression, "where") - group = self.sql(expression, "group") - aggregates = self.expressions(expression, key="aggregates") - aggregates = self.seg("SET") + self.seg(aggregates) if aggregates else "" - - if not (where or group or aggregates) and len(expression.expressions) == 1: - return f"TTL {self.expressions(expression, flat=True)}" - - return f"TTL{self.seg(self.expressions(expression))}{where}{group}{aggregates}" - - def transaction_sql(self, expression: exp.Transaction) -> str: - modes = self.expressions(expression, key="modes") - modes = f" {modes}" if modes else "" - return f"BEGIN{modes}" - - def commit_sql(self, expression: exp.Commit) -> str: - chain = expression.args.get("chain") - if chain is not None: - chain = " AND CHAIN" if chain else " AND NO CHAIN" - - return f"COMMIT{chain or ''}" - - def rollback_sql(self, expression: exp.Rollback) -> str: - savepoint = expression.args.get("savepoint") - savepoint = f" TO {savepoint}" if savepoint else "" - return f"ROLLBACK{savepoint}" - - def altercolumn_sql(self, expression: exp.AlterColumn) -> str: - this = self.sql(expression, "this") - - dtype = self.sql(expression, "dtype") - if dtype: - collate = self.sql(expression, "collate") - collate = f" COLLATE {collate}" if collate else "" - using = self.sql(expression, "using") - using = f" USING {using}" if using else "" - alter_set_type = self.ALTER_SET_TYPE + " " if self.ALTER_SET_TYPE else "" - return f"ALTER COLUMN {this} {alter_set_type}{dtype}{collate}{using}" - - default = self.sql(expression, "default") - if default: - return f"ALTER COLUMN {this} SET DEFAULT {default}" - - comment = self.sql(expression, "comment") - if comment: - return f"ALTER COLUMN {this} COMMENT {comment}" - - visible = expression.args.get("visible") - if visible: - return f"ALTER COLUMN {this} SET {visible}" - - allow_null = expression.args.get("allow_null") - drop = expression.args.get("drop") - - if not drop and not allow_null: - self.unsupported("Unsupported ALTER COLUMN syntax") - - if allow_null is not None: - keyword = "DROP" if drop else "SET" - return f"ALTER COLUMN {this} {keyword} NOT NULL" - - return f"ALTER COLUMN {this} DROP DEFAULT" - - def alterindex_sql(self, expression: exp.AlterIndex) -> str: - this = self.sql(expression, "this") - - visible = expression.args.get("visible") - visible_sql = "VISIBLE" if visible else "INVISIBLE" - - return f"ALTER INDEX {this} {visible_sql}" - - def alterdiststyle_sql(self, expression: exp.AlterDistStyle) -> str: - this = self.sql(expression, "this") - if not isinstance(expression.this, exp.Var): - this = f"KEY DISTKEY {this}" - return f"ALTER DISTSTYLE {this}" - - def altersortkey_sql(self, expression: exp.AlterSortKey) -> str: - compound = " COMPOUND" if expression.args.get("compound") else "" - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - expressions = f"({expressions})" if expressions else "" - return f"ALTER{compound} SORTKEY {this or expressions}" - - def alterrename_sql( - self, expression: exp.AlterRename, include_to: bool = True - ) -> str: - if not self.RENAME_TABLE_WITH_DB: - # Remove db from tables - expression = expression.transform( - lambda n: exp.table_(n.this) if isinstance(n, exp.Table) else n - ).assert_is(exp.AlterRename) - this = self.sql(expression, "this") - to_kw = " TO" if include_to else "" - return f"RENAME{to_kw} {this}" - - def renamecolumn_sql(self, expression: exp.RenameColumn) -> str: - exists = " IF EXISTS" if expression.args.get("exists") else "" - old_column = self.sql(expression, "this") - new_column = self.sql(expression, "to") - return f"RENAME COLUMN{exists} {old_column} TO {new_column}" - - def alterset_sql(self, expression: exp.AlterSet) -> str: - exprs = self.expressions(expression, flat=True) - if self.ALTER_SET_WRAPPED: - exprs = f"({exprs})" - - return f"SET {exprs}" - - def alter_sql(self, expression: exp.Alter) -> str: - actions = expression.args["actions"] - - if not self.dialect.ALTER_TABLE_ADD_REQUIRED_FOR_EACH_COLUMN and isinstance( - actions[0], exp.ColumnDef - ): - actions_sql = self.expressions(expression, key="actions", flat=True) - actions_sql = f"ADD {actions_sql}" - else: - actions_list = [] - for action in actions: - if isinstance(action, (exp.ColumnDef, exp.Schema)): - action_sql = self.add_column_sql(action) - else: - action_sql = self.sql(action) - if isinstance(action, exp.Query): - action_sql = f"AS {action_sql}" - - actions_list.append(action_sql) - - actions_sql = self.format_args(*actions_list).lstrip("\n") - - exists = " IF EXISTS" if expression.args.get("exists") else "" - on_cluster = self.sql(expression, "cluster") - on_cluster = f" {on_cluster}" if on_cluster else "" - only = " ONLY" if expression.args.get("only") else "" - options = self.expressions(expression, key="options") - options = f", {options}" if options else "" - kind = self.sql(expression, "kind") - not_valid = " NOT VALID" if expression.args.get("not_valid") else "" - check = " WITH CHECK" if expression.args.get("check") else "" - cascade = ( - " CASCADE" - if expression.args.get("cascade") - and self.dialect.ALTER_TABLE_SUPPORTS_CASCADE - else "" - ) - this = self.sql(expression, "this") - this = f" {this}" if this else "" - - return f"ALTER {kind}{exists}{only}{this}{on_cluster}{check}{self.sep()}{actions_sql}{not_valid}{options}{cascade}" - - def altersession_sql(self, expression: exp.AlterSession) -> str: - items_sql = self.expressions(expression, flat=True) - keyword = "UNSET" if expression.args.get("unset") else "SET" - return f"{keyword} {items_sql}" - - def add_column_sql(self, expression: exp.Expression) -> str: - sql = self.sql(expression) - if isinstance(expression, exp.Schema): - column_text = " COLUMNS" - elif ( - isinstance(expression, exp.ColumnDef) - and self.ALTER_TABLE_INCLUDE_COLUMN_KEYWORD - ): - column_text = " COLUMN" - else: - column_text = "" - - return f"ADD{column_text} {sql}" - - def droppartition_sql(self, expression: exp.DropPartition) -> str: - expressions = self.expressions(expression) - exists = " IF EXISTS " if expression.args.get("exists") else " " - return f"DROP{exists}{expressions}" - - def addconstraint_sql(self, expression: exp.AddConstraint) -> str: - return f"ADD {self.expressions(expression, indent=False)}" - - def addpartition_sql(self, expression: exp.AddPartition) -> str: - exists = "IF NOT EXISTS " if expression.args.get("exists") else "" - location = self.sql(expression, "location") - location = f" {location}" if location else "" - return f"ADD {exists}{self.sql(expression.this)}{location}" - - def distinct_sql(self, expression: exp.Distinct) -> str: - this = self.expressions(expression, flat=True) - - if not self.MULTI_ARG_DISTINCT and len(expression.expressions) > 1: - case = exp.case() - for arg in expression.expressions: - case = case.when(arg.is_(exp.null()), exp.null()) - this = self.sql(case.else_(f"({this})")) - - this = f" {this}" if this else "" - - on = self.sql(expression, "on") - on = f" ON {on}" if on else "" - return f"DISTINCT{this}{on}" - - def ignorenulls_sql(self, expression: exp.IgnoreNulls) -> str: - return self._embed_ignore_nulls(expression, "IGNORE NULLS") - - def respectnulls_sql(self, expression: exp.RespectNulls) -> str: - return self._embed_ignore_nulls(expression, "RESPECT NULLS") - - def havingmax_sql(self, expression: exp.HavingMax) -> str: - this_sql = self.sql(expression, "this") - expression_sql = self.sql(expression, "expression") - kind = "MAX" if expression.args.get("max") else "MIN" - return f"{this_sql} HAVING {kind} {expression_sql}" - - def intdiv_sql(self, expression: exp.IntDiv) -> str: - return self.sql( - exp.Cast( - this=exp.Div(this=expression.this, expression=expression.expression), - to=exp.DataType(this=exp.DataType.Type.INT), - ) - ) - - def dpipe_sql(self, expression: exp.DPipe) -> str: - if self.dialect.STRICT_STRING_CONCAT and expression.args.get("safe"): - return self.func( - "CONCAT", - *(exp.cast(e, exp.DataType.Type.TEXT) for e in expression.flatten()), - ) - return self.binary(expression, "||") - - def div_sql(self, expression: exp.Div) -> str: - l, r = expression.left, expression.right - - if not self.dialect.SAFE_DIVISION and expression.args.get("safe"): - r.replace(exp.Nullif(this=r.copy(), expression=exp.Literal.number(0))) - - if self.dialect.TYPED_DIVISION and not expression.args.get("typed"): - if not l.is_type(*exp.DataType.REAL_TYPES) and not r.is_type( - *exp.DataType.REAL_TYPES - ): - l.replace(exp.cast(l.copy(), to=exp.DataType.Type.DOUBLE)) - - elif not self.dialect.TYPED_DIVISION and expression.args.get("typed"): - if l.is_type(*exp.DataType.INTEGER_TYPES) and r.is_type( - *exp.DataType.INTEGER_TYPES - ): - return self.sql( - exp.cast( - l / r, - to=exp.DataType.Type.BIGINT, - ) - ) - - return self.binary(expression, "/") - - def safedivide_sql(self, expression: exp.SafeDivide) -> str: - n = exp._wrap(expression.this, exp.Binary) - d = exp._wrap(expression.expression, exp.Binary) - return self.sql(exp.If(this=d.neq(0), true=n / d, false=exp.Null())) - - def overlaps_sql(self, expression: exp.Overlaps) -> str: - return self.binary(expression, "OVERLAPS") - - def distance_sql(self, expression: exp.Distance) -> str: - return self.binary(expression, "<->") - - def dot_sql(self, expression: exp.Dot) -> str: - return f"{self.sql(expression, 'this')}.{self.sql(expression, 'expression')}" - - def eq_sql(self, expression: exp.EQ) -> str: - return self.binary(expression, "=") - - def propertyeq_sql(self, expression: exp.PropertyEQ) -> str: - return self.binary(expression, ":=") - - def escape_sql(self, expression: exp.Escape) -> str: - return self.binary(expression, "ESCAPE") - - def glob_sql(self, expression: exp.Glob) -> str: - return self.binary(expression, "GLOB") - - def gt_sql(self, expression: exp.GT) -> str: - return self.binary(expression, ">") - - def gte_sql(self, expression: exp.GTE) -> str: - return self.binary(expression, ">=") - - def is_sql(self, expression: exp.Is) -> str: - if not self.IS_BOOL_ALLOWED and isinstance(expression.expression, exp.Boolean): - return self.sql( - expression.this - if expression.expression.this - else exp.not_(expression.this) - ) - return self.binary(expression, "IS") - - def _like_sql(self, expression: exp.Like | exp.ILike) -> str: - this = expression.this - rhs = expression.expression - - if isinstance(expression, exp.Like): - exp_class: t.Type[exp.Like | exp.ILike] = exp.Like - op = "LIKE" - else: - exp_class = exp.ILike - op = "ILIKE" - - if isinstance(rhs, (exp.All, exp.Any)) and not self.SUPPORTS_LIKE_QUANTIFIERS: - exprs = rhs.this.unnest() - - if isinstance(exprs, exp.Tuple): - exprs = exprs.expressions - - connective = exp.or_ if isinstance(rhs, exp.Any) else exp.and_ - - like_expr: exp.Expression = exp_class(this=this, expression=exprs[0]) - for expr in exprs[1:]: - like_expr = connective(like_expr, exp_class(this=this, expression=expr)) - - parent = expression.parent - if not isinstance(parent, type(like_expr)) and isinstance( - parent, exp.Condition - ): - like_expr = exp.paren(like_expr, copy=False) - - return self.sql(like_expr) - - return self.binary(expression, op) - - def like_sql(self, expression: exp.Like) -> str: - return self._like_sql(expression) - - def ilike_sql(self, expression: exp.ILike) -> str: - return self._like_sql(expression) - - def match_sql(self, expression: exp.Match) -> str: - return self.binary(expression, "MATCH") - - def similarto_sql(self, expression: exp.SimilarTo) -> str: - return self.binary(expression, "SIMILAR TO") - - def lt_sql(self, expression: exp.LT) -> str: - return self.binary(expression, "<") - - def lte_sql(self, expression: exp.LTE) -> str: - return self.binary(expression, "<=") - - def mod_sql(self, expression: exp.Mod) -> str: - return self.binary(expression, "%") - - def mul_sql(self, expression: exp.Mul) -> str: - return self.binary(expression, "*") - - def neq_sql(self, expression: exp.NEQ) -> str: - return self.binary(expression, "<>") - - def nullsafeeq_sql(self, expression: exp.NullSafeEQ) -> str: - return self.binary(expression, "IS NOT DISTINCT FROM") - - def nullsafeneq_sql(self, expression: exp.NullSafeNEQ) -> str: - return self.binary(expression, "IS DISTINCT FROM") - - def sub_sql(self, expression: exp.Sub) -> str: - return self.binary(expression, "-") - - def trycast_sql(self, expression: exp.TryCast) -> str: - return self.cast_sql(expression, safe_prefix="TRY_") - - def jsoncast_sql(self, expression: exp.JSONCast) -> str: - return self.cast_sql(expression) - - def try_sql(self, expression: exp.Try) -> str: - if not self.TRY_SUPPORTED: - self.unsupported("Unsupported TRY function") - return self.sql(expression, "this") - - return self.func("TRY", expression.this) - - def log_sql(self, expression: exp.Log) -> str: - this = expression.this - expr = expression.expression - - if self.dialect.LOG_BASE_FIRST is False: - this, expr = expr, this - elif self.dialect.LOG_BASE_FIRST is None and expr: - if this.name in ("2", "10"): - return self.func(f"LOG{this.name}", expr) - - self.unsupported(f"Unsupported logarithm with base {self.sql(this)}") - - return self.func("LOG", this, expr) - - def use_sql(self, expression: exp.Use) -> str: - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - this = self.sql(expression, "this") or self.expressions(expression, flat=True) - this = f" {this}" if this else "" - return f"USE{kind}{this}" - - def binary(self, expression: exp.Binary, op: str) -> str: - sqls: t.List[str] = [] - stack: t.List[t.Union[str, exp.Expression]] = [expression] - binary_type = type(expression) - - while stack: - node = stack.pop() - - if type(node) is binary_type: - op_func = node.args.get("operator") - if op_func: - op = f"OPERATOR({self.sql(op_func)})" - - stack.append(node.right) - stack.append(f" {self.maybe_comment(op, comments=node.comments)} ") - stack.append(node.left) - else: - sqls.append(self.sql(node)) - - return "".join(sqls) - - def ceil_floor(self, expression: exp.Ceil | exp.Floor) -> str: - to_clause = self.sql(expression, "to") - if to_clause: - return f"{expression.sql_name()}({self.sql(expression, 'this')} TO {to_clause})" - - return self.function_fallback_sql(expression) - - def function_fallback_sql(self, expression: exp.Func) -> str: - args = [] - - for key in expression.arg_types: - arg_value = expression.args.get(key) - - if isinstance(arg_value, list): - for value in arg_value: - args.append(value) - elif arg_value is not None: - args.append(arg_value) - - if self.dialect.PRESERVE_ORIGINAL_NAMES: - name = ( - expression._meta and expression.meta.get("name") - ) or expression.sql_name() - else: - name = expression.sql_name() - - return self.func(name, *args) - - def func( - self, - name: str, - *args: t.Optional[exp.Expression | str], - prefix: str = "(", - suffix: str = ")", - normalize: bool = True, - ) -> str: - name = self.normalize_func(name) if normalize else name - return f"{name}{prefix}{self.format_args(*args)}{suffix}" - - def format_args( - self, *args: t.Optional[str | exp.Expression], sep: str = ", " - ) -> str: - arg_sqls = tuple( - self.sql(arg) - for arg in args - if arg is not None and not isinstance(arg, bool) - ) - if self.pretty and self.too_wide(arg_sqls): - return self.indent( - "\n" + f"{sep.strip()}\n".join(arg_sqls) + "\n", - skip_first=True, - skip_last=True, - ) - return sep.join(arg_sqls) - - def too_wide(self, args: t.Iterable) -> bool: - return sum(len(arg) for arg in args) > self.max_text_width - - def format_time( - self, - expression: exp.Expression, - inverse_time_mapping: t.Optional[t.Dict[str, str]] = None, - inverse_time_trie: t.Optional[t.Dict] = None, - ) -> t.Optional[str]: - return format_time( - self.sql(expression, "format"), - inverse_time_mapping or self.dialect.INVERSE_TIME_MAPPING, - inverse_time_trie or self.dialect.INVERSE_TIME_TRIE, - ) - - def expressions( - self, - expression: t.Optional[exp.Expression] = None, - key: t.Optional[str] = None, - sqls: t.Optional[t.Collection[str | exp.Expression]] = None, - flat: bool = False, - indent: bool = True, - skip_first: bool = False, - skip_last: bool = False, - sep: str = ", ", - prefix: str = "", - dynamic: bool = False, - new_line: bool = False, - ) -> str: - expressions = expression.args.get(key or "expressions") if expression else sqls - - if not expressions: - return "" - - if flat: - return sep.join(sql for sql in (self.sql(e) for e in expressions) if sql) - - num_sqls = len(expressions) - result_sqls = [] - - for i, e in enumerate(expressions): - sql = self.sql(e, comment=False) - if not sql: - continue - - comments = ( - self.maybe_comment("", e) if isinstance(e, exp.Expression) else "" - ) - - if self.pretty: - if self.leading_comma: - result_sqls.append(f"{sep if i > 0 else ''}{prefix}{sql}{comments}") - else: - result_sqls.append( - f"{prefix}{sql}{(sep.rstrip() if comments else sep) if i + 1 < num_sqls else ''}{comments}" - ) - else: - result_sqls.append( - f"{prefix}{sql}{comments}{sep if i + 1 < num_sqls else ''}" - ) - - if self.pretty and (not dynamic or self.too_wide(result_sqls)): - if new_line: - result_sqls.insert(0, "") - result_sqls.append("") - result_sql = "\n".join(s.rstrip() for s in result_sqls) - else: - result_sql = "".join(result_sqls) - - return ( - self.indent(result_sql, skip_first=skip_first, skip_last=skip_last) - if indent - else result_sql - ) - - def op_expressions( - self, op: str, expression: exp.Expression, flat: bool = False - ) -> str: - flat = flat or isinstance(expression.parent, exp.Properties) - expressions_sql = self.expressions(expression, flat=flat) - if flat: - return f"{op} {expressions_sql}" - return f"{self.seg(op)}{self.sep() if expressions_sql else ''}{expressions_sql}" - - def naked_property(self, expression: exp.Property) -> str: - property_name = exp.Properties.PROPERTY_TO_NAME.get(expression.__class__) - if not property_name: - self.unsupported(f"Unsupported property {expression.__class__.__name__}") - return f"{property_name} {self.sql(expression, 'this')}" - - def tag_sql(self, expression: exp.Tag) -> str: - return f"{expression.args.get('prefix')}{self.sql(expression.this)}{expression.args.get('postfix')}" - - def token_sql(self, token_type: TokenType) -> str: - return self.TOKEN_MAPPING.get(token_type, token_type.name) - - def userdefinedfunction_sql(self, expression: exp.UserDefinedFunction) -> str: - this = self.sql(expression, "this") - expressions = self.no_identify(self.expressions, expression) - expressions = ( - self.wrap(expressions) - if expression.args.get("wrapped") - else f" {expressions}" - ) - return f"{this}{expressions}" if expressions.strip() != "" else this - - def joinhint_sql(self, expression: exp.JoinHint) -> str: - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - return f"{this}({expressions})" - - def kwarg_sql(self, expression: exp.Kwarg) -> str: - return self.binary(expression, "=>") - - def when_sql(self, expression: exp.When) -> str: - matched = "MATCHED" if expression.args["matched"] else "NOT MATCHED" - source = ( - " BY SOURCE" - if self.MATCHED_BY_SOURCE and expression.args.get("source") - else "" - ) - condition = self.sql(expression, "condition") - condition = f" AND {condition}" if condition else "" - - then_expression = expression.args.get("then") - if isinstance(then_expression, exp.Insert): - this = self.sql(then_expression, "this") - this = f"INSERT {this}" if this else "INSERT" - then = self.sql(then_expression, "expression") - then = f"{this} VALUES {then}" if then else this - elif isinstance(then_expression, exp.Update): - if isinstance(then_expression.args.get("expressions"), exp.Star): - then = f"UPDATE {self.sql(then_expression, 'expressions')}" - else: - expressions_sql = self.expressions(then_expression) - then = ( - f"UPDATE SET{self.sep()}{expressions_sql}" - if expressions_sql - else "UPDATE" - ) - - else: - then = self.sql(then_expression) - return f"WHEN {matched}{source}{condition} THEN {then}" - - def whens_sql(self, expression: exp.Whens) -> str: - return self.expressions(expression, sep=" ", indent=False) - - def merge_sql(self, expression: exp.Merge) -> str: - table = expression.this - table_alias = "" - - hints = table.args.get("hints") - if hints and table.alias and isinstance(hints[0], exp.WithTableHint): - # T-SQL syntax is MERGE ... [WITH ()] [[AS] table_alias] - table_alias = f" AS {self.sql(table.args['alias'].pop())}" - - this = self.sql(table) - using = f"USING {self.sql(expression, 'using')}" - whens = self.sql(expression, "whens") - - on = self.sql(expression, "on") - on = f"ON {on}" if on else "" - - if not on: - on = self.expressions(expression, key="using_cond") - on = f"USING ({on})" if on else "" - - returning = self.sql(expression, "returning") - if returning: - whens = f"{whens}{returning}" - - sep = self.sep() - - return self.prepend_ctes( - expression, - f"MERGE INTO {this}{table_alias}{sep}{using}{sep}{on}{sep}{whens}", - ) - - @unsupported_args("format") - def tochar_sql(self, expression: exp.ToChar) -> str: - return self.sql(exp.cast(expression.this, exp.DataType.Type.TEXT)) - - def tonumber_sql(self, expression: exp.ToNumber) -> str: - if not self.SUPPORTS_TO_NUMBER: - self.unsupported("Unsupported TO_NUMBER function") - return self.sql(exp.cast(expression.this, exp.DataType.Type.DOUBLE)) - - fmt = expression.args.get("format") - if not fmt: - self.unsupported("Conversion format is required for TO_NUMBER") - return self.sql(exp.cast(expression.this, exp.DataType.Type.DOUBLE)) - - return self.func("TO_NUMBER", expression.this, fmt) - - def dictproperty_sql(self, expression: exp.DictProperty) -> str: - this = self.sql(expression, "this") - kind = self.sql(expression, "kind") - settings_sql = self.expressions(expression, key="settings", sep=" ") - args = ( - f"({self.sep('')}{settings_sql}{self.seg(')', sep='')}" - if settings_sql - else "()" - ) - return f"{this}({kind}{args})" - - def dictrange_sql(self, expression: exp.DictRange) -> str: - this = self.sql(expression, "this") - max = self.sql(expression, "max") - min = self.sql(expression, "min") - return f"{this}(MIN {min} MAX {max})" - - def dictsubproperty_sql(self, expression: exp.DictSubProperty) -> str: - return f"{self.sql(expression, 'this')} {self.sql(expression, 'value')}" - - def duplicatekeyproperty_sql(self, expression: exp.DuplicateKeyProperty) -> str: - return f"DUPLICATE KEY ({self.expressions(expression, flat=True)})" - - # https://docs.starrocks.io/docs/sql-reference/sql-statements/table_bucket_part_index/CREATE_TABLE/ - def uniquekeyproperty_sql( - self, expression: exp.UniqueKeyProperty, prefix: str = "UNIQUE KEY" - ) -> str: - return f"{prefix} ({self.expressions(expression, flat=True)})" - - # https://docs.starrocks.io/docs/sql-reference/sql-statements/data-definition/CREATE_TABLE/#distribution_desc - def distributedbyproperty_sql(self, expression: exp.DistributedByProperty) -> str: - expressions = self.expressions(expression, flat=True) - expressions = f" {self.wrap(expressions)}" if expressions else "" - buckets = self.sql(expression, "buckets") - kind = self.sql(expression, "kind") - buckets = f" BUCKETS {buckets}" if buckets else "" - order = self.sql(expression, "order") - return f"DISTRIBUTED BY {kind}{expressions}{buckets}{order}" - - def oncluster_sql(self, expression: exp.OnCluster) -> str: - return "" - - def clusteredbyproperty_sql(self, expression: exp.ClusteredByProperty) -> str: - expressions = self.expressions(expression, key="expressions", flat=True) - sorted_by = self.expressions(expression, key="sorted_by", flat=True) - sorted_by = f" SORTED BY ({sorted_by})" if sorted_by else "" - buckets = self.sql(expression, "buckets") - return f"CLUSTERED BY ({expressions}){sorted_by} INTO {buckets} BUCKETS" - - def anyvalue_sql(self, expression: exp.AnyValue) -> str: - this = self.sql(expression, "this") - having = self.sql(expression, "having") - - if having: - this = f"{this} HAVING {'MAX' if expression.args.get('max') else 'MIN'} {having}" - - return self.func("ANY_VALUE", this) - - def querytransform_sql(self, expression: exp.QueryTransform) -> str: - transform = self.func("TRANSFORM", *expression.expressions) - row_format_before = self.sql(expression, "row_format_before") - row_format_before = f" {row_format_before}" if row_format_before else "" - record_writer = self.sql(expression, "record_writer") - record_writer = f" RECORDWRITER {record_writer}" if record_writer else "" - using = f" USING {self.sql(expression, 'command_script')}" - schema = self.sql(expression, "schema") - schema = f" AS {schema}" if schema else "" - row_format_after = self.sql(expression, "row_format_after") - row_format_after = f" {row_format_after}" if row_format_after else "" - record_reader = self.sql(expression, "record_reader") - record_reader = f" RECORDREADER {record_reader}" if record_reader else "" - return f"{transform}{row_format_before}{record_writer}{using}{schema}{row_format_after}{record_reader}" - - def indexconstraintoption_sql(self, expression: exp.IndexConstraintOption) -> str: - key_block_size = self.sql(expression, "key_block_size") - if key_block_size: - return f"KEY_BLOCK_SIZE = {key_block_size}" - - using = self.sql(expression, "using") - if using: - return f"USING {using}" - - parser = self.sql(expression, "parser") - if parser: - return f"WITH PARSER {parser}" - - comment = self.sql(expression, "comment") - if comment: - return f"COMMENT {comment}" - - visible = expression.args.get("visible") - if visible is not None: - return "VISIBLE" if visible else "INVISIBLE" - - engine_attr = self.sql(expression, "engine_attr") - if engine_attr: - return f"ENGINE_ATTRIBUTE = {engine_attr}" - - secondary_engine_attr = self.sql(expression, "secondary_engine_attr") - if secondary_engine_attr: - return f"SECONDARY_ENGINE_ATTRIBUTE = {secondary_engine_attr}" - - self.unsupported("Unsupported index constraint option.") - return "" - - def checkcolumnconstraint_sql(self, expression: exp.CheckColumnConstraint) -> str: - enforced = " ENFORCED" if expression.args.get("enforced") else "" - return f"CHECK ({self.sql(expression, 'this')}){enforced}" - - def indexcolumnconstraint_sql(self, expression: exp.IndexColumnConstraint) -> str: - kind = self.sql(expression, "kind") - kind = f"{kind} INDEX" if kind else "INDEX" - this = self.sql(expression, "this") - this = f" {this}" if this else "" - index_type = self.sql(expression, "index_type") - index_type = f" USING {index_type}" if index_type else "" - expressions = self.expressions(expression, flat=True) - expressions = f" ({expressions})" if expressions else "" - options = self.expressions(expression, key="options", sep=" ") - options = f" {options}" if options else "" - return f"{kind}{this}{index_type}{expressions}{options}" - - def nvl2_sql(self, expression: exp.Nvl2) -> str: - if self.NVL2_SUPPORTED: - return self.function_fallback_sql(expression) - - case = exp.Case().when( - expression.this.is_(exp.null()).not_(copy=False), - expression.args["true"], - copy=False, - ) - else_cond = expression.args.get("false") - if else_cond: - case.else_(else_cond, copy=False) - - return self.sql(case) - - def comprehension_sql(self, expression: exp.Comprehension) -> str: - this = self.sql(expression, "this") - expr = self.sql(expression, "expression") - position = self.sql(expression, "position") - position = f", {position}" if position else "" - iterator = self.sql(expression, "iterator") - condition = self.sql(expression, "condition") - condition = f" IF {condition}" if condition else "" - return f"{this} FOR {expr}{position} IN {iterator}{condition}" - - def columnprefix_sql(self, expression: exp.ColumnPrefix) -> str: - return f"{self.sql(expression, 'this')}({self.sql(expression, 'expression')})" - - def opclass_sql(self, expression: exp.Opclass) -> str: - return f"{self.sql(expression, 'this')} {self.sql(expression, 'expression')}" - - def _ml_sql(self, expression: exp.Func, name: str) -> str: - model = self.sql(expression, "this") - model = f"MODEL {model}" - expr = expression.expression - if expr: - expr_sql = self.sql(expression, "expression") - expr_sql = ( - f"TABLE {expr_sql}" if not isinstance(expr, exp.Subquery) else expr_sql - ) - else: - expr_sql = None - - parameters = self.sql(expression, "params_struct") or None - - return self.func(name, model, expr_sql, parameters) - - def predict_sql(self, expression: exp.Predict) -> str: - return self._ml_sql(expression, "PREDICT") - - def generateembedding_sql(self, expression: exp.GenerateEmbedding) -> str: - name = ( - "GENERATE_TEXT_EMBEDDING" - if expression.args.get("is_text") - else "GENERATE_EMBEDDING" - ) - return self._ml_sql(expression, name) - - def mltranslate_sql(self, expression: exp.MLTranslate) -> str: - return self._ml_sql(expression, "TRANSLATE") - - def mlforecast_sql(self, expression: exp.MLForecast) -> str: - return self._ml_sql(expression, "FORECAST") - - def featuresattime_sql(self, expression: exp.FeaturesAtTime) -> str: - this_sql = self.sql(expression, "this") - if isinstance(expression.this, exp.Table): - this_sql = f"TABLE {this_sql}" - - return self.func( - "FEATURES_AT_TIME", - this_sql, - expression.args.get("time"), - expression.args.get("num_rows"), - expression.args.get("ignore_feature_nulls"), - ) - - def vectorsearch_sql(self, expression: exp.VectorSearch) -> str: - this_sql = self.sql(expression, "this") - if isinstance(expression.this, exp.Table): - this_sql = f"TABLE {this_sql}" - - query_table = self.sql(expression, "query_table") - if isinstance(expression.args["query_table"], exp.Table): - query_table = f"TABLE {query_table}" - - return self.func( - "VECTOR_SEARCH", - this_sql, - expression.args.get("column_to_search"), - query_table, - expression.args.get("query_column_to_search"), - expression.args.get("top_k"), - expression.args.get("distance_type"), - expression.args.get("options"), - ) - - def forin_sql(self, expression: exp.ForIn) -> str: - this = self.sql(expression, "this") - expression_sql = self.sql(expression, "expression") - return f"FOR {this} DO {expression_sql}" - - def refresh_sql(self, expression: exp.Refresh) -> str: - this = self.sql(expression, "this") - kind = ( - "" - if isinstance(expression.this, exp.Literal) - else f"{expression.text('kind')} " - ) - return f"REFRESH {kind}{this}" - - def toarray_sql(self, expression: exp.ToArray) -> str: - arg = expression.this - if not arg.type: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - arg = annotate_types(arg, dialect=self.dialect) - - if arg.is_type(exp.DataType.Type.ARRAY): - return self.sql(arg) - - cond_for_null = arg.is_(exp.null()) - return self.sql( - exp.func("IF", cond_for_null, exp.null(), exp.array(arg, copy=False)) - ) - - def tsordstotime_sql(self, expression: exp.TsOrDsToTime) -> str: - this = expression.this - time_format = self.format_time(expression) - - if time_format: - return self.sql( - exp.cast( - exp.StrToTime(this=this, format=expression.args["format"]), - exp.DataType.Type.TIME, - ) - ) - - if isinstance(this, exp.TsOrDsToTime) or this.is_type(exp.DataType.Type.TIME): - return self.sql(this) - - return self.sql(exp.cast(this, exp.DataType.Type.TIME)) - - def tsordstotimestamp_sql(self, expression: exp.TsOrDsToTimestamp) -> str: - this = expression.this - if isinstance(this, exp.TsOrDsToTimestamp) or this.is_type( - exp.DataType.Type.TIMESTAMP - ): - return self.sql(this) - - return self.sql( - exp.cast(this, exp.DataType.Type.TIMESTAMP, dialect=self.dialect) - ) - - def tsordstodatetime_sql(self, expression: exp.TsOrDsToDatetime) -> str: - this = expression.this - if isinstance(this, exp.TsOrDsToDatetime) or this.is_type( - exp.DataType.Type.DATETIME - ): - return self.sql(this) - - return self.sql( - exp.cast(this, exp.DataType.Type.DATETIME, dialect=self.dialect) - ) - - def tsordstodate_sql(self, expression: exp.TsOrDsToDate) -> str: - this = expression.this - time_format = self.format_time(expression) - - if time_format and time_format not in ( - self.dialect.TIME_FORMAT, - self.dialect.DATE_FORMAT, - ): - return self.sql( - exp.cast( - exp.StrToTime(this=this, format=expression.args["format"]), - exp.DataType.Type.DATE, - ) - ) - - if isinstance(this, exp.TsOrDsToDate) or this.is_type(exp.DataType.Type.DATE): - return self.sql(this) - - return self.sql(exp.cast(this, exp.DataType.Type.DATE)) - - def unixdate_sql(self, expression: exp.UnixDate) -> str: - return self.sql( - exp.func( - "DATEDIFF", - expression.this, - exp.cast(exp.Literal.string("1970-01-01"), exp.DataType.Type.DATE), - "day", - ) - ) - - def lastday_sql(self, expression: exp.LastDay) -> str: - if self.LAST_DAY_SUPPORTS_DATE_PART: - return self.function_fallback_sql(expression) - - unit = expression.text("unit") - if unit and unit != "MONTH": - self.unsupported("Date parts are not supported in LAST_DAY.") - - return self.func("LAST_DAY", expression.this) - - def dateadd_sql(self, expression: exp.DateAdd) -> str: - from bigframes_vendored.sqlglot.dialects.dialect import unit_to_str - - return self.func( - "DATE_ADD", expression.this, expression.expression, unit_to_str(expression) - ) - - def arrayany_sql(self, expression: exp.ArrayAny) -> str: - if self.CAN_IMPLEMENT_ARRAY_ANY: - filtered = exp.ArrayFilter( - this=expression.this, expression=expression.expression - ) - filtered_not_empty = exp.ArraySize(this=filtered).neq(0) - original_is_empty = exp.ArraySize(this=expression.this).eq(0) - return self.sql(exp.paren(original_is_empty.or_(filtered_not_empty))) - - from bigframes_vendored.sqlglot.dialects import Dialect - - # SQLGlot's executor supports ARRAY_ANY, so we don't wanna warn for the SQLGlot dialect - if self.dialect.__class__ != Dialect: - self.unsupported("ARRAY_ANY is unsupported") - - return self.function_fallback_sql(expression) - - def struct_sql(self, expression: exp.Struct) -> str: - expression.set( - "expressions", - [ - exp.alias_(e.expression, e.name if e.this.is_string else e.this) - if isinstance(e, exp.PropertyEQ) - else e - for e in expression.expressions - ], - ) - - return self.function_fallback_sql(expression) - - def partitionrange_sql(self, expression: exp.PartitionRange) -> str: - low = self.sql(expression, "this") - high = self.sql(expression, "expression") - - return f"{low} TO {high}" - - def truncatetable_sql(self, expression: exp.TruncateTable) -> str: - target = "DATABASE" if expression.args.get("is_database") else "TABLE" - tables = f" {self.expressions(expression)}" - - exists = " IF EXISTS" if expression.args.get("exists") else "" - - on_cluster = self.sql(expression, "cluster") - on_cluster = f" {on_cluster}" if on_cluster else "" - - identity = self.sql(expression, "identity") - identity = f" {identity} IDENTITY" if identity else "" - - option = self.sql(expression, "option") - option = f" {option}" if option else "" - - partition = self.sql(expression, "partition") - partition = f" {partition}" if partition else "" - - return f"TRUNCATE {target}{exists}{tables}{on_cluster}{identity}{option}{partition}" - - # This transpiles T-SQL's CONVERT function - # https://learn.microsoft.com/en-us/sql/t-sql/functions/cast-and-convert-transact-sql?view=sql-server-ver16 - def convert_sql(self, expression: exp.Convert) -> str: - to = expression.this - value = expression.expression - style = expression.args.get("style") - safe = expression.args.get("safe") - strict = expression.args.get("strict") - - if not to or not value: - return "" - - # Retrieve length of datatype and override to default if not specified - if ( - not seq_get(to.expressions, 0) - and to.this in self.PARAMETERIZABLE_TEXT_TYPES - ): - to = exp.DataType.build( - to.this, expressions=[exp.Literal.number(30)], nested=False - ) - - transformed: t.Optional[exp.Expression] = None - cast = exp.Cast if strict else exp.TryCast - - # Check whether a conversion with format (T-SQL calls this 'style') is applicable - if isinstance(style, exp.Literal) and style.is_int: - from bigframes_vendored.sqlglot.dialects.tsql import TSQL - - style_value = style.name - converted_style = TSQL.CONVERT_FORMAT_MAPPING.get(style_value) - if not converted_style: - self.unsupported(f"Unsupported T-SQL 'style' value: {style_value}") - - fmt = exp.Literal.string(converted_style) - - if to.this == exp.DataType.Type.DATE: - transformed = exp.StrToDate(this=value, format=fmt) - elif to.this in (exp.DataType.Type.DATETIME, exp.DataType.Type.DATETIME2): - transformed = exp.StrToTime(this=value, format=fmt) - elif to.this in self.PARAMETERIZABLE_TEXT_TYPES: - transformed = cast( - this=exp.TimeToStr(this=value, format=fmt), to=to, safe=safe - ) - elif to.this == exp.DataType.Type.TEXT: - transformed = exp.TimeToStr(this=value, format=fmt) - - if not transformed: - transformed = cast(this=value, to=to, safe=safe) - - return self.sql(transformed) - - def _jsonpathkey_sql(self, expression: exp.JSONPathKey) -> str: - this = expression.this - if isinstance(this, exp.JSONPathWildcard): - this = self.json_path_part(this) - return f".{this}" if this else "" - - if self.SAFE_JSON_PATH_KEY_RE.match(this): - return f".{this}" - - this = self.json_path_part(this) - return ( - f"[{this}]" - if self._quote_json_path_key_using_brackets - and self.JSON_PATH_BRACKETED_KEY_SUPPORTED - else f".{this}" - ) - - def _jsonpathsubscript_sql(self, expression: exp.JSONPathSubscript) -> str: - this = self.json_path_part(expression.this) - return f"[{this}]" if this else "" - - def _simplify_unless_literal(self, expression: E) -> E: - if not isinstance(expression, exp.Literal): - from bigframes_vendored.sqlglot.optimizer.simplify import simplify - - expression = simplify(expression, dialect=self.dialect) - - return expression - - def _embed_ignore_nulls( - self, expression: exp.IgnoreNulls | exp.RespectNulls, text: str - ) -> str: - this = expression.this - if isinstance(this, self.RESPECT_IGNORE_NULLS_UNSUPPORTED_EXPRESSIONS): - self.unsupported( - f"RESPECT/IGNORE NULLS is not supported for {type(this).key} in {self.dialect.__class__.__name__}" - ) - return self.sql(this) - - if self.IGNORE_NULLS_IN_FUNC and not expression.meta.get("inline"): - # The first modifier here will be the one closest to the AggFunc's arg - mods = sorted( - expression.find_all(exp.HavingMax, exp.Order, exp.Limit), - key=lambda x: 0 - if isinstance(x, exp.HavingMax) - else (1 if isinstance(x, exp.Order) else 2), - ) - - if mods: - mod = mods[0] - this = expression.__class__(this=mod.this.copy()) - this.meta["inline"] = True - mod.this.replace(this) - return self.sql(expression.this) - - agg_func = expression.find(exp.AggFunc) - - if agg_func: - agg_func_sql = self.sql(agg_func, comment=False)[:-1] + f" {text})" - return self.maybe_comment(agg_func_sql, comments=agg_func.comments) - - return f"{self.sql(expression, 'this')} {text}" - - def _replace_line_breaks(self, string: str) -> str: - """We don't want to extra indent line breaks so we temporarily replace them with sentinels.""" - if self.pretty: - return string.replace("\n", self.SENTINEL_LINE_BREAK) - return string - - def copyparameter_sql(self, expression: exp.CopyParameter) -> str: - option = self.sql(expression, "this") - - if expression.expressions: - upper = option.upper() - - # Snowflake FILE_FORMAT options are separated by whitespace - sep = " " if upper == "FILE_FORMAT" else ", " - - # Databricks copy/format options do not set their list of values with EQ - op = " " if upper in ("COPY_OPTIONS", "FORMAT_OPTIONS") else " = " - values = self.expressions(expression, flat=True, sep=sep) - return f"{option}{op}({values})" - - value = self.sql(expression, "expression") - - if not value: - return option - - op = " = " if self.COPY_PARAMS_EQ_REQUIRED else " " - - return f"{option}{op}{value}" - - def credentials_sql(self, expression: exp.Credentials) -> str: - cred_expr = expression.args.get("credentials") - if isinstance(cred_expr, exp.Literal): - # Redshift case: CREDENTIALS - credentials = self.sql(expression, "credentials") - credentials = f"CREDENTIALS {credentials}" if credentials else "" - else: - # Snowflake case: CREDENTIALS = (...) - credentials = self.expressions( - expression, key="credentials", flat=True, sep=" " - ) - credentials = ( - f"CREDENTIALS = ({credentials})" if cred_expr is not None else "" - ) - - storage = self.sql(expression, "storage") - storage = f"STORAGE_INTEGRATION = {storage}" if storage else "" - - encryption = self.expressions(expression, key="encryption", flat=True, sep=" ") - encryption = f" ENCRYPTION = ({encryption})" if encryption else "" - - iam_role = self.sql(expression, "iam_role") - iam_role = f"IAM_ROLE {iam_role}" if iam_role else "" - - region = self.sql(expression, "region") - region = f" REGION {region}" if region else "" - - return f"{credentials}{storage}{encryption}{iam_role}{region}" - - def copy_sql(self, expression: exp.Copy) -> str: - this = self.sql(expression, "this") - this = f" INTO {this}" if self.COPY_HAS_INTO_KEYWORD else f" {this}" - - credentials = self.sql(expression, "credentials") - credentials = self.seg(credentials) if credentials else "" - files = self.expressions(expression, key="files", flat=True) - kind = ( - self.seg("FROM" if expression.args.get("kind") else "TO") if files else "" - ) - - sep = ", " if self.dialect.COPY_PARAMS_ARE_CSV else " " - params = self.expressions( - expression, - key="params", - sep=sep, - new_line=True, - skip_last=True, - skip_first=True, - indent=self.COPY_PARAMS_ARE_WRAPPED, - ) - - if params: - if self.COPY_PARAMS_ARE_WRAPPED: - params = f" WITH ({params})" - elif not self.pretty and (files or credentials): - params = f" {params}" - - return f"COPY{this}{kind} {files}{credentials}{params}" - - def semicolon_sql(self, expression: exp.Semicolon) -> str: - return "" - - def datadeletionproperty_sql(self, expression: exp.DataDeletionProperty) -> str: - on_sql = "ON" if expression.args.get("on") else "OFF" - filter_col: t.Optional[str] = self.sql(expression, "filter_column") - filter_col = f"FILTER_COLUMN={filter_col}" if filter_col else None - retention_period: t.Optional[str] = self.sql(expression, "retention_period") - retention_period = ( - f"RETENTION_PERIOD={retention_period}" if retention_period else None - ) - - if filter_col or retention_period: - on_sql = self.func("ON", filter_col, retention_period) - - return f"DATA_DELETION={on_sql}" - - def maskingpolicycolumnconstraint_sql( - self, expression: exp.MaskingPolicyColumnConstraint - ) -> str: - this = self.sql(expression, "this") - expressions = self.expressions(expression, flat=True) - expressions = f" USING ({expressions})" if expressions else "" - return f"MASKING POLICY {this}{expressions}" - - def gapfill_sql(self, expression: exp.GapFill) -> str: - this = self.sql(expression, "this") - this = f"TABLE {this}" - return self.func( - "GAP_FILL", this, *[v for k, v in expression.args.items() if k != "this"] - ) - - def scope_resolution(self, rhs: str, scope_name: str) -> str: - return self.func("SCOPE_RESOLUTION", scope_name or None, rhs) - - def scoperesolution_sql(self, expression: exp.ScopeResolution) -> str: - this = self.sql(expression, "this") - expr = expression.expression - - if isinstance(expr, exp.Func): - # T-SQL's CLR functions are case sensitive - expr = f"{self.sql(expr, 'this')}({self.format_args(*expr.expressions)})" - else: - expr = self.sql(expression, "expression") - - return self.scope_resolution(expr, this) - - def parsejson_sql(self, expression: exp.ParseJSON) -> str: - if self.PARSE_JSON_NAME is None: - return self.sql(expression.this) - - return self.func(self.PARSE_JSON_NAME, expression.this, expression.expression) - - def rand_sql(self, expression: exp.Rand) -> str: - lower = self.sql(expression, "lower") - upper = self.sql(expression, "upper") - - if lower and upper: - return ( - f"({upper} - {lower}) * {self.func('RAND', expression.this)} + {lower}" - ) - return self.func("RAND", expression.this) - - def changes_sql(self, expression: exp.Changes) -> str: - information = self.sql(expression, "information") - information = f"INFORMATION => {information}" - at_before = self.sql(expression, "at_before") - at_before = f"{self.seg('')}{at_before}" if at_before else "" - end = self.sql(expression, "end") - end = f"{self.seg('')}{end}" if end else "" - - return f"CHANGES ({information}){at_before}{end}" - - def pad_sql(self, expression: exp.Pad) -> str: - prefix = "L" if expression.args.get("is_left") else "R" - - fill_pattern = self.sql(expression, "fill_pattern") or None - if not fill_pattern and self.PAD_FILL_PATTERN_IS_REQUIRED: - fill_pattern = "' '" - - return self.func( - f"{prefix}PAD", expression.this, expression.expression, fill_pattern - ) - - def summarize_sql(self, expression: exp.Summarize) -> str: - table = " TABLE" if expression.args.get("table") else "" - return f"SUMMARIZE{table} {self.sql(expression.this)}" - - def explodinggenerateseries_sql( - self, expression: exp.ExplodingGenerateSeries - ) -> str: - generate_series = exp.GenerateSeries(**expression.args) - - parent = expression.parent - if isinstance(parent, (exp.Alias, exp.TableAlias)): - parent = parent.parent - - if self.SUPPORTS_EXPLODING_PROJECTIONS and not isinstance( - parent, (exp.Table, exp.Unnest) - ): - return self.sql(exp.Unnest(expressions=[generate_series])) - - if isinstance(parent, exp.Select): - self.unsupported("GenerateSeries projection unnesting is not supported.") - - return self.sql(generate_series) - - def arrayconcat_sql( - self, expression: exp.ArrayConcat, name: str = "ARRAY_CONCAT" - ) -> str: - exprs = expression.expressions - if not self.ARRAY_CONCAT_IS_VAR_LEN: - if len(exprs) == 0: - rhs: t.Union[str, exp.Expression] = exp.Array(expressions=[]) - else: - rhs = reduce( - lambda x, y: exp.ArrayConcat(this=x, expressions=[y]), exprs - ) - else: - rhs = self.expressions(expression) # type: ignore - - return self.func(name, expression.this, rhs or None) - - def converttimezone_sql(self, expression: exp.ConvertTimezone) -> str: - if self.SUPPORTS_CONVERT_TIMEZONE: - return self.function_fallback_sql(expression) - - source_tz = expression.args.get("source_tz") - target_tz = expression.args.get("target_tz") - timestamp = expression.args.get("timestamp") - - if source_tz and timestamp: - timestamp = exp.AtTimeZone( - this=exp.cast(timestamp, exp.DataType.Type.TIMESTAMPNTZ), zone=source_tz - ) - - expr = exp.AtTimeZone(this=timestamp, zone=target_tz) - - return self.sql(expr) - - def json_sql(self, expression: exp.JSON) -> str: - this = self.sql(expression, "this") - this = f" {this}" if this else "" - - _with = expression.args.get("with_") - - if _with is None: - with_sql = "" - elif not _with: - with_sql = " WITHOUT" - else: - with_sql = " WITH" - - unique_sql = " UNIQUE KEYS" if expression.args.get("unique") else "" - - return f"JSON{this}{with_sql}{unique_sql}" - - def jsonvalue_sql(self, expression: exp.JSONValue) -> str: - def _generate_on_options(arg: t.Any) -> str: - return arg if isinstance(arg, str) else f"DEFAULT {self.sql(arg)}" - - path = self.sql(expression, "path") - returning = self.sql(expression, "returning") - returning = f" RETURNING {returning}" if returning else "" - - on_condition = self.sql(expression, "on_condition") - on_condition = f" {on_condition}" if on_condition else "" - - return self.func( - "JSON_VALUE", expression.this, f"{path}{returning}{on_condition}" - ) - - def conditionalinsert_sql(self, expression: exp.ConditionalInsert) -> str: - else_ = "ELSE " if expression.args.get("else_") else "" - condition = self.sql(expression, "expression") - condition = f"WHEN {condition} THEN " if condition else else_ - insert = self.sql(expression, "this")[len("INSERT") :].strip() - return f"{condition}{insert}" - - def multitableinserts_sql(self, expression: exp.MultitableInserts) -> str: - kind = self.sql(expression, "kind") - expressions = self.seg(self.expressions(expression, sep=" ")) - res = f"INSERT {kind}{expressions}{self.seg(self.sql(expression, 'source'))}" - return res - - def oncondition_sql(self, expression: exp.OnCondition) -> str: - # Static options like "NULL ON ERROR" are stored as strings, in contrast to "DEFAULT ON ERROR" - empty = expression.args.get("empty") - empty = ( - f"DEFAULT {empty} ON EMPTY" - if isinstance(empty, exp.Expression) - else self.sql(expression, "empty") - ) - - error = expression.args.get("error") - error = ( - f"DEFAULT {error} ON ERROR" - if isinstance(error, exp.Expression) - else self.sql(expression, "error") - ) - - if error and empty: - error = ( - f"{empty} {error}" - if self.dialect.ON_CONDITION_EMPTY_BEFORE_ERROR - else f"{error} {empty}" - ) - empty = "" - - null = self.sql(expression, "null") - - return f"{empty}{error}{null}" - - def jsonextractquote_sql(self, expression: exp.JSONExtractQuote) -> str: - scalar = " ON SCALAR STRING" if expression.args.get("scalar") else "" - return f"{self.sql(expression, 'option')} QUOTES{scalar}" - - def jsonexists_sql(self, expression: exp.JSONExists) -> str: - this = self.sql(expression, "this") - path = self.sql(expression, "path") - - passing = self.expressions(expression, "passing") - passing = f" PASSING {passing}" if passing else "" - - on_condition = self.sql(expression, "on_condition") - on_condition = f" {on_condition}" if on_condition else "" - - path = f"{path}{passing}{on_condition}" - - return self.func("JSON_EXISTS", this, path) - - def arrayagg_sql(self, expression: exp.ArrayAgg) -> str: - array_agg = self.function_fallback_sql(expression) - - # Add a NULL FILTER on the column to mimic the results going from a dialect that excludes nulls - # on ARRAY_AGG (e.g Spark) to one that doesn't (e.g. DuckDB) - if self.dialect.ARRAY_AGG_INCLUDES_NULLS and expression.args.get( - "nulls_excluded" - ): - parent = expression.parent - if isinstance(parent, exp.Filter): - parent_cond = parent.expression.this - parent_cond.replace( - parent_cond.and_(expression.this.is_(exp.null()).not_()) - ) - else: - this = expression.this - # Do not add the filter if the input is not a column (e.g. literal, struct etc) - if this.find(exp.Column): - # DISTINCT is already present in the agg function, do not propagate it to FILTER as well - this_sql = ( - self.expressions(this) - if isinstance(this, exp.Distinct) - else self.sql(expression, "this") - ) - - array_agg = f"{array_agg} FILTER(WHERE {this_sql} IS NOT NULL)" - - return array_agg - - def slice_sql(self, expression: exp.Slice) -> str: - step = self.sql(expression, "step") - end = self.sql(expression.expression) - begin = self.sql(expression.this) - - sql = f"{end}:{step}" if step else end - return f"{begin}:{sql}" if sql else f"{begin}:" - - def apply_sql(self, expression: exp.Apply) -> str: - this = self.sql(expression, "this") - expr = self.sql(expression, "expression") - - return f"{this} APPLY({expr})" - - def _grant_or_revoke_sql( - self, - expression: exp.Grant | exp.Revoke, - keyword: str, - preposition: str, - grant_option_prefix: str = "", - grant_option_suffix: str = "", - ) -> str: - privileges_sql = self.expressions(expression, key="privileges", flat=True) - - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - - securable = self.sql(expression, "securable") - securable = f" {securable}" if securable else "" - - principals = self.expressions(expression, key="principals", flat=True) - - if not expression.args.get("grant_option"): - grant_option_prefix = grant_option_suffix = "" - - # cascade for revoke only - cascade = self.sql(expression, "cascade") - cascade = f" {cascade}" if cascade else "" - - return f"{keyword} {grant_option_prefix}{privileges_sql} ON{kind}{securable} {preposition} {principals}{grant_option_suffix}{cascade}" - - def grant_sql(self, expression: exp.Grant) -> str: - return self._grant_or_revoke_sql( - expression, - keyword="GRANT", - preposition="TO", - grant_option_suffix=" WITH GRANT OPTION", - ) - - def revoke_sql(self, expression: exp.Revoke) -> str: - return self._grant_or_revoke_sql( - expression, - keyword="REVOKE", - preposition="FROM", - grant_option_prefix="GRANT OPTION FOR ", - ) - - def grantprivilege_sql(self, expression: exp.GrantPrivilege): - this = self.sql(expression, "this") - columns = self.expressions(expression, flat=True) - columns = f"({columns})" if columns else "" - - return f"{this}{columns}" - - def grantprincipal_sql(self, expression: exp.GrantPrincipal): - this = self.sql(expression, "this") - - kind = self.sql(expression, "kind") - kind = f"{kind} " if kind else "" - - return f"{kind}{this}" - - def columns_sql(self, expression: exp.Columns): - func = self.function_fallback_sql(expression) - if expression.args.get("unpack"): - func = f"*{func}" - - return func - - def overlay_sql(self, expression: exp.Overlay): - this = self.sql(expression, "this") - expr = self.sql(expression, "expression") - from_sql = self.sql(expression, "from_") - for_sql = self.sql(expression, "for_") - for_sql = f" FOR {for_sql}" if for_sql else "" - - return f"OVERLAY({this} PLACING {expr} FROM {from_sql}{for_sql})" - - @unsupported_args("format") - def todouble_sql(self, expression: exp.ToDouble) -> str: - return self.sql(exp.cast(expression.this, exp.DataType.Type.DOUBLE)) - - def string_sql(self, expression: exp.String) -> str: - this = expression.this - zone = expression.args.get("zone") - - if zone: - # This is a BigQuery specific argument for STRING(, ) - # BigQuery stores timestamps internally as UTC, so ConvertTimezone is used with UTC - # set for source_tz to transpile the time conversion before the STRING cast - this = exp.ConvertTimezone( - source_tz=exp.Literal.string("UTC"), target_tz=zone, timestamp=this - ) - - return self.sql(exp.cast(this, exp.DataType.Type.VARCHAR)) - - def median_sql(self, expression: exp.Median): - if not self.SUPPORTS_MEDIAN: - return self.sql( - exp.PercentileCont( - this=expression.this, expression=exp.Literal.number(0.5) - ) - ) - - return self.function_fallback_sql(expression) - - def overflowtruncatebehavior_sql( - self, expression: exp.OverflowTruncateBehavior - ) -> str: - filler = self.sql(expression, "this") - filler = f" {filler}" if filler else "" - with_count = ( - "WITH COUNT" if expression.args.get("with_count") else "WITHOUT COUNT" - ) - return f"TRUNCATE{filler} {with_count}" - - def unixseconds_sql(self, expression: exp.UnixSeconds) -> str: - if self.SUPPORTS_UNIX_SECONDS: - return self.function_fallback_sql(expression) - - start_ts = exp.cast( - exp.Literal.string("1970-01-01 00:00:00+00"), - to=exp.DataType.Type.TIMESTAMPTZ, - ) - - return self.sql( - exp.TimestampDiff( - this=expression.this, expression=start_ts, unit=exp.var("SECONDS") - ) - ) - - def arraysize_sql(self, expression: exp.ArraySize) -> str: - dim = expression.expression - - # For dialects that don't support the dimension arg, we can safely transpile it's default value (1st dimension) - if dim and self.ARRAY_SIZE_DIM_REQUIRED is None: - if not (dim.is_int and dim.name == "1"): - self.unsupported("Cannot transpile dimension argument for ARRAY_LENGTH") - dim = None - - # If dimension is required but not specified, default initialize it - if self.ARRAY_SIZE_DIM_REQUIRED and not dim: - dim = exp.Literal.number(1) - - return self.func(self.ARRAY_SIZE_NAME, expression.this, dim) - - def attach_sql(self, expression: exp.Attach) -> str: - this = self.sql(expression, "this") - exists_sql = " IF NOT EXISTS" if expression.args.get("exists") else "" - expressions = self.expressions(expression) - expressions = f" ({expressions})" if expressions else "" - - return f"ATTACH{exists_sql} {this}{expressions}" - - def detach_sql(self, expression: exp.Detach) -> str: - this = self.sql(expression, "this") - # the DATABASE keyword is required if IF EXISTS is set - # without it, DuckDB throws an error: Parser Error: syntax error at or near "exists" (Line Number: 1) - # ref: https://duckdb.org/docs/stable/sql/statements/attach.html#detach-syntax - exists_sql = " DATABASE IF EXISTS" if expression.args.get("exists") else "" - - return f"DETACH{exists_sql} {this}" - - def attachoption_sql(self, expression: exp.AttachOption) -> str: - this = self.sql(expression, "this") - value = self.sql(expression, "expression") - value = f" {value}" if value else "" - return f"{this}{value}" - - def watermarkcolumnconstraint_sql( - self, expression: exp.WatermarkColumnConstraint - ) -> str: - return f"WATERMARK FOR {self.sql(expression, 'this')} AS {self.sql(expression, 'expression')}" - - def encodeproperty_sql(self, expression: exp.EncodeProperty) -> str: - encode = "KEY ENCODE" if expression.args.get("key") else "ENCODE" - encode = f"{encode} {self.sql(expression, 'this')}" - - properties = expression.args.get("properties") - if properties: - encode = f"{encode} {self.properties(properties)}" - - return encode - - def includeproperty_sql(self, expression: exp.IncludeProperty) -> str: - this = self.sql(expression, "this") - include = f"INCLUDE {this}" - - column_def = self.sql(expression, "column_def") - if column_def: - include = f"{include} {column_def}" - - alias = self.sql(expression, "alias") - if alias: - include = f"{include} AS {alias}" - - return include - - def xmlelement_sql(self, expression: exp.XMLElement) -> str: - name = f"NAME {self.sql(expression, 'this')}" - return self.func("XMLELEMENT", name, *expression.expressions) - - def xmlkeyvalueoption_sql(self, expression: exp.XMLKeyValueOption) -> str: - this = self.sql(expression, "this") - expr = self.sql(expression, "expression") - expr = f"({expr})" if expr else "" - return f"{this}{expr}" - - def partitionbyrangeproperty_sql( - self, expression: exp.PartitionByRangeProperty - ) -> str: - partitions = self.expressions(expression, "partition_expressions") - create = self.expressions(expression, "create_expressions") - return f"PARTITION BY RANGE {self.wrap(partitions)} {self.wrap(create)}" - - def partitionbyrangepropertydynamic_sql( - self, expression: exp.PartitionByRangePropertyDynamic - ) -> str: - start = self.sql(expression, "start") - end = self.sql(expression, "end") - - every = expression.args["every"] - if isinstance(every, exp.Interval) and every.this.is_string: - every.this.replace(exp.Literal.number(every.name)) - - return f"START {self.wrap(start)} END {self.wrap(end)} EVERY {self.wrap(self.sql(every))}" - - def unpivotcolumns_sql(self, expression: exp.UnpivotColumns) -> str: - name = self.sql(expression, "this") - values = self.expressions(expression, flat=True) - - return f"NAME {name} VALUE {values}" - - def analyzesample_sql(self, expression: exp.AnalyzeSample) -> str: - kind = self.sql(expression, "kind") - sample = self.sql(expression, "sample") - return f"SAMPLE {sample} {kind}" - - def analyzestatistics_sql(self, expression: exp.AnalyzeStatistics) -> str: - kind = self.sql(expression, "kind") - option = self.sql(expression, "option") - option = f" {option}" if option else "" - this = self.sql(expression, "this") - this = f" {this}" if this else "" - columns = self.expressions(expression) - columns = f" {columns}" if columns else "" - return f"{kind}{option} STATISTICS{this}{columns}" - - def analyzehistogram_sql(self, expression: exp.AnalyzeHistogram) -> str: - this = self.sql(expression, "this") - columns = self.expressions(expression) - inner_expression = self.sql(expression, "expression") - inner_expression = f" {inner_expression}" if inner_expression else "" - update_options = self.sql(expression, "update_options") - update_options = f" {update_options} UPDATE" if update_options else "" - return f"{this} HISTOGRAM ON {columns}{inner_expression}{update_options}" - - def analyzedelete_sql(self, expression: exp.AnalyzeDelete) -> str: - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - return f"DELETE{kind} STATISTICS" - - def analyzelistchainedrows_sql(self, expression: exp.AnalyzeListChainedRows) -> str: - inner_expression = self.sql(expression, "expression") - return f"LIST CHAINED ROWS{inner_expression}" - - def analyzevalidate_sql(self, expression: exp.AnalyzeValidate) -> str: - kind = self.sql(expression, "kind") - this = self.sql(expression, "this") - this = f" {this}" if this else "" - inner_expression = self.sql(expression, "expression") - return f"VALIDATE {kind}{this}{inner_expression}" - - def analyze_sql(self, expression: exp.Analyze) -> str: - options = self.expressions(expression, key="options", sep=" ") - options = f" {options}" if options else "" - kind = self.sql(expression, "kind") - kind = f" {kind}" if kind else "" - this = self.sql(expression, "this") - this = f" {this}" if this else "" - mode = self.sql(expression, "mode") - mode = f" {mode}" if mode else "" - properties = self.sql(expression, "properties") - properties = f" {properties}" if properties else "" - partition = self.sql(expression, "partition") - partition = f" {partition}" if partition else "" - inner_expression = self.sql(expression, "expression") - inner_expression = f" {inner_expression}" if inner_expression else "" - return f"ANALYZE{options}{kind}{this}{partition}{mode}{inner_expression}{properties}" - - def xmltable_sql(self, expression: exp.XMLTable) -> str: - this = self.sql(expression, "this") - namespaces = self.expressions(expression, key="namespaces") - namespaces = f"XMLNAMESPACES({namespaces}), " if namespaces else "" - passing = self.expressions(expression, key="passing") - passing = f"{self.sep()}PASSING{self.seg(passing)}" if passing else "" - columns = self.expressions(expression, key="columns") - columns = f"{self.sep()}COLUMNS{self.seg(columns)}" if columns else "" - by_ref = ( - f"{self.sep()}RETURNING SEQUENCE BY REF" - if expression.args.get("by_ref") - else "" - ) - return f"XMLTABLE({self.sep('')}{self.indent(namespaces + this + passing + by_ref + columns)}{self.seg(')', sep='')}" - - def xmlnamespace_sql(self, expression: exp.XMLNamespace) -> str: - this = self.sql(expression, "this") - return this if isinstance(expression.this, exp.Alias) else f"DEFAULT {this}" - - def export_sql(self, expression: exp.Export) -> str: - this = self.sql(expression, "this") - connection = self.sql(expression, "connection") - connection = f"WITH CONNECTION {connection} " if connection else "" - options = self.sql(expression, "options") - return f"EXPORT DATA {connection}{options} AS {this}" - - def declare_sql(self, expression: exp.Declare) -> str: - return f"DECLARE {self.expressions(expression, flat=True)}" - - def declareitem_sql(self, expression: exp.DeclareItem) -> str: - variable = self.sql(expression, "this") - default = self.sql(expression, "default") - default = f" = {default}" if default else "" - - kind = self.sql(expression, "kind") - if isinstance(expression.args.get("kind"), exp.Schema): - kind = f"TABLE {kind}" - - return f"{variable} AS {kind}{default}" - - def recursivewithsearch_sql(self, expression: exp.RecursiveWithSearch) -> str: - kind = self.sql(expression, "kind") - this = self.sql(expression, "this") - set = self.sql(expression, "expression") - using = self.sql(expression, "using") - using = f" USING {using}" if using else "" - - kind_sql = kind if kind == "CYCLE" else f"SEARCH {kind} FIRST BY" - - return f"{kind_sql} {this} SET {set}{using}" - - def parameterizedagg_sql(self, expression: exp.ParameterizedAgg) -> str: - params = self.expressions(expression, key="params", flat=True) - return self.func(expression.name, *expression.expressions) + f"({params})" - - def anonymousaggfunc_sql(self, expression: exp.AnonymousAggFunc) -> str: - return self.func(expression.name, *expression.expressions) - - def combinedaggfunc_sql(self, expression: exp.CombinedAggFunc) -> str: - return self.anonymousaggfunc_sql(expression) - - def combinedparameterizedagg_sql( - self, expression: exp.CombinedParameterizedAgg - ) -> str: - return self.parameterizedagg_sql(expression) - - def show_sql(self, expression: exp.Show) -> str: - self.unsupported("Unsupported SHOW statement") - return "" - - def install_sql(self, expression: exp.Install) -> str: - self.unsupported("Unsupported INSTALL statement") - return "" - - def get_put_sql(self, expression: exp.Put | exp.Get) -> str: - # Snowflake GET/PUT statements: - # PUT - # GET - props = expression.args.get("properties") - props_sql = ( - self.properties(props, prefix=" ", sep=" ", wrapped=False) if props else "" - ) - this = self.sql(expression, "this") - target = self.sql(expression, "target") - - if isinstance(expression, exp.Put): - return f"PUT {this} {target}{props_sql}" - else: - return f"GET {target} {this}{props_sql}" - - def translatecharacters_sql(self, expression: exp.TranslateCharacters): - this = self.sql(expression, "this") - expr = self.sql(expression, "expression") - with_error = " WITH ERROR" if expression.args.get("with_error") else "" - return f"TRANSLATE({this} USING {expr}{with_error})" - - def decodecase_sql(self, expression: exp.DecodeCase) -> str: - if self.SUPPORTS_DECODE_CASE: - return self.func("DECODE", *expression.expressions) - - expression, *expressions = expression.expressions - - ifs = [] - for search, result in zip(expressions[::2], expressions[1::2]): - if isinstance(search, exp.Literal): - ifs.append(exp.If(this=expression.eq(search), true=result)) - elif isinstance(search, exp.Null): - ifs.append(exp.If(this=expression.is_(exp.Null()), true=result)) - else: - if isinstance(search, exp.Binary): - search = exp.paren(search) - - cond = exp.or_( - expression.eq(search), - exp.and_( - expression.is_(exp.Null()), search.is_(exp.Null()), copy=False - ), - copy=False, - ) - ifs.append(exp.If(this=cond, true=result)) - - case = exp.Case( - ifs=ifs, default=expressions[-1] if len(expressions) % 2 == 1 else None - ) - return self.sql(case) - - def semanticview_sql(self, expression: exp.SemanticView) -> str: - this = self.sql(expression, "this") - this = self.seg(this, sep="") - dimensions = self.expressions( - expression, "dimensions", dynamic=True, skip_first=True, skip_last=True - ) - dimensions = self.seg(f"DIMENSIONS {dimensions}") if dimensions else "" - metrics = self.expressions( - expression, "metrics", dynamic=True, skip_first=True, skip_last=True - ) - metrics = self.seg(f"METRICS {metrics}") if metrics else "" - facts = self.expressions( - expression, "facts", dynamic=True, skip_first=True, skip_last=True - ) - facts = self.seg(f"FACTS {facts}") if facts else "" - where = self.sql(expression, "where") - where = self.seg(f"WHERE {where}") if where else "" - body = self.indent(this + metrics + dimensions + facts + where, skip_first=True) - return f"SEMANTIC_VIEW({body}{self.seg(')', sep='')}" - - def getextract_sql(self, expression: exp.GetExtract) -> str: - this = expression.this - expr = expression.expression - - if not this.type or not expression.type: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - this = annotate_types(this, dialect=self.dialect) - - if this.is_type(*(exp.DataType.Type.ARRAY, exp.DataType.Type.MAP)): - return self.sql(exp.Bracket(this=this, expressions=[expr])) - - return self.sql( - exp.JSONExtract(this=this, expression=self.dialect.to_json_path(expr)) - ) - - def datefromunixdate_sql(self, expression: exp.DateFromUnixDate) -> str: - return self.sql( - exp.DateAdd( - this=exp.cast(exp.Literal.string("1970-01-01"), exp.DataType.Type.DATE), - expression=expression.this, - unit=exp.var("DAY"), - ) - ) - - def space_sql(self: Generator, expression: exp.Space) -> str: - return self.sql(exp.Repeat(this=exp.Literal.string(" "), times=expression.this)) - - def buildproperty_sql(self, expression: exp.BuildProperty) -> str: - return f"BUILD {self.sql(expression, 'this')}" - - def refreshtriggerproperty_sql(self, expression: exp.RefreshTriggerProperty) -> str: - method = self.sql(expression, "method") - kind = expression.args.get("kind") - if not kind: - return f"REFRESH {method}" - - every = self.sql(expression, "every") - unit = self.sql(expression, "unit") - every = f" EVERY {every} {unit}" if every else "" - starts = self.sql(expression, "starts") - starts = f" STARTS {starts}" if starts else "" - - return f"REFRESH {method} ON {kind}{every}{starts}" - - def modelattribute_sql(self, expression: exp.ModelAttribute) -> str: - self.unsupported("The model!attribute syntax is not supported") - return "" - - def directorystage_sql(self, expression: exp.DirectoryStage) -> str: - return self.func("DIRECTORY", expression.this) - - def uuid_sql(self, expression: exp.Uuid) -> str: - is_string = expression.args.get("is_string", False) - uuid_func_sql = self.func("UUID") - - if is_string and not self.dialect.UUID_IS_STRING_TYPE: - return self.sql( - exp.cast(uuid_func_sql, exp.DataType.Type.VARCHAR, dialect=self.dialect) - ) - - return uuid_func_sql - - def initcap_sql(self, expression: exp.Initcap) -> str: - delimiters = expression.expression - - if delimiters: - # do not generate delimiters arg if we are round-tripping from default delimiters - if ( - delimiters.is_string - and delimiters.this == self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS - ): - delimiters = None - elif not self.dialect.INITCAP_SUPPORTS_CUSTOM_DELIMITERS: - self.unsupported("INITCAP does not support custom delimiters") - delimiters = None - - return self.func("INITCAP", expression.this, delimiters) - - def localtime_sql(self, expression: exp.Localtime) -> str: - this = expression.this - return self.func("LOCALTIME", this) if this else "LOCALTIME" - - def localtimestamp_sql(self, expression: exp.Localtime) -> str: - this = expression.this - return self.func("LOCALTIMESTAMP", this) if this else "LOCALTIMESTAMP" - - def weekstart_sql(self, expression: exp.WeekStart) -> str: - this = expression.this.name.upper() - if self.dialect.WEEK_OFFSET == -1 and this == "SUNDAY": - # BigQuery specific optimization since WEEK(SUNDAY) == WEEK - return "WEEK" - - return self.func("WEEK", expression.this) diff --git a/third_party/bigframes_vendored/sqlglot/helper.py b/third_party/bigframes_vendored/sqlglot/helper.py deleted file mode 100644 index 5cd16e2c3cd..00000000000 --- a/third_party/bigframes_vendored/sqlglot/helper.py +++ /dev/null @@ -1,532 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/helper.py - -from __future__ import annotations - -import datetime -import inspect -import logging -import re -import sys -import typing as t -from collections.abc import Collection, Set -from copy import copy -from difflib import get_close_matches -from enum import Enum -from itertools import count - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot import exp - from bigframes_vendored.sqlglot._typing import A, E, T - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - from bigframes_vendored.sqlglot.expressions import Expression - - -CAMEL_CASE_PATTERN = re.compile("(? t.Any: - return classmethod(self.fget).__get__(None, owner)() # type: ignore - - -def suggest_closest_match_and_fail( - kind: str, - word: str, - possibilities: t.Iterable[str], -) -> None: - close_matches = get_close_matches(word, possibilities, n=1) - - similar = seq_get(close_matches, 0) or "" - if similar: - similar = f" Did you mean {similar}?" - - raise ValueError(f"Unknown {kind} '{word}'.{similar}") - - -def seq_get(seq: t.Sequence[T], index: int) -> t.Optional[T]: - """Returns the value in `seq` at position `index`, or `None` if `index` is out of bounds.""" - try: - return seq[index] - except IndexError: - return None - - -@t.overload -def ensure_list(value: t.Collection[T]) -> t.List[T]: ... - - -@t.overload -def ensure_list(value: None) -> t.List: ... - - -@t.overload -def ensure_list(value: T) -> t.List[T]: ... - - -def ensure_list(value): - """ - Ensures that a value is a list, otherwise casts or wraps it into one. - - Args: - value: The value of interest. - - Returns: - The value cast as a list if it's a list or a tuple, or else the value wrapped in a list. - """ - if value is None: - return [] - if isinstance(value, (list, tuple)): - return list(value) - - return [value] - - -@t.overload -def ensure_collection(value: t.Collection[T]) -> t.Collection[T]: ... - - -@t.overload -def ensure_collection(value: T) -> t.Collection[T]: ... - - -def ensure_collection(value): - """ - Ensures that a value is a collection (excluding `str` and `bytes`), otherwise wraps it into a list. - - Args: - value: The value of interest. - - Returns: - The value if it's a collection, or else the value wrapped in a list. - """ - if value is None: - return [] - return ( - value - if isinstance(value, Collection) and not isinstance(value, (str, bytes)) - else [value] - ) - - -def csv(*args: str, sep: str = ", ") -> str: - """ - Formats any number of string arguments as CSV. - - Args: - args: The string arguments to format. - sep: The argument separator. - - Returns: - The arguments formatted as a CSV string. - """ - return sep.join(arg for arg in args if arg) - - -def subclasses( - module_name: str, - classes: t.Type | t.Tuple[t.Type, ...], - exclude: t.Set[t.Type] = set(), -) -> t.List[t.Type]: - """ - Returns all subclasses for a collection of classes, possibly excluding some of them. - - Args: - module_name: The name of the module to search for subclasses in. - classes: Class(es) we want to find the subclasses of. - exclude: Classes we want to exclude from the returned list. - - Returns: - The target subclasses. - """ - return [ - obj - for _, obj in inspect.getmembers( - sys.modules[module_name], - lambda obj: inspect.isclass(obj) - and issubclass(obj, classes) - and obj not in exclude, - ) - ] - - -def apply_index_offset( - this: exp.Expression, - expressions: t.List[E], - offset: int, - dialect: DialectType = None, -) -> t.List[E]: - """ - Applies an offset to a given integer literal expression. - - Args: - this: The target of the index. - expressions: The expression the offset will be applied to, wrapped in a list. - offset: The offset that will be applied. - dialect: the dialect of interest. - - Returns: - The original expression with the offset applied to it, wrapped in a list. If the provided - `expressions` argument contains more than one expression, it's returned unaffected. - """ - if not offset or len(expressions) != 1: - return expressions - - expression = expressions[0] - - from bigframes_vendored.sqlglot import exp - from bigframes_vendored.sqlglot.optimizer.annotate_types import annotate_types - from bigframes_vendored.sqlglot.optimizer.simplify import simplify - - if not this.type: - annotate_types(this, dialect=dialect) - - if t.cast(exp.DataType, this.type).this not in ( - exp.DataType.Type.UNKNOWN, - exp.DataType.Type.ARRAY, - ): - return expressions - - if not expression.type: - annotate_types(expression, dialect=dialect) - - if t.cast(exp.DataType, expression.type).this in exp.DataType.INTEGER_TYPES: - logger.info("Applying array index offset (%s)", offset) - expression = simplify(expression + offset) - return [expression] - - return expressions - - -def camel_to_snake_case(name: str) -> str: - """Converts `name` from camelCase to snake_case and returns the result.""" - return CAMEL_CASE_PATTERN.sub("_", name).upper() - - -def while_changing(expression: Expression, func: t.Callable[[Expression], E]) -> E: - """ - Applies a transformation to a given expression until a fix point is reached. - - Args: - expression: The expression to be transformed. - func: The transformation to be applied. - - Returns: - The transformed expression. - """ - - while True: - start_hash = hash(expression) - expression = func(expression) - end_hash = hash(expression) - - if start_hash == end_hash: - break - - return expression - - -def tsort(dag: t.Dict[T, t.Set[T]]) -> t.List[T]: - """ - Sorts a given directed acyclic graph in topological order. - - Args: - dag: The graph to be sorted. - - Returns: - A list that contains all of the graph's nodes in topological order. - """ - result = [] - - for node, deps in tuple(dag.items()): - for dep in deps: - if dep not in dag: - dag[dep] = set() - - while dag: - current = {node for node, deps in dag.items() if not deps} - - if not current: - raise ValueError("Cycle error") - - for node in current: - dag.pop(node) - - for deps in dag.values(): - deps -= current - - result.extend(sorted(current)) # type: ignore - - return result - - -def find_new_name(taken: t.Collection[str], base: str) -> str: - """ - Searches for a new name. - - Args: - taken: A collection of taken names. - base: Base name to alter. - - Returns: - The new, available name. - """ - if base not in taken: - return base - - i = 2 - new = f"{base}_{i}" - while new in taken: - i += 1 - new = f"{base}_{i}" - - return new - - -def is_int(text: str) -> bool: - return is_type(text, int) - - -def is_float(text: str) -> bool: - return is_type(text, float) - - -def is_type(text: str, target_type: t.Type) -> bool: - try: - target_type(text) - return True - except ValueError: - return False - - -def name_sequence(prefix: str) -> t.Callable[[], str]: - """Returns a name generator given a prefix (e.g. a0, a1, a2, ... if the prefix is "a").""" - sequence = count() - return lambda: f"{prefix}{next(sequence)}" - - -def object_to_dict(obj: t.Any, **kwargs) -> t.Dict: - """Returns a dictionary created from an object's attributes.""" - return { - **{ - k: v.copy() if hasattr(v, "copy") else copy(v) for k, v in vars(obj).items() - }, - **kwargs, - } - - -def split_num_words( - value: str, sep: str, min_num_words: int, fill_from_start: bool = True -) -> t.List[t.Optional[str]]: - """ - Perform a split on a value and return N words as a result with `None` used for words that don't exist. - - Args: - value: The value to be split. - sep: The value to use to split on. - min_num_words: The minimum number of words that are going to be in the result. - fill_from_start: Indicates that if `None` values should be inserted at the start or end of the list. - - Examples: - >>> split_num_words("db.table", ".", 3) - [None, 'db', 'table'] - >>> split_num_words("db.table", ".", 3, fill_from_start=False) - ['db', 'table', None] - >>> split_num_words("db.table", ".", 1) - ['db', 'table'] - - Returns: - The list of words returned by `split`, possibly augmented by a number of `None` values. - """ - words = value.split(sep) - if fill_from_start: - return [None] * (min_num_words - len(words)) + words - return words + [None] * (min_num_words - len(words)) - - -def is_iterable(value: t.Any) -> bool: - """ - Checks if the value is an iterable, excluding the types `str` and `bytes`. - - Examples: - >>> is_iterable([1,2]) - True - >>> is_iterable("test") - False - - Args: - value: The value to check if it is an iterable. - - Returns: - A `bool` value indicating if it is an iterable. - """ - from bigframes_vendored.sqlglot import Expression - - return hasattr(value, "__iter__") and not isinstance( - value, (str, bytes, Expression) - ) - - -def flatten(values: t.Iterable[t.Iterable[t.Any] | t.Any]) -> t.Iterator[t.Any]: - """ - Flattens an iterable that can contain both iterable and non-iterable elements. Objects of - type `str` and `bytes` are not regarded as iterables. - - Examples: - >>> list(flatten([[1, 2], 3, {4}, (5, "bla")])) - [1, 2, 3, 4, 5, 'bla'] - >>> list(flatten([1, 2, 3])) - [1, 2, 3] - - Args: - values: The value to be flattened. - - Yields: - Non-iterable elements in `values`. - """ - for value in values: - if is_iterable(value): - yield from flatten(value) - else: - yield value - - -def dict_depth(d: t.Dict) -> int: - """ - Get the nesting depth of a dictionary. - - Example: - >>> dict_depth(None) - 0 - >>> dict_depth({}) - 1 - >>> dict_depth({"a": "b"}) - 1 - >>> dict_depth({"a": {}}) - 2 - >>> dict_depth({"a": {"b": {}}}) - 3 - """ - try: - return 1 + dict_depth(next(iter(d.values()))) - except AttributeError: - # d doesn't have attribute "values" - return 0 - except StopIteration: - # d.values() returns an empty sequence - return 1 - - -def first(it: t.Iterable[T]) -> T: - """Returns the first element from an iterable (useful for sets).""" - return next(i for i in it) - - -def to_bool(value: t.Optional[str | bool]) -> t.Optional[str | bool]: - if isinstance(value, bool) or value is None: - return value - - # Coerce the value to boolean if it matches to the truthy/falsy values below - value_lower = value.lower() - if value_lower in ("true", "1"): - return True - if value_lower in ("false", "0"): - return False - - return value - - -def merge_ranges(ranges: t.List[t.Tuple[A, A]]) -> t.List[t.Tuple[A, A]]: - """ - Merges a sequence of ranges, represented as tuples (low, high) whose values - belong to some totally-ordered set. - - Example: - >>> merge_ranges([(1, 3), (2, 6)]) - [(1, 6)] - """ - if not ranges: - return [] - - ranges = sorted(ranges) - - merged = [ranges[0]] - - for start, end in ranges[1:]: - last_start, last_end = merged[-1] - - if start <= last_end: - merged[-1] = (last_start, max(last_end, end)) - else: - merged.append((start, end)) - - return merged - - -def is_iso_date(text: str) -> bool: - try: - datetime.date.fromisoformat(text) - return True - except ValueError: - return False - - -def is_iso_datetime(text: str) -> bool: - try: - datetime.datetime.fromisoformat(text) - return True - except ValueError: - return False - - -# Interval units that operate on date components -DATE_UNITS = {"day", "week", "month", "quarter", "year", "year_month"} - - -def is_date_unit(expression: t.Optional[exp.Expression]) -> bool: - return expression is not None and expression.name.lower() in DATE_UNITS - - -K = t.TypeVar("K") -V = t.TypeVar("V") - - -class SingleValuedMapping(t.Mapping[K, V]): - """ - Mapping where all keys return the same value. - - This rigamarole is meant to avoid copying keys, which was originally intended - as an optimization while qualifying columns for tables with lots of columns. - """ - - def __init__(self, keys: t.Collection[K], value: V): - self._keys = keys if isinstance(keys, Set) else set(keys) - self._value = value - - def __getitem__(self, key: K) -> V: - if key in self._keys: - return self._value - raise KeyError(key) - - def __len__(self) -> int: - return len(self._keys) - - def __iter__(self) -> t.Iterator[K]: - return iter(self._keys) diff --git a/third_party/bigframes_vendored/sqlglot/jsonpath.py b/third_party/bigframes_vendored/sqlglot/jsonpath.py deleted file mode 100644 index cc5ac7edd3d..00000000000 --- a/third_party/bigframes_vendored/sqlglot/jsonpath.py +++ /dev/null @@ -1,238 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/jsonpath.py - -from __future__ import annotations - -import typing as t - -import bigframes_vendored.sqlglot.expressions as exp -from bigframes_vendored.sqlglot.errors import ParseError -from bigframes_vendored.sqlglot.tokens import Token, Tokenizer, TokenType - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import Lit - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - -class JSONPathTokenizer(Tokenizer): - SINGLE_TOKENS = { - "(": TokenType.L_PAREN, - ")": TokenType.R_PAREN, - "[": TokenType.L_BRACKET, - "]": TokenType.R_BRACKET, - ":": TokenType.COLON, - ",": TokenType.COMMA, - "-": TokenType.DASH, - ".": TokenType.DOT, - "?": TokenType.PLACEHOLDER, - "@": TokenType.PARAMETER, - "'": TokenType.QUOTE, - '"': TokenType.QUOTE, - "$": TokenType.DOLLAR, - "*": TokenType.STAR, - } - - KEYWORDS = { - "..": TokenType.DOT, - } - - IDENTIFIER_ESCAPES = ["\\"] - STRING_ESCAPES = ["\\"] - - VAR_TOKENS = { - TokenType.VAR, - } - - -def parse(path: str, dialect: DialectType = None) -> exp.JSONPath: - """Takes in a JSON path string and parses it into a JSONPath expression.""" - from bigframes_vendored.sqlglot.dialects import Dialect - - jsonpath_tokenizer = Dialect.get_or_raise(dialect).jsonpath_tokenizer() - tokens = jsonpath_tokenizer.tokenize(path) - size = len(tokens) - - i = 0 - - def _curr() -> t.Optional[TokenType]: - return tokens[i].token_type if i < size else None - - def _prev() -> Token: - return tokens[i - 1] - - def _advance() -> Token: - nonlocal i - i += 1 - return _prev() - - def _error(msg: str) -> str: - return f"{msg} at index {i}: {path}" - - @t.overload - def _match(token_type: TokenType, raise_unmatched: Lit[True] = True) -> Token: - pass - - @t.overload - def _match( - token_type: TokenType, raise_unmatched: Lit[False] = False - ) -> t.Optional[Token]: - pass - - def _match(token_type, raise_unmatched=False): - if _curr() == token_type: - return _advance() - if raise_unmatched: - raise ParseError(_error(f"Expected {token_type}")) - return None - - def _match_set(types: t.Collection[TokenType]) -> t.Optional[Token]: - return _advance() if _curr() in types else None - - def _parse_literal() -> t.Any: - token = _match(TokenType.STRING) or _match(TokenType.IDENTIFIER) - if token: - return token.text - if _match(TokenType.STAR): - return exp.JSONPathWildcard() - if _match(TokenType.PLACEHOLDER) or _match(TokenType.L_PAREN): - script = _prev().text == "(" - start = i - - while True: - if _match(TokenType.L_BRACKET): - _parse_bracket() # nested call which we can throw away - if _curr() in (TokenType.R_BRACKET, None): - break - _advance() - - expr_type = exp.JSONPathScript if script else exp.JSONPathFilter - return expr_type(this=path[tokens[start].start : tokens[i].end]) - - number = "-" if _match(TokenType.DASH) else "" - - token = _match(TokenType.NUMBER) - if token: - number += token.text - - if number: - return int(number) - - return False - - def _parse_slice() -> t.Any: - start = _parse_literal() - end = _parse_literal() if _match(TokenType.COLON) else None - step = _parse_literal() if _match(TokenType.COLON) else None - - if end is None and step is None: - return start - - return exp.JSONPathSlice(start=start, end=end, step=step) - - def _parse_bracket() -> exp.JSONPathPart: - literal = _parse_slice() - - if isinstance(literal, str) or literal is not False: - indexes = [literal] - while _match(TokenType.COMMA): - literal = _parse_slice() - - if literal: - indexes.append(literal) - - if len(indexes) == 1: - if isinstance(literal, str): - node: exp.JSONPathPart = exp.JSONPathKey(this=indexes[0]) - elif isinstance(literal, exp.JSONPathPart) and isinstance( - literal, (exp.JSONPathScript, exp.JSONPathFilter) - ): - node = exp.JSONPathSelector(this=indexes[0]) - else: - node = exp.JSONPathSubscript(this=indexes[0]) - else: - node = exp.JSONPathUnion(expressions=indexes) - else: - raise ParseError(_error("Cannot have empty segment")) - - _match(TokenType.R_BRACKET, raise_unmatched=True) - - return node - - def _parse_var_text() -> str: - """ - Consumes & returns the text for a var. In BigQuery it's valid to have a key with spaces - in it, e.g JSON_QUERY(..., '$. a b c ') should produce a single JSONPathKey(' a b c '). - This is done by merging "consecutive" vars until a key separator is found (dot, colon etc) - or the path string is exhausted. - """ - prev_index = i - 2 - - while _match_set(jsonpath_tokenizer.VAR_TOKENS): - pass - - start = 0 if prev_index < 0 else tokens[prev_index].end + 1 - - if i >= len(tokens): - # This key is the last token for the path, so it's text is the remaining path - text = path[start:] - else: - text = path[start : tokens[i].start] - - return text - - # We canonicalize the JSON path AST so that it always starts with a - # "root" element, so paths like "field" will be generated as "$.field" - _match(TokenType.DOLLAR) - expressions: t.List[exp.JSONPathPart] = [exp.JSONPathRoot()] - - while _curr(): - if _match(TokenType.DOT) or _match(TokenType.COLON): - recursive = _prev().text == ".." - - if _match_set(jsonpath_tokenizer.VAR_TOKENS): - value: t.Optional[str | exp.JSONPathWildcard] = _parse_var_text() - elif _match(TokenType.IDENTIFIER): - value = _prev().text - elif _match(TokenType.STAR): - value = exp.JSONPathWildcard() - else: - value = None - - if recursive: - expressions.append(exp.JSONPathRecursive(this=value)) - elif value: - expressions.append(exp.JSONPathKey(this=value)) - else: - raise ParseError(_error("Expected key name or * after DOT")) - elif _match(TokenType.L_BRACKET): - expressions.append(_parse_bracket()) - elif _match_set(jsonpath_tokenizer.VAR_TOKENS): - expressions.append(exp.JSONPathKey(this=_parse_var_text())) - elif _match(TokenType.IDENTIFIER): - expressions.append(exp.JSONPathKey(this=_prev().text)) - elif _match(TokenType.STAR): - expressions.append(exp.JSONPathWildcard()) - else: - raise ParseError(_error(f"Unexpected {tokens[i].token_type}")) - - return exp.JSONPath(expressions=expressions) - - -JSON_PATH_PART_TRANSFORMS: t.Dict[t.Type[exp.Expression], t.Callable[..., str]] = { - exp.JSONPathFilter: lambda _, e: f"?{e.this}", - exp.JSONPathKey: lambda self, e: self._jsonpathkey_sql(e), - exp.JSONPathRecursive: lambda _, e: f"..{e.this or ''}", - exp.JSONPathRoot: lambda *_: "$", - exp.JSONPathScript: lambda _, e: f"({e.this}", - exp.JSONPathSelector: lambda self, e: f"[{self.json_path_part(e.this)}]", - exp.JSONPathSlice: lambda self, e: ":".join( - "" if p is False else self.json_path_part(p) - for p in [e.args.get("start"), e.args.get("end"), e.args.get("step")] - if p is not None - ), - exp.JSONPathSubscript: lambda self, e: self._jsonpathsubscript_sql(e), - exp.JSONPathUnion: lambda self, - e: f"[{','.join(self.json_path_part(p) for p in e.expressions)}]", - exp.JSONPathWildcard: lambda *_: "*", -} - -ALL_JSON_PATH_PARTS = set(JSON_PATH_PART_TRANSFORMS) diff --git a/third_party/bigframes_vendored/sqlglot/lineage.py b/third_party/bigframes_vendored/sqlglot/lineage.py deleted file mode 100644 index 826e64bfdc8..00000000000 --- a/third_party/bigframes_vendored/sqlglot/lineage.py +++ /dev/null @@ -1,455 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/lineage.py - -from __future__ import annotations - -import json -import logging -import typing as t -from dataclasses import dataclass, field - -from bigframes_vendored.sqlglot import Schema, exp, maybe_parse -from bigframes_vendored.sqlglot.errors import SqlglotError -from bigframes_vendored.sqlglot.optimizer import ( - Scope, - build_scope, - find_all_in_scope, - normalize_identifiers, - qualify, -) -from bigframes_vendored.sqlglot.optimizer.scope import ScopeType - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - -logger = logging.getLogger("sqlglot") - - -@dataclass(frozen=True) -class Node: - name: str - expression: exp.Expression - source: exp.Expression - downstream: t.List[Node] = field(default_factory=list) - source_name: str = "" - reference_node_name: str = "" - - def walk(self) -> t.Iterator[Node]: - yield self - - for d in self.downstream: - yield from d.walk() - - def to_html(self, dialect: DialectType = None, **opts) -> GraphHTML: - nodes = {} - edges = [] - - for node in self.walk(): - if isinstance(node.expression, exp.Table): - label = f"FROM {node.expression.this}" - title = f"
SELECT {node.name} FROM {node.expression.this}
" - group = 1 - else: - label = node.expression.sql(pretty=True, dialect=dialect) - source = node.source.transform( - lambda n: ( - exp.Tag(this=n, prefix="", postfix="") - if n is node.expression - else n - ), - copy=False, - ).sql(pretty=True, dialect=dialect) - title = f"
{source}
" - group = 0 - - node_id = id(node) - - nodes[node_id] = { - "id": node_id, - "label": label, - "title": title, - "group": group, - } - - for d in node.downstream: - edges.append({"from": node_id, "to": id(d)}) - return GraphHTML(nodes, edges, **opts) - - -def lineage( - column: str | exp.Column, - sql: str | exp.Expression, - schema: t.Optional[t.Dict | Schema] = None, - sources: t.Optional[t.Mapping[str, str | exp.Query]] = None, - dialect: DialectType = None, - scope: t.Optional[Scope] = None, - trim_selects: bool = True, - copy: bool = True, - **kwargs, -) -> Node: - """Build the lineage graph for a column of a SQL query. - - Args: - column: The column to build the lineage for. - sql: The SQL string or expression. - schema: The schema of tables. - sources: A mapping of queries which will be used to continue building lineage. - dialect: The dialect of input SQL. - scope: A pre-created scope to use instead. - trim_selects: Whether to clean up selects by trimming to only relevant columns. - copy: Whether to copy the Expression arguments. - **kwargs: Qualification optimizer kwargs. - - Returns: - A lineage node. - """ - - expression = maybe_parse(sql, copy=copy, dialect=dialect) - column = normalize_identifiers.normalize_identifiers(column, dialect=dialect).name - - if sources: - expression = exp.expand( - expression, - { - k: t.cast(exp.Query, maybe_parse(v, copy=copy, dialect=dialect)) - for k, v in sources.items() - }, - dialect=dialect, - copy=copy, - ) - - if not scope: - expression = qualify.qualify( - expression, - dialect=dialect, - schema=schema, - **{"validate_qualify_columns": False, "identify": False, **kwargs}, # type: ignore - ) - - scope = build_scope(expression) - - if not scope: - raise SqlglotError("Cannot build lineage, sql must be SELECT") - - if not any(select.alias_or_name == column for select in scope.expression.selects): - raise SqlglotError(f"Cannot find column '{column}' in query.") - - return to_node(column, scope, dialect, trim_selects=trim_selects) - - -def to_node( - column: str | int, - scope: Scope, - dialect: DialectType, - scope_name: t.Optional[str] = None, - upstream: t.Optional[Node] = None, - source_name: t.Optional[str] = None, - reference_node_name: t.Optional[str] = None, - trim_selects: bool = True, -) -> Node: - # Find the specific select clause that is the source of the column we want. - # This can either be a specific, named select or a generic `*` clause. - select = ( - scope.expression.selects[column] - if isinstance(column, int) - else next( - ( - select - for select in scope.expression.selects - if select.alias_or_name == column - ), - exp.Star() if scope.expression.is_star else scope.expression, - ) - ) - - if isinstance(scope.expression, exp.Subquery): - for source in scope.subquery_scopes: - return to_node( - column, - scope=source, - dialect=dialect, - upstream=upstream, - source_name=source_name, - reference_node_name=reference_node_name, - trim_selects=trim_selects, - ) - if isinstance(scope.expression, exp.SetOperation): - name = type(scope.expression).__name__.upper() - upstream = upstream or Node( - name=name, source=scope.expression, expression=select - ) - - index = ( - column - if isinstance(column, int) - else next( - ( - i - for i, select in enumerate(scope.expression.selects) - if select.alias_or_name == column or select.is_star - ), - -1, # mypy will not allow a None here, but a negative index should never be returned - ) - ) - - if index == -1: - raise ValueError(f"Could not find {column} in {scope.expression}") - - for s in scope.union_scopes: - to_node( - index, - scope=s, - dialect=dialect, - upstream=upstream, - source_name=source_name, - reference_node_name=reference_node_name, - trim_selects=trim_selects, - ) - - return upstream - - if trim_selects and isinstance(scope.expression, exp.Select): - # For better ergonomics in our node labels, replace the full select with - # a version that has only the column we care about. - # "x", SELECT x, y FROM foo - # => "x", SELECT x FROM foo - source = t.cast(exp.Expression, scope.expression.select(select, append=False)) - else: - source = scope.expression - - # Create the node for this step in the lineage chain, and attach it to the previous one. - node = Node( - name=f"{scope_name}.{column}" if scope_name else str(column), - source=source, - expression=select, - source_name=source_name or "", - reference_node_name=reference_node_name or "", - ) - - if upstream: - upstream.downstream.append(node) - - subquery_scopes = { - id(subquery_scope.expression): subquery_scope - for subquery_scope in scope.subquery_scopes - } - - for subquery in find_all_in_scope(select, exp.UNWRAPPED_QUERIES): - subquery_scope = subquery_scopes.get(id(subquery)) - if not subquery_scope: - logger.warning(f"Unknown subquery scope: {subquery.sql(dialect=dialect)}") - continue - - for name in subquery.named_selects: - to_node( - name, - scope=subquery_scope, - dialect=dialect, - upstream=node, - trim_selects=trim_selects, - ) - - # if the select is a star add all scope sources as downstreams - if isinstance(select, exp.Star): - for source in scope.sources.values(): - if isinstance(source, Scope): - source = source.expression - node.downstream.append( - Node(name=select.sql(comments=False), source=source, expression=source) - ) - - # Find all columns that went into creating this one to list their lineage nodes. - source_columns = set(find_all_in_scope(select, exp.Column)) - - # If the source is a UDTF find columns used in the UDTF to generate the table - if isinstance(source, exp.UDTF): - source_columns |= set(source.find_all(exp.Column)) - derived_tables = [ - source.expression.parent - for source in scope.sources.values() - if isinstance(source, Scope) and source.is_derived_table - ] - else: - derived_tables = scope.derived_tables - - source_names = { - dt.alias: dt.comments[0].split()[1] - for dt in derived_tables - if dt.comments and dt.comments[0].startswith("source: ") - } - - pivots = scope.pivots - pivot = pivots[0] if len(pivots) == 1 and not pivots[0].unpivot else None - if pivot: - # For each aggregation function, the pivot creates a new column for each field in category - # combined with the aggfunc. So the columns parsed have this order: cat_a_value_sum, cat_a, - # b_value_sum, b. Because of this step wise manner the aggfunc 'sum(value) as value_sum' - # belongs to the column indices 0, 2, and the aggfunc 'max(price)' without an alias belongs - # to the column indices 1, 3. Here, only the columns used in the aggregations are of interest - # in the lineage, so lookup the pivot column name by index and map that with the columns used - # in the aggregation. - # - # Example: PIVOT (SUM(value) AS value_sum, MAX(price)) FOR category IN ('a' AS cat_a, 'b') - pivot_columns = pivot.args["columns"] - pivot_aggs_count = len(pivot.expressions) - - pivot_column_mapping = {} - for i, agg in enumerate(pivot.expressions): - agg_cols = list(agg.find_all(exp.Column)) - for col_index in range(i, len(pivot_columns), pivot_aggs_count): - pivot_column_mapping[pivot_columns[col_index].name] = agg_cols - - for c in source_columns: - table = c.table - source = scope.sources.get(table) - - if isinstance(source, Scope): - reference_node_name = None - if ( - source.scope_type == ScopeType.DERIVED_TABLE - and table not in source_names - ): - reference_node_name = table - elif source.scope_type == ScopeType.CTE: - selected_node, _ = scope.selected_sources.get(table, (None, None)) - reference_node_name = selected_node.name if selected_node else None - - # The table itself came from a more specific scope. Recurse into that one using the unaliased column name. - to_node( - c.name, - scope=source, - dialect=dialect, - scope_name=table, - upstream=node, - source_name=source_names.get(table) or source_name, - reference_node_name=reference_node_name, - trim_selects=trim_selects, - ) - elif pivot and pivot.alias_or_name == c.table: - downstream_columns = [] - - column_name = c.name - if any(column_name == pivot_column.name for pivot_column in pivot_columns): - downstream_columns.extend(pivot_column_mapping[column_name]) - else: - # The column is not in the pivot, so it must be an implicit column of the - # pivoted source -- adapt column to be from the implicit pivoted source. - downstream_columns.append( - exp.column(c.this, table=pivot.parent.alias_or_name) - ) - - for downstream_column in downstream_columns: - table = downstream_column.table - source = scope.sources.get(table) - if isinstance(source, Scope): - to_node( - downstream_column.name, - scope=source, - scope_name=table, - dialect=dialect, - upstream=node, - source_name=source_names.get(table) or source_name, - reference_node_name=reference_node_name, - trim_selects=trim_selects, - ) - else: - source = source or exp.Placeholder() - node.downstream.append( - Node( - name=downstream_column.sql(comments=False), - source=source, - expression=source, - ) - ) - else: - # The source is not a scope and the column is not in any pivot - we've reached the end - # of the line. At this point, if a source is not found it means this column's lineage - # is unknown. This can happen if the definition of a source used in a query is not - # passed into the `sources` map. - source = source or exp.Placeholder() - node.downstream.append( - Node(name=c.sql(comments=False), source=source, expression=source) - ) - - return node - - -class GraphHTML: - """Node to HTML generator using vis.js. - - https://visjs.github.io/vis-network/docs/network/ - """ - - def __init__( - self, - nodes: t.Dict, - edges: t.List, - imports: bool = True, - options: t.Optional[t.Dict] = None, - ): - self.imports = imports - - self.options = { - "height": "500px", - "width": "100%", - "layout": { - "hierarchical": { - "enabled": True, - "nodeSpacing": 200, - "sortMethod": "directed", - }, - }, - "interaction": { - "dragNodes": False, - "selectable": False, - }, - "physics": { - "enabled": False, - }, - "edges": { - "arrows": "to", - }, - "nodes": { - "font": "20px monaco", - "shape": "box", - "widthConstraint": { - "maximum": 300, - }, - }, - **(options or {}), - } - - self.nodes = nodes - self.edges = edges - - def __str__(self): - nodes = json.dumps(list(self.nodes.values())) - edges = json.dumps(self.edges) - options = json.dumps(self.options) - imports = ( - """ - - """ - if self.imports - else "" - ) - - return f"""
-
- {imports} - -
""" - - def _repr_html_(self) -> str: - return self.__str__() diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/__init__.py b/third_party/bigframes_vendored/sqlglot/optimizer/__init__.py deleted file mode 100644 index 9cc759fbe23..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/__init__.py +++ /dev/null @@ -1,24 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/__init__.py - -# ruff: noqa: F401 - -from bigframes_vendored.sqlglot.optimizer.optimizer import RULES as RULES # noqa: F401 -from bigframes_vendored.sqlglot.optimizer.optimizer import ( # noqa: F401 - optimize as optimize, -) -from bigframes_vendored.sqlglot.optimizer.scope import Scope as Scope # noqa: F401 -from bigframes_vendored.sqlglot.optimizer.scope import ( # noqa: F401 - build_scope as build_scope, -) -from bigframes_vendored.sqlglot.optimizer.scope import ( # noqa: F401 - find_all_in_scope as find_all_in_scope, -) -from bigframes_vendored.sqlglot.optimizer.scope import ( # noqa: F401 - find_in_scope as find_in_scope, -) -from bigframes_vendored.sqlglot.optimizer.scope import ( # noqa: F401 - traverse_scope as traverse_scope, -) -from bigframes_vendored.sqlglot.optimizer.scope import ( # noqa: F401 - walk_in_scope as walk_in_scope, -) diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/annotate_types.py b/third_party/bigframes_vendored/sqlglot/optimizer/annotate_types.py deleted file mode 100644 index cca95feee82..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/annotate_types.py +++ /dev/null @@ -1,893 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/annotate_types.py - -from __future__ import annotations - -import functools -import logging -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect -from bigframes_vendored.sqlglot.helper import ( - ensure_list, - is_date_unit, - is_iso_date, - is_iso_datetime, - seq_get, -) -from bigframes_vendored.sqlglot.optimizer.scope import Scope, traverse_scope -from bigframes_vendored.sqlglot.schema import MappingSchema, Schema, ensure_schema - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import B, E - - BinaryCoercionFunc = t.Callable[[exp.Expression, exp.Expression], exp.DataType.Type] - BinaryCoercions = t.Dict[ - t.Tuple[exp.DataType.Type, exp.DataType.Type], - BinaryCoercionFunc, - ] - - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - from bigframes_vendored.sqlglot.typing import ExpressionMetadataType - -logger = logging.getLogger("sqlglot") - - -def annotate_types( - expression: E, - schema: t.Optional[t.Dict | Schema] = None, - expression_metadata: t.Optional[ExpressionMetadataType] = None, - coerces_to: t.Optional[t.Dict[exp.DataType.Type, t.Set[exp.DataType.Type]]] = None, - dialect: DialectType = None, - overwrite_types: bool = True, -) -> E: - """ - Infers the types of an expression, annotating its AST accordingly. - - Example: - >>> import sqlglot - >>> schema = {"y": {"cola": "SMALLINT"}} - >>> sql = "SELECT x.cola + 2.5 AS cola FROM (SELECT y.cola AS cola FROM y AS y) AS x" - >>> annotated_expr = annotate_types(sqlglot.parse_one(sql), schema=schema) - >>> annotated_expr.expressions[0].type.this # Get the type of "x.cola + 2.5 AS cola" - - - Args: - expression: Expression to annotate. - schema: Database schema. - expression_metadata: Maps expression type to corresponding annotation function. - coerces_to: Maps expression type to set of types that it can be coerced into. - overwrite_types: Re-annotate the existing AST types. - - Returns: - The expression annotated with types. - """ - - schema = ensure_schema(schema, dialect=dialect) - - return TypeAnnotator( - schema=schema, - expression_metadata=expression_metadata, - coerces_to=coerces_to, - overwrite_types=overwrite_types, - ).annotate(expression) - - -def _coerce_date_literal( - l: exp.Expression, unit: t.Optional[exp.Expression] -) -> exp.DataType.Type: - date_text = l.name - is_iso_date_ = is_iso_date(date_text) - - if is_iso_date_ and is_date_unit(unit): - return exp.DataType.Type.DATE - - # An ISO date is also an ISO datetime, but not vice versa - if is_iso_date_ or is_iso_datetime(date_text): - return exp.DataType.Type.DATETIME - - return exp.DataType.Type.UNKNOWN - - -def _coerce_date( - l: exp.Expression, unit: t.Optional[exp.Expression] -) -> exp.DataType.Type: - if not is_date_unit(unit): - return exp.DataType.Type.DATETIME - return l.type.this if l.type else exp.DataType.Type.UNKNOWN - - -def swap_args(func: BinaryCoercionFunc) -> BinaryCoercionFunc: - @functools.wraps(func) - def _swapped(ll: exp.Expression, r: exp.Expression) -> exp.DataType.Type: - return func(r, ll) - - return _swapped - - -def swap_all(coercions: BinaryCoercions) -> BinaryCoercions: - return { - **coercions, - **{(b, a): swap_args(func) for (a, b), func in coercions.items()}, - } - - -class _TypeAnnotator(type): - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - - # Highest-to-lowest type precedence, as specified in Spark's docs (ANSI): - # https://spark.apache.org/docs/3.2.0/sql-ref-ansi-compliance.html - text_precedence = ( - exp.DataType.Type.TEXT, - exp.DataType.Type.NVARCHAR, - exp.DataType.Type.VARCHAR, - exp.DataType.Type.NCHAR, - exp.DataType.Type.CHAR, - ) - numeric_precedence = ( - exp.DataType.Type.DECFLOAT, - exp.DataType.Type.DOUBLE, - exp.DataType.Type.FLOAT, - exp.DataType.Type.BIGDECIMAL, - exp.DataType.Type.DECIMAL, - exp.DataType.Type.BIGINT, - exp.DataType.Type.INT, - exp.DataType.Type.SMALLINT, - exp.DataType.Type.TINYINT, - ) - timelike_precedence = ( - exp.DataType.Type.TIMESTAMPLTZ, - exp.DataType.Type.TIMESTAMPTZ, - exp.DataType.Type.TIMESTAMP, - exp.DataType.Type.DATETIME, - exp.DataType.Type.DATE, - ) - - for type_precedence in ( - text_precedence, - numeric_precedence, - timelike_precedence, - ): - coerces_to = set() - for data_type in type_precedence: - klass.COERCES_TO[data_type] = coerces_to.copy() - coerces_to |= {data_type} - return klass - - -class TypeAnnotator(metaclass=_TypeAnnotator): - NESTED_TYPES = { - exp.DataType.Type.ARRAY, - } - - # Specifies what types a given type can be coerced into (autofilled) - COERCES_TO: t.Dict[exp.DataType.Type, t.Set[exp.DataType.Type]] = {} - - # Coercion functions for binary operations. - # Map of type pairs to a callable that takes both sides of the binary operation and returns the resulting type. - BINARY_COERCIONS: BinaryCoercions = { - **swap_all( - { - (t, exp.DataType.Type.INTERVAL): lambda ll, r: _coerce_date_literal( - ll, r.args.get("unit") - ) - for t in exp.DataType.TEXT_TYPES - } - ), - **swap_all( - { - # text + numeric will yield the numeric type to match most dialects' semantics - (text, numeric): lambda ll, r: t.cast( - exp.DataType.Type, - ll.type if ll.type in exp.DataType.NUMERIC_TYPES else r.type, - ) - for text in exp.DataType.TEXT_TYPES - for numeric in exp.DataType.NUMERIC_TYPES - } - ), - **swap_all( - { - ( - exp.DataType.Type.DATE, - exp.DataType.Type.INTERVAL, - ): lambda ll, r: _coerce_date(ll, r.args.get("unit")), - } - ), - } - - def __init__( - self, - schema: Schema, - expression_metadata: t.Optional[ExpressionMetadataType] = None, - coerces_to: t.Optional[ - t.Dict[exp.DataType.Type, t.Set[exp.DataType.Type]] - ] = None, - binary_coercions: t.Optional[BinaryCoercions] = None, - overwrite_types: bool = True, - ) -> None: - self.schema = schema - dialect = schema.dialect or Dialect() - self.dialect = dialect - self.expression_metadata = expression_metadata or dialect.EXPRESSION_METADATA - self.coerces_to = coerces_to or dialect.COERCES_TO or self.COERCES_TO - self.binary_coercions = binary_coercions or self.BINARY_COERCIONS - - # Caches the ids of annotated sub-Expressions, to ensure we only visit them once - self._visited: t.Set[int] = set() - - # Caches NULL-annotated expressions to set them to UNKNOWN after type inference is completed - self._null_expressions: t.Dict[int, exp.Expression] = {} - - # Databricks and Spark ≥v3 actually support NULL (i.e., VOID) as a type - self._supports_null_type = dialect.SUPPORTS_NULL_TYPE - - # Maps an exp.SetOperation's id (e.g. UNION) to its projection types. This is computed if the - # exp.SetOperation is the expression of a scope source, as selecting from it multiple times - # would reprocess the entire subtree to coerce the types of its operands' projections - self._setop_column_types: t.Dict[ - int, t.Dict[str, exp.DataType | exp.DataType.Type] - ] = {} - - # When set to False, this enables partial annotation by skipping already-annotated nodes - self._overwrite_types = overwrite_types - - def clear(self) -> None: - self._visited.clear() - self._null_expressions.clear() - self._setop_column_types.clear() - - def _set_type( - self, expression: E, target_type: t.Optional[exp.DataType | exp.DataType.Type] - ) -> E: - prev_type = expression.type - expression_id = id(expression) - - expression.type = target_type or exp.DataType.Type.UNKNOWN # type: ignore - self._visited.add(expression_id) - - if ( - not self._supports_null_type - and t.cast(exp.DataType, expression.type).this == exp.DataType.Type.NULL - ): - self._null_expressions[expression_id] = expression - elif ( - prev_type and t.cast(exp.DataType, prev_type).this == exp.DataType.Type.NULL - ): - self._null_expressions.pop(expression_id, None) - - if ( - isinstance(expression, exp.Column) - and expression.is_type(exp.DataType.Type.JSON) - and (dot_parts := expression.meta.get("dot_parts")) - ): - # JSON dot access is case sensitive across all dialects, so we need to undo the normalization. - i = iter(dot_parts) - parent = expression.parent - while isinstance(parent, exp.Dot): - parent.expression.set("this", exp.to_identifier(next(i), quoted=True)) - parent = parent.parent - - expression.meta.pop("dot_parts", None) - - return expression - - def annotate(self, expression: E, annotate_scope: bool = True) -> E: - # This flag is used to avoid costly scope traversals when we only care about annotating - # non-column expressions (partial type inference), e.g., when simplifying in the optimizer - if annotate_scope: - for scope in traverse_scope(expression): - self.annotate_scope(scope) - - # This takes care of non-traversable expressions - self._annotate_expression(expression) - - # Replace NULL type with the default type of the targeted dialect, since the former is not an actual type; - # it is mostly used to aid type coercion, e.g. in query set operations. - for expr in self._null_expressions.values(): - expr.type = self.dialect.DEFAULT_NULL_TYPE - - return expression - - def annotate_scope(self, scope: Scope) -> None: - selects = {} - - for name, source in scope.sources.items(): - if not isinstance(source, Scope): - continue - - expression = source.expression - if isinstance(expression, exp.UDTF): - values = [] - - if isinstance(expression, exp.Lateral): - if isinstance(expression.this, exp.Explode): - values = [expression.this.this] - elif isinstance(expression, exp.Unnest): - values = [expression] - elif not isinstance(expression, exp.TableFromRows): - values = expression.expressions[0].expressions - - if not values: - continue - - alias_column_names = expression.alias_column_names - - if ( - isinstance(expression, exp.Unnest) - and not alias_column_names - and expression.type - and expression.type.is_type(exp.DataType.Type.STRUCT) - ): - selects[name] = { - col_def.name: t.cast( - t.Union[exp.DataType, exp.DataType.Type], col_def.kind - ) - for col_def in expression.type.expressions - if isinstance(col_def, exp.ColumnDef) and col_def.kind - } - else: - selects[name] = { - alias: column.type - for alias, column in zip(alias_column_names, values) - } - elif isinstance(expression, exp.SetOperation) and len( - expression.left.selects - ) == len(expression.right.selects): - selects[name] = self._get_setop_column_types(expression) - - else: - selects[name] = {s.alias_or_name: s.type for s in expression.selects} - - if isinstance(self.schema, MappingSchema): - for table_column in scope.table_columns: - source = scope.sources.get(table_column.name) - - if isinstance(source, exp.Table): - schema = self.schema.find( - source, raise_on_missing=False, ensure_data_types=True - ) - if not isinstance(schema, dict): - continue - - struct_type = exp.DataType( - this=exp.DataType.Type.STRUCT, - expressions=[ - exp.ColumnDef(this=exp.to_identifier(c), kind=kind) - for c, kind in schema.items() - ], - nested=True, - ) - self._set_type(table_column, struct_type) - elif ( - isinstance(source, Scope) - and isinstance(source.expression, exp.Query) - and ( - source.expression.meta.get("query_type") - or exp.DataType.build("UNKNOWN") - ).is_type(exp.DataType.Type.STRUCT) - ): - self._set_type(table_column, source.expression.meta["query_type"]) - - # Iterate through all the expressions of the current scope in post-order, and annotate - self._annotate_expression(scope.expression, scope, selects) - - if self.dialect.QUERY_RESULTS_ARE_STRUCTS and isinstance( - scope.expression, exp.Query - ): - struct_type = exp.DataType( - this=exp.DataType.Type.STRUCT, - expressions=[ - exp.ColumnDef( - this=exp.to_identifier(select.output_name), - kind=select.type.copy() if select.type else None, - ) - for select in scope.expression.selects - ], - nested=True, - ) - - if not any( - cd.kind.is_type(exp.DataType.Type.UNKNOWN) - for cd in struct_type.expressions - if cd.kind - ): - # We don't use `_set_type` on purpose here. If we annotated the query directly, then - # using it in other contexts (e.g., ARRAY()) could result in incorrect type - # annotations, i.e., it shouldn't be interpreted as a STRUCT value. - scope.expression.meta["query_type"] = struct_type - - def _annotate_expression( - self, - expression: exp.Expression, - scope: t.Optional[Scope] = None, - selects: t.Optional[t.Dict[str, t.Dict[str, t.Any]]] = None, - ) -> None: - stack = [(expression, False)] - selects = selects or {} - - while stack: - expr, children_annotated = stack.pop() - - if id(expr) in self._visited or ( - not self._overwrite_types - and expr.type - and not expr.is_type(exp.DataType.Type.UNKNOWN) - ): - continue # We've already inferred the expression's type - - if not children_annotated: - stack.append((expr, True)) - for child_expr in expr.iter_expressions(): - stack.append((child_expr, False)) - continue - - if scope and isinstance(expr, exp.Column) and expr.table: - source = scope.sources.get(expr.table) - if isinstance(source, exp.Table): - self._set_type(expr, self.schema.get_column_type(source, expr)) - elif source: - if expr.table in selects and expr.name in selects[expr.table]: - self._set_type(expr, selects[expr.table][expr.name]) - elif isinstance(source.expression, exp.Unnest): - self._set_type(expr, source.expression.type) - else: - self._set_type(expr, exp.DataType.Type.UNKNOWN) - else: - self._set_type(expr, exp.DataType.Type.UNKNOWN) - - if expr.type and expr.type.args.get("nullable") is False: - expr.meta["nonnull"] = True - continue - - spec = self.expression_metadata.get(expr.__class__) - - if spec and (annotator := spec.get("annotator")): - annotator(self, expr) - elif spec and (returns := spec.get("returns")): - self._set_type(expr, t.cast(exp.DataType.Type, returns)) - else: - self._set_type(expr, exp.DataType.Type.UNKNOWN) - - def _maybe_coerce( - self, - type1: exp.DataType | exp.DataType.Type, - type2: exp.DataType | exp.DataType.Type, - ) -> exp.DataType | exp.DataType.Type: - """ - Returns type2 if type1 can be coerced into it, otherwise type1. - - If either type is parameterized (e.g. DECIMAL(18, 2) contains two parameters), - we assume type1 does not coerce into type2, so we also return it in this case. - """ - if isinstance(type1, exp.DataType): - if type1.expressions: - return type1 - type1_value = type1.this - else: - type1_value = type1 - - if isinstance(type2, exp.DataType): - if type2.expressions: - return type2 - type2_value = type2.this - else: - type2_value = type2 - - # We propagate the UNKNOWN type upwards if found - if exp.DataType.Type.UNKNOWN in (type1_value, type2_value): - return exp.DataType.Type.UNKNOWN - - if type1_value == exp.DataType.Type.NULL: - return type2_value - if type2_value == exp.DataType.Type.NULL: - return type1_value - - return ( - type2_value - if type2_value in self.coerces_to.get(type1_value, {}) - else type1_value - ) - - def _get_setop_column_types( - self, setop: exp.SetOperation - ) -> t.Dict[str, exp.DataType | exp.DataType.Type]: - """ - Computes and returns the coerced column types for a SetOperation. - - This handles UNION, INTERSECT, EXCEPT, etc., coercing types across - left and right operands for all projections/columns. - - Args: - setop: The SetOperation expression to analyze - - Returns: - Dictionary mapping column names to their coerced types - """ - setop_id = id(setop) - if setop_id in self._setop_column_types: - return self._setop_column_types[setop_id] - - col_types: t.Dict[str, exp.DataType | exp.DataType.Type] = {} - - # Validate that left and right have same number of projections - if not ( - isinstance(setop, exp.SetOperation) - and setop.left.selects - and setop.right.selects - and len(setop.left.selects) == len(setop.right.selects) - ): - return col_types - - # Process a chain / sub-tree of set operations - for set_op in setop.walk( - prune=lambda n: not isinstance(n, (exp.SetOperation, exp.Subquery)) - ): - if not isinstance(set_op, exp.SetOperation): - continue - - if set_op.args.get("by_name"): - r_type_by_select = { - s.alias_or_name: s.type for s in set_op.right.selects - } - setop_cols = { - s.alias_or_name: self._maybe_coerce( - t.cast(exp.DataType, s.type), - r_type_by_select.get(s.alias_or_name) - or exp.DataType.Type.UNKNOWN, - ) - for s in set_op.left.selects - } - else: - setop_cols = { - ls.alias_or_name: self._maybe_coerce( - t.cast(exp.DataType, ls.type), t.cast(exp.DataType, rs.type) - ) - for ls, rs in zip(set_op.left.selects, set_op.right.selects) - } - - # Coerce intermediate results with the previously registered types, if they exist - for col_name, col_type in setop_cols.items(): - col_types[col_name] = self._maybe_coerce( - col_type, col_types.get(col_name, exp.DataType.Type.NULL) - ) - - self._setop_column_types[setop_id] = col_types - return col_types - - def _annotate_binary(self, expression: B) -> B: - left, right = expression.left, expression.right - if not left or not right: - expression_sql = expression.sql(self.dialect) - logger.warning( - f"Failed to annotate badly formed binary expression: {expression_sql}" - ) - self._set_type(expression, None) - return expression - - left_type, right_type = left.type.this, right.type.this # type: ignore - - if isinstance(expression, (exp.Connector, exp.Predicate)): - self._set_type(expression, exp.DataType.Type.BOOLEAN) - elif (left_type, right_type) in self.binary_coercions: - self._set_type( - expression, self.binary_coercions[(left_type, right_type)](left, right) - ) - else: - self._set_type(expression, self._maybe_coerce(left_type, right_type)) - - if isinstance(expression, exp.Is) or ( - left.meta.get("nonnull") is True and right.meta.get("nonnull") is True - ): - expression.meta["nonnull"] = True - - return expression - - def _annotate_unary(self, expression: E) -> E: - if isinstance(expression, exp.Not): - self._set_type(expression, exp.DataType.Type.BOOLEAN) - else: - self._set_type(expression, expression.this.type) - - if expression.this.meta.get("nonnull") is True: - expression.meta["nonnull"] = True - - return expression - - def _annotate_literal(self, expression: exp.Literal) -> exp.Literal: - if expression.is_string: - self._set_type(expression, exp.DataType.Type.VARCHAR) - elif expression.is_int: - self._set_type(expression, exp.DataType.Type.INT) - else: - self._set_type(expression, exp.DataType.Type.DOUBLE) - - expression.meta["nonnull"] = True - - return expression - - @t.no_type_check - def _annotate_by_args( - self, - expression: E, - *args: str | exp.Expression, - promote: bool = False, - array: bool = False, - ) -> E: - literal_type = None - non_literal_type = None - nested_type = None - - for arg in args: - if isinstance(arg, str): - expressions = expression.args.get(arg) - else: - expressions = arg - - for expr in ensure_list(expressions): - expr_type = expr.type - - # Stop at the first nested data type found - we don't want to _maybe_coerce nested types - if expr_type.args.get("nested"): - nested_type = expr_type - break - - if not expr_type.is_type(exp.DataType.Type.UNKNOWN): - if isinstance(expr, exp.Literal): - literal_type = self._maybe_coerce( - literal_type or expr_type, expr_type - ) - else: - non_literal_type = self._maybe_coerce( - non_literal_type or expr_type, expr_type - ) - - if nested_type: - break - - result_type = None - - if nested_type: - result_type = nested_type - elif literal_type and non_literal_type: - if self.dialect.PRIORITIZE_NON_LITERAL_TYPES: - literal_this_type = ( - literal_type.this - if isinstance(literal_type, exp.DataType) - else literal_type - ) - non_literal_this_type = ( - non_literal_type.this - if isinstance(non_literal_type, exp.DataType) - else non_literal_type - ) - if ( - literal_this_type in exp.DataType.INTEGER_TYPES - and non_literal_this_type in exp.DataType.INTEGER_TYPES - ) or ( - literal_this_type in exp.DataType.REAL_TYPES - and non_literal_this_type in exp.DataType.REAL_TYPES - ): - result_type = non_literal_type - else: - result_type = literal_type or non_literal_type or exp.DataType.Type.UNKNOWN - - self._set_type( - expression, - result_type or self._maybe_coerce(non_literal_type, literal_type), - ) - - if promote: - if expression.type.this in exp.DataType.INTEGER_TYPES: - self._set_type(expression, exp.DataType.Type.BIGINT) - elif expression.type.this in exp.DataType.FLOAT_TYPES: - self._set_type(expression, exp.DataType.Type.DOUBLE) - - if array: - self._set_type( - expression, - exp.DataType( - this=exp.DataType.Type.ARRAY, - expressions=[expression.type], - nested=True, - ), - ) - - return expression - - def _annotate_timeunit( - self, expression: exp.TimeUnit | exp.DateTrunc - ) -> exp.TimeUnit | exp.DateTrunc: - if expression.this.type.this in exp.DataType.TEXT_TYPES: - datatype = _coerce_date_literal(expression.this, expression.unit) - elif expression.this.type.this in exp.DataType.TEMPORAL_TYPES: - datatype = _coerce_date(expression.this, expression.unit) - else: - datatype = exp.DataType.Type.UNKNOWN - - self._set_type(expression, datatype) - return expression - - def _annotate_bracket(self, expression: exp.Bracket) -> exp.Bracket: - bracket_arg = expression.expressions[0] - this = expression.this - - if isinstance(bracket_arg, exp.Slice): - self._set_type(expression, this.type) - elif this.type.is_type(exp.DataType.Type.ARRAY): - self._set_type(expression, seq_get(this.type.expressions, 0)) - elif isinstance(this, (exp.Map, exp.VarMap)) and bracket_arg in this.keys: - index = this.keys.index(bracket_arg) - value = seq_get(this.values, index) - self._set_type(expression, value.type if value else None) - else: - self._set_type(expression, exp.DataType.Type.UNKNOWN) - - return expression - - def _annotate_div(self, expression: exp.Div) -> exp.Div: - left_type, right_type = expression.left.type.this, expression.right.type.this # type: ignore - - if ( - expression.args.get("typed") - and left_type in exp.DataType.INTEGER_TYPES - and right_type in exp.DataType.INTEGER_TYPES - ): - self._set_type(expression, exp.DataType.Type.BIGINT) - else: - self._set_type(expression, self._maybe_coerce(left_type, right_type)) - if expression.type and expression.type.this not in exp.DataType.REAL_TYPES: - self._set_type( - expression, - self._maybe_coerce(expression.type, exp.DataType.Type.DOUBLE), - ) - - return expression - - def _annotate_dot(self, expression: exp.Dot) -> exp.Dot: - self._set_type(expression, None) - this_type = expression.this.type - - if this_type and this_type.is_type(exp.DataType.Type.STRUCT): - for e in this_type.expressions: - if e.name == expression.expression.name: - self._set_type(expression, e.kind) - break - - return expression - - def _annotate_explode(self, expression: exp.Explode) -> exp.Explode: - self._set_type(expression, seq_get(expression.this.type.expressions, 0)) - return expression - - def _annotate_unnest(self, expression: exp.Unnest) -> exp.Unnest: - child = seq_get(expression.expressions, 0) - - if child and child.is_type(exp.DataType.Type.ARRAY): - expr_type = seq_get(child.type.expressions, 0) - else: - expr_type = None - - self._set_type(expression, expr_type) - return expression - - def _annotate_subquery(self, expression: exp.Subquery) -> exp.Subquery: - # For scalar subqueries (subqueries with a single projection), infer the type - # from that single projection. This allows type propagation in cases like: - # SELECT (SELECT 1 AS c) AS c - query = expression.unnest() - - if isinstance(query, exp.Query): - selects = query.selects - if len(selects) == 1: - self._set_type(expression, selects[0].type) - return expression - - self._set_type(expression, exp.DataType.Type.UNKNOWN) - return expression - - def _annotate_struct_value( - self, expression: exp.Expression - ) -> t.Optional[exp.DataType] | exp.ColumnDef: - # Case: STRUCT(key AS value) - this: t.Optional[exp.Expression] = None - kind = expression.type - - if alias := expression.args.get("alias"): - this = alias.copy() - elif expression.expression: - # Case: STRUCT(key = value) or STRUCT(key := value) - this = expression.this.copy() - kind = expression.expression.type - elif isinstance(expression, exp.Column): - # Case: STRUCT(c) - this = expression.this.copy() - - if kind and kind.is_type(exp.DataType.Type.UNKNOWN): - return None - - if this: - return exp.ColumnDef(this=this, kind=kind) - - return kind - - def _annotate_struct(self, expression: exp.Struct) -> exp.Struct: - expressions = [] - for expr in expression.expressions: - struct_field_type = self._annotate_struct_value(expr) - if struct_field_type is None: - self._set_type(expression, None) - return expression - - expressions.append(struct_field_type) - - self._set_type( - expression, - exp.DataType( - this=exp.DataType.Type.STRUCT, expressions=expressions, nested=True - ), - ) - return expression - - @t.overload - def _annotate_map(self, expression: exp.Map) -> exp.Map: ... - - @t.overload - def _annotate_map(self, expression: exp.VarMap) -> exp.VarMap: ... - - def _annotate_map(self, expression): - keys = expression.args.get("keys") - values = expression.args.get("values") - - map_type = exp.DataType(this=exp.DataType.Type.MAP) - if isinstance(keys, exp.Array) and isinstance(values, exp.Array): - key_type = seq_get(keys.type.expressions, 0) or exp.DataType.Type.UNKNOWN - value_type = ( - seq_get(values.type.expressions, 0) or exp.DataType.Type.UNKNOWN - ) - - if ( - key_type != exp.DataType.Type.UNKNOWN - and value_type != exp.DataType.Type.UNKNOWN - ): - map_type.set("expressions", [key_type, value_type]) - map_type.set("nested", True) - - self._set_type(expression, map_type) - return expression - - def _annotate_to_map(self, expression: exp.ToMap) -> exp.ToMap: - map_type = exp.DataType(this=exp.DataType.Type.MAP) - arg = expression.this - if arg.is_type(exp.DataType.Type.STRUCT): - for coldef in arg.type.expressions: - kind = coldef.kind - if kind != exp.DataType.Type.UNKNOWN: - map_type.set("expressions", [exp.DataType.build("varchar"), kind]) - map_type.set("nested", True) - break - - self._set_type(expression, map_type) - return expression - - def _annotate_extract(self, expression: exp.Extract) -> exp.Extract: - part = expression.name - if part == "TIME": - self._set_type(expression, exp.DataType.Type.TIME) - elif part == "DATE": - self._set_type(expression, exp.DataType.Type.DATE) - else: - self._set_type(expression, exp.DataType.Type.INT) - return expression - - def _annotate_by_array_element(self, expression: exp.Expression) -> exp.Expression: - array_arg = expression.this - if array_arg.type.is_type(exp.DataType.Type.ARRAY): - element_type = ( - seq_get(array_arg.type.expressions, 0) or exp.DataType.Type.UNKNOWN - ) - self._set_type(expression, element_type) - else: - self._set_type(expression, exp.DataType.Type.UNKNOWN) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/canonicalize.py b/third_party/bigframes_vendored/sqlglot/optimizer/canonicalize.py deleted file mode 100644 index ec17916e137..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/canonicalize.py +++ /dev/null @@ -1,243 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/canonicalize.py - -from __future__ import annotations - -import itertools -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType -from bigframes_vendored.sqlglot.helper import is_date_unit, is_iso_date, is_iso_datetime -from bigframes_vendored.sqlglot.optimizer.annotate_types import TypeAnnotator - - -def canonicalize( - expression: exp.Expression, dialect: DialectType = None -) -> exp.Expression: - """Converts a sql expression into a standard form. - - This method relies on annotate_types because many of the - conversions rely on type inference. - - Args: - expression: The expression to canonicalize. - """ - - dialect = Dialect.get_or_raise(dialect) - - def _canonicalize(expression: exp.Expression) -> exp.Expression: - expression = add_text_to_concat(expression) - expression = replace_date_funcs(expression, dialect=dialect) - expression = coerce_type(expression, dialect.PROMOTE_TO_INFERRED_DATETIME_TYPE) - expression = remove_redundant_casts(expression) - expression = ensure_bools(expression, _replace_int_predicate) - expression = remove_ascending_order(expression) - return expression - - return exp.replace_tree(expression, _canonicalize) - - -def add_text_to_concat(node: exp.Expression) -> exp.Expression: - if ( - isinstance(node, exp.Add) - and node.type - and node.type.this in exp.DataType.TEXT_TYPES - ): - node = exp.Concat( - expressions=[node.left, node.right], - # All known dialects, i.e. Redshift and T-SQL, that support - # concatenating strings with the + operator do not coalesce NULLs. - coalesce=False, - ) - return node - - -def replace_date_funcs(node: exp.Expression, dialect: DialectType) -> exp.Expression: - if ( - isinstance(node, (exp.Date, exp.TsOrDsToDate)) - and not node.expressions - and not node.args.get("zone") - and node.this.is_string - and is_iso_date(node.this.name) - ): - return exp.cast(node.this, to=exp.DataType.Type.DATE) - if isinstance(node, exp.Timestamp) and not node.args.get("zone"): - if not node.type: - from bigframes_vendored.sqlglot.optimizer.annotate_types import ( - annotate_types, - ) - - node = annotate_types(node, dialect=dialect) - return exp.cast(node.this, to=node.type or exp.DataType.Type.TIMESTAMP) - - return node - - -COERCIBLE_DATE_OPS = ( - exp.Add, - exp.Sub, - exp.EQ, - exp.NEQ, - exp.GT, - exp.GTE, - exp.LT, - exp.LTE, - exp.NullSafeEQ, - exp.NullSafeNEQ, -) - - -def coerce_type( - node: exp.Expression, promote_to_inferred_datetime_type: bool -) -> exp.Expression: - if isinstance(node, COERCIBLE_DATE_OPS): - _coerce_date(node.left, node.right, promote_to_inferred_datetime_type) - elif isinstance(node, exp.Between): - _coerce_date(node.this, node.args["low"], promote_to_inferred_datetime_type) - elif isinstance(node, exp.Extract) and not node.expression.is_type( - *exp.DataType.TEMPORAL_TYPES - ): - _replace_cast(node.expression, exp.DataType.Type.DATETIME) - elif isinstance(node, (exp.DateAdd, exp.DateSub, exp.DateTrunc)): - _coerce_timeunit_arg(node.this, node.unit) - elif isinstance(node, exp.DateDiff): - _coerce_datediff_args(node) - - return node - - -def remove_redundant_casts(expression: exp.Expression) -> exp.Expression: - if ( - isinstance(expression, exp.Cast) - and expression.this.type - and expression.to == expression.this.type - ): - return expression.this - - if ( - isinstance(expression, (exp.Date, exp.TsOrDsToDate)) - and expression.this.type - and expression.this.type.this == exp.DataType.Type.DATE - and not expression.this.type.expressions - ): - return expression.this - - return expression - - -def ensure_bools( - expression: exp.Expression, replace_func: t.Callable[[exp.Expression], None] -) -> exp.Expression: - if isinstance(expression, exp.Connector): - replace_func(expression.left) - replace_func(expression.right) - elif isinstance(expression, exp.Not): - replace_func(expression.this) - # We can't replace num in CASE x WHEN num ..., because it's not the full predicate - elif isinstance(expression, exp.If) and not ( - isinstance(expression.parent, exp.Case) and expression.parent.this - ): - replace_func(expression.this) - elif isinstance(expression, (exp.Where, exp.Having)): - replace_func(expression.this) - - return expression - - -def remove_ascending_order(expression: exp.Expression) -> exp.Expression: - if isinstance(expression, exp.Ordered) and expression.args.get("desc") is False: - # Convert ORDER BY a ASC to ORDER BY a - expression.set("desc", None) - - return expression - - -def _coerce_date( - a: exp.Expression, - b: exp.Expression, - promote_to_inferred_datetime_type: bool, -) -> None: - for a, b in itertools.permutations([a, b]): - if isinstance(b, exp.Interval): - a = _coerce_timeunit_arg(a, b.unit) - - a_type = a.type - if ( - not a_type - or a_type.this not in exp.DataType.TEMPORAL_TYPES - or not b.type - or b.type.this not in exp.DataType.TEXT_TYPES - ): - continue - - if promote_to_inferred_datetime_type: - if b.is_string: - date_text = b.name - if is_iso_date(date_text): - b_type = exp.DataType.Type.DATE - elif is_iso_datetime(date_text): - b_type = exp.DataType.Type.DATETIME - else: - b_type = a_type.this - else: - # If b is not a datetime string, we conservatively promote it to a DATETIME, - # in order to ensure there are no surprising truncations due to downcasting - b_type = exp.DataType.Type.DATETIME - - target_type = ( - b_type - if b_type in TypeAnnotator.COERCES_TO.get(a_type.this, {}) - else a_type - ) - else: - target_type = a_type - - if target_type != a_type: - _replace_cast(a, target_type) - - _replace_cast(b, target_type) - - -def _coerce_timeunit_arg( - arg: exp.Expression, unit: t.Optional[exp.Expression] -) -> exp.Expression: - if not arg.type: - return arg - - if arg.type.this in exp.DataType.TEXT_TYPES: - date_text = arg.name - is_iso_date_ = is_iso_date(date_text) - - if is_iso_date_ and is_date_unit(unit): - return arg.replace(exp.cast(arg.copy(), to=exp.DataType.Type.DATE)) - - # An ISO date is also an ISO datetime, but not vice versa - if is_iso_date_ or is_iso_datetime(date_text): - return arg.replace(exp.cast(arg.copy(), to=exp.DataType.Type.DATETIME)) - - elif arg.type.this == exp.DataType.Type.DATE and not is_date_unit(unit): - return arg.replace(exp.cast(arg.copy(), to=exp.DataType.Type.DATETIME)) - - return arg - - -def _coerce_datediff_args(node: exp.DateDiff) -> None: - for e in (node.this, node.expression): - if e.type.this not in exp.DataType.TEMPORAL_TYPES: - e.replace(exp.cast(e.copy(), to=exp.DataType.Type.DATETIME)) - - -def _replace_cast(node: exp.Expression, to: exp.DATA_TYPE) -> None: - node.replace(exp.cast(node.copy(), to=to)) - - -# this was originally designed for presto, there is a similar transform for tsql -# this is different in that it only operates on int types, this is because -# presto has a boolean type whereas tsql doesn't (people use bits) -# with y as (select true as x) select x = 0 FROM y -- illegal presto query -def _replace_int_predicate(expression: exp.Expression) -> None: - if isinstance(expression, exp.Coalesce): - for child in expression.iter_expressions(): - _replace_int_predicate(child) - elif expression.type and expression.type.this in exp.DataType.INTEGER_TYPES: - expression.replace(expression.neq(0)) diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_ctes.py b/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_ctes.py deleted file mode 100644 index 8714c6bfa1b..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_ctes.py +++ /dev/null @@ -1,45 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/eliminate_ctes.py - -from bigframes_vendored.sqlglot.optimizer.scope import Scope, build_scope - - -def eliminate_ctes(expression): - """ - Remove unused CTEs from an expression. - - Example: - >>> import sqlglot - >>> sql = "WITH y AS (SELECT a FROM x) SELECT a FROM z" - >>> expression = sqlglot.parse_one(sql) - >>> eliminate_ctes(expression).sql() - 'SELECT a FROM z' - - Args: - expression (sqlglot.Expression): expression to optimize - Returns: - sqlglot.Expression: optimized expression - """ - root = build_scope(expression) - - if root: - ref_count = root.ref_count() - - # Traverse the scope tree in reverse so we can remove chains of unused CTEs - for scope in reversed(list(root.traverse())): - if scope.is_cte: - count = ref_count[id(scope)] - if count <= 0: - cte_node = scope.expression.parent - with_node = cte_node.parent - cte_node.pop() - - # Pop the entire WITH clause if this is the last CTE - if with_node and len(with_node.expressions) <= 0: - with_node.pop() - - # Decrement the ref count for all sources this CTE selects from - for _, source in scope.selected_sources.values(): - if isinstance(source, Scope): - ref_count[id(source)] -= 1 - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_joins.py b/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_joins.py deleted file mode 100644 index db6621495cf..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_joins.py +++ /dev/null @@ -1,191 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/eliminate_joins.py - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.optimizer.normalize import normalized -from bigframes_vendored.sqlglot.optimizer.scope import Scope, traverse_scope - - -def eliminate_joins(expression): - """ - Remove unused joins from an expression. - - This only removes joins when we know that the join condition doesn't produce duplicate rows. - - Example: - >>> import sqlglot - >>> sql = "SELECT x.a FROM x LEFT JOIN (SELECT DISTINCT y.b FROM y) AS y ON x.b = y.b" - >>> expression = sqlglot.parse_one(sql) - >>> eliminate_joins(expression).sql() - 'SELECT x.a FROM x' - - Args: - expression (sqlglot.Expression): expression to optimize - Returns: - sqlglot.Expression: optimized expression - """ - for scope in traverse_scope(expression): - # If any columns in this scope aren't qualified, it's hard to determine if a join isn't used. - # It's probably possible to infer this from the outputs of derived tables. - # But for now, let's just skip this rule. - if scope.unqualified_columns: - continue - - joins = scope.expression.args.get("joins", []) - - # Reverse the joins so we can remove chains of unused joins - for join in reversed(joins): - if join.is_semi_or_anti_join: - continue - - alias = join.alias_or_name - if _should_eliminate_join(scope, join, alias): - join.pop() - scope.remove_source(alias) - return expression - - -def _should_eliminate_join(scope, join, alias): - inner_source = scope.sources.get(alias) - return ( - isinstance(inner_source, Scope) - and not _join_is_used(scope, join, alias) - and ( - ( - join.side == "LEFT" - and _is_joined_on_all_unique_outputs(inner_source, join) - ) - or (not join.args.get("on") and _has_single_output_row(inner_source)) - ) - ) - - -def _join_is_used(scope, join, alias): - # We need to find all columns that reference this join. - # But columns in the ON clause shouldn't count. - on = join.args.get("on") - if on: - on_clause_columns = {id(column) for column in on.find_all(exp.Column)} - else: - on_clause_columns = set() - return any( - column - for column in scope.source_columns(alias) - if id(column) not in on_clause_columns - ) - - -def _is_joined_on_all_unique_outputs(scope, join): - unique_outputs = _unique_outputs(scope) - if not unique_outputs: - return False - - _, join_keys, _ = join_condition(join) - remaining_unique_outputs = unique_outputs - {c.name for c in join_keys} - return not remaining_unique_outputs - - -def _unique_outputs(scope): - """Determine output columns of `scope` that must have a unique combination per row""" - if scope.expression.args.get("distinct"): - return set(scope.expression.named_selects) - - group = scope.expression.args.get("group") - if group: - grouped_expressions = set(group.expressions) - grouped_outputs = set() - - unique_outputs = set() - for select in scope.expression.selects: - output = select.unalias() - if output in grouped_expressions: - grouped_outputs.add(output) - unique_outputs.add(select.alias_or_name) - - # All the grouped expressions must be in the output - if not grouped_expressions.difference(grouped_outputs): - return unique_outputs - else: - return set() - - if _has_single_output_row(scope): - return set(scope.expression.named_selects) - - return set() - - -def _has_single_output_row(scope): - return isinstance(scope.expression, exp.Select) and ( - all(isinstance(e.unalias(), exp.AggFunc) for e in scope.expression.selects) - or _is_limit_1(scope) - or not scope.expression.args.get("from_") - ) - - -def _is_limit_1(scope): - limit = scope.expression.args.get("limit") - return limit and limit.expression.this == "1" - - -def join_condition(join): - """ - Extract the join condition from a join expression. - - Args: - join (exp.Join) - Returns: - tuple[list[str], list[str], exp.Expression]: - Tuple of (source key, join key, remaining predicate) - """ - name = join.alias_or_name - on = (join.args.get("on") or exp.true()).copy() - source_key = [] - join_key = [] - - def extract_condition(condition): - left, right = condition.unnest_operands() - left_tables = exp.column_table_names(left) - right_tables = exp.column_table_names(right) - - if name in left_tables and name not in right_tables: - join_key.append(left) - source_key.append(right) - condition.replace(exp.true()) - elif name in right_tables and name not in left_tables: - join_key.append(right) - source_key.append(left) - condition.replace(exp.true()) - - # find the join keys - # SELECT - # FROM x - # JOIN y - # ON x.a = y.b AND y.b > 1 - # - # should pull y.b as the join key and x.a as the source key - if normalized(on): - on = on if isinstance(on, exp.And) else exp.and_(on, exp.true(), copy=False) - - for condition in on.flatten(): - if isinstance(condition, exp.EQ): - extract_condition(condition) - elif normalized(on, dnf=True): - conditions = None - - for condition in on.flatten(): - parts = [part for part in condition.flatten() if isinstance(part, exp.EQ)] - if conditions is None: - conditions = parts - else: - temp = [] - for p in parts: - cs = [c for c in conditions if p == c] - - if cs: - temp.append(p) - temp.extend(cs) - conditions = temp - - for condition in conditions: - extract_condition(condition) - - return source_key, join_key, on diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_subqueries.py b/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_subqueries.py deleted file mode 100644 index 9deb0f65dc5..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/eliminate_subqueries.py +++ /dev/null @@ -1,195 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/eliminate_subqueries.py - -from __future__ import annotations - -import itertools -import typing as t - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.helper import find_new_name -from bigframes_vendored.sqlglot.optimizer.scope import Scope, build_scope - -if t.TYPE_CHECKING: - ExistingCTEsMapping = t.Dict[exp.Expression, str] - TakenNameMapping = t.Dict[str, t.Union[Scope, exp.Expression]] - - -def eliminate_subqueries(expression: exp.Expression) -> exp.Expression: - """ - Rewrite derived tables as CTES, deduplicating if possible. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("SELECT a FROM (SELECT * FROM x) AS y") - >>> eliminate_subqueries(expression).sql() - 'WITH y AS (SELECT * FROM x) SELECT a FROM y AS y' - - This also deduplicates common subqueries: - >>> expression = sqlglot.parse_one("SELECT a FROM (SELECT * FROM x) AS y CROSS JOIN (SELECT * FROM x) AS z") - >>> eliminate_subqueries(expression).sql() - 'WITH y AS (SELECT * FROM x) SELECT a FROM y AS y CROSS JOIN y AS z' - - Args: - expression (sqlglot.Expression): expression - Returns: - sqlglot.Expression: expression - """ - if isinstance(expression, exp.Subquery): - # It's possible to have subqueries at the root, e.g. (SELECT * FROM x) LIMIT 1 - eliminate_subqueries(expression.this) - return expression - - root = build_scope(expression) - - if not root: - return expression - - # Map of alias->Scope|Table - # These are all aliases that are already used in the expression. - # We don't want to create new CTEs that conflict with these names. - taken: TakenNameMapping = {} - - # All CTE aliases in the root scope are taken - for scope in root.cte_scopes: - taken[scope.expression.parent.alias] = scope - - # All table names are taken - for scope in root.traverse(): - taken.update( - { - source.name: source - for _, source in scope.sources.items() - if isinstance(source, exp.Table) - } - ) - - # Map of Expression->alias - # Existing CTES in the root expression. We'll use this for deduplication. - existing_ctes: ExistingCTEsMapping = {} - - with_ = root.expression.args.get("with_") - recursive = False - if with_: - recursive = with_.args.get("recursive") - for cte in with_.expressions: - existing_ctes[cte.this] = cte.alias - new_ctes = [] - - # We're adding more CTEs, but we want to maintain the DAG order. - # Derived tables within an existing CTE need to come before the existing CTE. - for cte_scope in root.cte_scopes: - # Append all the new CTEs from this existing CTE - for scope in cte_scope.traverse(): - if scope is cte_scope: - # Don't try to eliminate this CTE itself - continue - new_cte = _eliminate(scope, existing_ctes, taken) - if new_cte: - new_ctes.append(new_cte) - - # Append the existing CTE itself - new_ctes.append(cte_scope.expression.parent) - - # Now append the rest - for scope in itertools.chain( - root.union_scopes, root.subquery_scopes, root.table_scopes - ): - for child_scope in scope.traverse(): - new_cte = _eliminate(child_scope, existing_ctes, taken) - if new_cte: - new_ctes.append(new_cte) - - if new_ctes: - query = expression.expression if isinstance(expression, exp.DDL) else expression - query.set("with_", exp.With(expressions=new_ctes, recursive=recursive)) - - return expression - - -def _eliminate( - scope: Scope, existing_ctes: ExistingCTEsMapping, taken: TakenNameMapping -) -> t.Optional[exp.Expression]: - if scope.is_derived_table: - return _eliminate_derived_table(scope, existing_ctes, taken) - - if scope.is_cte: - return _eliminate_cte(scope, existing_ctes, taken) - - return None - - -def _eliminate_derived_table( - scope: Scope, existing_ctes: ExistingCTEsMapping, taken: TakenNameMapping -) -> t.Optional[exp.Expression]: - # This makes sure that we don't: - # - drop the "pivot" arg from a pivoted subquery - # - eliminate a lateral correlated subquery - if scope.parent.pivots or isinstance(scope.parent.expression, exp.Lateral): - return None - - # Get rid of redundant exp.Subquery expressions, i.e. those that are just used as wrappers - to_replace = scope.expression.parent.unwrap() - name, cte = _new_cte(scope, existing_ctes, taken) - table = exp.alias_(exp.table_(name), alias=to_replace.alias or name) - table.set("joins", to_replace.args.get("joins")) - - to_replace.replace(table) - - return cte - - -def _eliminate_cte( - scope: Scope, existing_ctes: ExistingCTEsMapping, taken: TakenNameMapping -) -> t.Optional[exp.Expression]: - parent = scope.expression.parent - name, cte = _new_cte(scope, existing_ctes, taken) - - with_ = parent.parent - parent.pop() - if not with_.expressions: - with_.pop() - - # Rename references to this CTE - for child_scope in scope.parent.traverse(): - for table, source in child_scope.selected_sources.values(): - if source is scope: - new_table = exp.alias_( - exp.table_(name), alias=table.alias_or_name, copy=False - ) - table.replace(new_table) - - return cte - - -def _new_cte( - scope: Scope, existing_ctes: ExistingCTEsMapping, taken: TakenNameMapping -) -> t.Tuple[str, t.Optional[exp.Expression]]: - """ - Returns: - tuple of (name, cte) - where `name` is a new name for this CTE in the root scope and `cte` is a new CTE instance. - If this CTE duplicates an existing CTE, `cte` will be None. - """ - duplicate_cte_alias = existing_ctes.get(scope.expression) - parent = scope.expression.parent - name = parent.alias - - if not name: - name = find_new_name(taken=taken, base="cte") - - if duplicate_cte_alias: - name = duplicate_cte_alias - elif taken.get(name): - name = find_new_name(taken=taken, base=name) - - taken[name] = scope - - if not duplicate_cte_alias: - existing_ctes[scope.expression] = name - cte = exp.CTE( - this=scope.expression, - alias=exp.TableAlias(this=exp.to_identifier(name)), - ) - else: - cte = None - return name, cte diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/isolate_table_selects.py b/third_party/bigframes_vendored/sqlglot/optimizer/isolate_table_selects.py deleted file mode 100644 index f2ebf8a1a8a..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/isolate_table_selects.py +++ /dev/null @@ -1,54 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/isolate_table_selects.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import alias, exp -from bigframes_vendored.sqlglot.errors import OptimizeError -from bigframes_vendored.sqlglot.optimizer.scope import traverse_scope -from bigframes_vendored.sqlglot.schema import ensure_schema - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - from bigframes_vendored.sqlglot.schema import Schema - - -def isolate_table_selects( - expression: E, - schema: t.Optional[t.Dict | Schema] = None, - dialect: DialectType = None, -) -> E: - schema = ensure_schema(schema, dialect=dialect) - - for scope in traverse_scope(expression): - if len(scope.selected_sources) == 1: - continue - - for _, source in scope.selected_sources.values(): - assert source.parent - - if ( - not isinstance(source, exp.Table) - or not schema.column_names(source) - or isinstance(source.parent, exp.Subquery) - or isinstance(source.parent.parent, exp.Table) - ): - continue - - if not source.alias: - raise OptimizeError( - "Tables require an alias. Run qualify_tables optimization." - ) - - source.replace( - exp.select("*") - .from_( - alias(source, source.alias_or_name, table=True), - copy=False, - ) - .subquery(source.alias, copy=False) - ) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/merge_subqueries.py b/third_party/bigframes_vendored/sqlglot/optimizer/merge_subqueries.py deleted file mode 100644 index 81e213ee814..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/merge_subqueries.py +++ /dev/null @@ -1,446 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/merge_subqueries.py - -from __future__ import annotations - -import typing as t -from collections import defaultdict - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.helper import find_new_name, seq_get -from bigframes_vendored.sqlglot.optimizer.scope import Scope, traverse_scope - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - - FromOrJoin = t.Union[exp.From, exp.Join] - - -def merge_subqueries(expression: E, leave_tables_isolated: bool = False) -> E: - """ - Rewrite sqlglot AST to merge derived tables into the outer query. - - This also merges CTEs if they are selected from only once. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("SELECT a FROM (SELECT x.a FROM x) CROSS JOIN y") - >>> merge_subqueries(expression).sql() - 'SELECT x.a FROM x CROSS JOIN y' - - If `leave_tables_isolated` is True, this will not merge inner queries into outer - queries if it would result in multiple table selects in a single query: - >>> expression = sqlglot.parse_one("SELECT a FROM (SELECT x.a FROM x) CROSS JOIN y") - >>> merge_subqueries(expression, leave_tables_isolated=True).sql() - 'SELECT a FROM (SELECT x.a FROM x) CROSS JOIN y' - - Inspired by https://dev.mysql.com/doc/refman/8.0/en/derived-table-optimization.html - - Args: - expression (sqlglot.Expression): expression to optimize - leave_tables_isolated (bool): - Returns: - sqlglot.Expression: optimized expression - """ - expression = merge_ctes(expression, leave_tables_isolated) - expression = merge_derived_tables(expression, leave_tables_isolated) - return expression - - -# If a derived table has these Select args, it can't be merged -UNMERGABLE_ARGS = set(exp.Select.arg_types) - { - "expressions", - "from_", - "joins", - "where", - "order", - "hint", -} - - -# Projections in the outer query that are instances of these types can be replaced -# without getting wrapped in parentheses, because the precedence won't be altered. -SAFE_TO_REPLACE_UNWRAPPED = ( - exp.Column, - exp.EQ, - exp.Func, - exp.NEQ, - exp.Paren, -) - - -def merge_ctes(expression: E, leave_tables_isolated: bool = False) -> E: - scopes = traverse_scope(expression) - - # All places where we select from CTEs. - # We key on the CTE scope so we can detect CTES that are selected from multiple times. - cte_selections = defaultdict(list) - for outer_scope in scopes: - for table, inner_scope in outer_scope.selected_sources.values(): - if isinstance(inner_scope, Scope) and inner_scope.is_cte: - cte_selections[id(inner_scope)].append( - ( - outer_scope, - inner_scope, - table, - ) - ) - - singular_cte_selections = [v[0] for k, v in cte_selections.items() if len(v) == 1] - for outer_scope, inner_scope, table in singular_cte_selections: - from_or_join = table.find_ancestor(exp.From, exp.Join) - if _mergeable(outer_scope, inner_scope, leave_tables_isolated, from_or_join): - alias = table.alias_or_name - _rename_inner_sources(outer_scope, inner_scope, alias) - _merge_from(outer_scope, inner_scope, table, alias) - _merge_expressions(outer_scope, inner_scope, alias) - _merge_order(outer_scope, inner_scope) - _merge_joins(outer_scope, inner_scope, from_or_join) - _merge_where(outer_scope, inner_scope, from_or_join) - _merge_hints(outer_scope, inner_scope) - _pop_cte(inner_scope) - outer_scope.clear_cache() - return expression - - -def merge_derived_tables(expression: E, leave_tables_isolated: bool = False) -> E: - for outer_scope in traverse_scope(expression): - for subquery in outer_scope.derived_tables: - from_or_join = subquery.find_ancestor(exp.From, exp.Join) - alias = subquery.alias_or_name - inner_scope = outer_scope.sources[alias] - if _mergeable( - outer_scope, inner_scope, leave_tables_isolated, from_or_join - ): - _rename_inner_sources(outer_scope, inner_scope, alias) - _merge_from(outer_scope, inner_scope, subquery, alias) - _merge_expressions(outer_scope, inner_scope, alias) - _merge_order(outer_scope, inner_scope) - _merge_joins(outer_scope, inner_scope, from_or_join) - _merge_where(outer_scope, inner_scope, from_or_join) - _merge_hints(outer_scope, inner_scope) - outer_scope.clear_cache() - - return expression - - -def _mergeable( - outer_scope: Scope, - inner_scope: Scope, - leave_tables_isolated: bool, - from_or_join: FromOrJoin, -) -> bool: - """ - Return True if `inner_select` can be merged into outer query. - """ - inner_select = inner_scope.expression.unnest() - - def _is_a_window_expression_in_unmergable_operation(): - window_aliases = { - s.alias_or_name for s in inner_select.selects if s.find(exp.Window) - } - inner_select_name = from_or_join.alias_or_name - unmergable_window_columns = [ - column - for column in outer_scope.columns - if column.find_ancestor( - exp.Where, exp.Group, exp.Order, exp.Join, exp.Having, exp.AggFunc - ) - ] - window_expressions_in_unmergable = [ - column - for column in unmergable_window_columns - if column.table == inner_select_name and column.name in window_aliases - ] - return any(window_expressions_in_unmergable) - - def _outer_select_joins_on_inner_select_join(): - """ - All columns from the inner select in the ON clause must be from the first FROM table. - - That is, this can be merged: - SELECT * FROM x JOIN (SELECT y.a AS a FROM y JOIN z) AS q ON x.a = q.a - ^^^ ^ - But this can't: - SELECT * FROM x JOIN (SELECT z.a AS a FROM y JOIN z) AS q ON x.a = q.a - ^^^ ^ - """ - if not isinstance(from_or_join, exp.Join): - return False - - alias = from_or_join.alias_or_name - - on = from_or_join.args.get("on") - if not on: - return False - selections = [c.name for c in on.find_all(exp.Column) if c.table == alias] - inner_from = inner_scope.expression.args.get("from_") - if not inner_from: - return False - inner_from_table = inner_from.alias_or_name - inner_projections = {s.alias_or_name: s for s in inner_scope.expression.selects} - return any( - col.table != inner_from_table - for selection in selections - for col in inner_projections[selection].find_all(exp.Column) - ) - - def _is_recursive(): - # Recursive CTEs look like this: - # WITH RECURSIVE cte AS ( - # SELECT * FROM x <-- inner scope - # UNION ALL - # SELECT * FROM cte <-- outer scope - # ) - cte = inner_scope.expression.parent - node = outer_scope.expression.parent - - while node: - if node is cte: - return True - node = node.parent - return False - - return ( - isinstance(outer_scope.expression, exp.Select) - and not outer_scope.expression.is_star - and isinstance(inner_select, exp.Select) - and not any(inner_select.args.get(arg) for arg in UNMERGABLE_ARGS) - and inner_select.args.get("from_") is not None - and not outer_scope.pivots - and not any( - e.find(exp.AggFunc, exp.Select, exp.Explode) - for e in inner_select.expressions - ) - and not (leave_tables_isolated and len(outer_scope.selected_sources) > 1) - and not (isinstance(from_or_join, exp.Join) and inner_select.args.get("joins")) - and not ( - isinstance(from_or_join, exp.Join) - and inner_select.args.get("where") - and from_or_join.side in ("FULL", "LEFT", "RIGHT") - ) - and not ( - isinstance(from_or_join, exp.From) - and inner_select.args.get("where") - and any( - j.side in ("FULL", "RIGHT") - for j in outer_scope.expression.args.get("joins", []) - ) - ) - and not _outer_select_joins_on_inner_select_join() - and not _is_a_window_expression_in_unmergable_operation() - and not _is_recursive() - and not (inner_select.args.get("order") and outer_scope.is_union) - and not isinstance(seq_get(inner_select.expressions, 0), exp.QueryTransform) - ) - - -def _rename_inner_sources(outer_scope: Scope, inner_scope: Scope, alias: str) -> None: - """ - Renames any sources in the inner query that conflict with names in the outer query. - """ - inner_taken = set(inner_scope.selected_sources) - outer_taken = set(outer_scope.selected_sources) - conflicts = outer_taken.intersection(inner_taken) - conflicts -= {alias} - - taken = outer_taken.union(inner_taken) - - for conflict in conflicts: - new_name = find_new_name(taken, conflict) - - source, _ = inner_scope.selected_sources[conflict] - new_alias = exp.to_identifier(new_name) - - if isinstance(source, exp.Table) and source.alias: - source.set("alias", new_alias) - elif isinstance(source, exp.Table): - source.replace(exp.alias_(source, new_alias)) - elif isinstance(source.parent, exp.Subquery): - source.parent.set("alias", exp.TableAlias(this=new_alias)) - - for column in inner_scope.source_columns(conflict): - column.set("table", exp.to_identifier(new_name)) - - inner_scope.rename_source(conflict, new_name) - - -def _merge_from( - outer_scope: Scope, - inner_scope: Scope, - node_to_replace: t.Union[exp.Subquery, exp.Table], - alias: str, -) -> None: - """ - Merge FROM clause of inner query into outer query. - """ - new_subquery = inner_scope.expression.args["from_"].this - new_subquery.set("joins", node_to_replace.args.get("joins")) - node_to_replace.replace(new_subquery) - for join_hint in outer_scope.join_hints: - tables = join_hint.find_all(exp.Table) - for table in tables: - if table.alias_or_name == node_to_replace.alias_or_name: - table.set("this", exp.to_identifier(new_subquery.alias_or_name)) - outer_scope.remove_source(alias) - outer_scope.add_source( - new_subquery.alias_or_name, inner_scope.sources[new_subquery.alias_or_name] - ) - - -def _merge_joins( - outer_scope: Scope, inner_scope: Scope, from_or_join: FromOrJoin -) -> None: - """ - Merge JOIN clauses of inner query into outer query. - """ - - new_joins = [] - - joins = inner_scope.expression.args.get("joins") or [] - - for join in joins: - new_joins.append(join) - outer_scope.add_source( - join.alias_or_name, inner_scope.sources[join.alias_or_name] - ) - - if new_joins: - outer_joins = outer_scope.expression.args.get("joins", []) - - # Maintain the join order - if isinstance(from_or_join, exp.From): - position = 0 - else: - position = outer_joins.index(from_or_join) + 1 - outer_joins[position:position] = new_joins - - outer_scope.expression.set("joins", outer_joins) - - -def _merge_expressions(outer_scope: Scope, inner_scope: Scope, alias: str) -> None: - """ - Merge projections of inner query into outer query. - - Args: - outer_scope (sqlglot.optimizer.scope.Scope) - inner_scope (sqlglot.optimizer.scope.Scope) - alias (str) - """ - # Collect all columns that reference the alias of the inner query - outer_columns = defaultdict(list) - for column in outer_scope.columns: - if column.table == alias: - outer_columns[column.name].append(column) - - # Replace columns with the projection expression in the inner query - for expression in inner_scope.expression.expressions: - projection_name = expression.alias_or_name - if not projection_name: - continue - columns_to_replace = outer_columns.get(projection_name, []) - - expression = expression.unalias() - must_wrap_expression = not isinstance(expression, SAFE_TO_REPLACE_UNWRAPPED) - - for column in columns_to_replace: - # Ensures we don't alter the intended operator precedence if there's additional - # context surrounding the outer expression (i.e. it's not a simple projection). - if ( - isinstance(column.parent, (exp.Unary, exp.Binary)) - and must_wrap_expression - ): - expression = exp.paren(expression, copy=False) - - # make sure we do not accidentally change the name of the column - if isinstance(column.parent, exp.Select) and column.name != expression.name: - expression = exp.alias_(expression, column.name) - - column.replace(expression.copy()) - - -def _merge_where( - outer_scope: Scope, inner_scope: Scope, from_or_join: FromOrJoin -) -> None: - """ - Merge WHERE clause of inner query into outer query. - - Args: - outer_scope (sqlglot.optimizer.scope.Scope) - inner_scope (sqlglot.optimizer.scope.Scope) - from_or_join (exp.From|exp.Join) - """ - where = inner_scope.expression.args.get("where") - if not where or not where.this: - return - - expression = outer_scope.expression - - if isinstance(from_or_join, exp.Join): - # Merge predicates from an outer join to the ON clause - # if it only has columns that are already joined - from_ = expression.args.get("from_") - sources = {from_.alias_or_name} if from_ else set() - - for join in expression.args["joins"]: - source = join.alias_or_name - sources.add(source) - if source == from_or_join.alias_or_name: - break - - if exp.column_table_names(where.this) <= sources: - from_or_join.on(where.this, copy=False) - from_or_join.set("on", from_or_join.args.get("on")) - return - - expression.where(where.this, copy=False) - - -def _merge_order(outer_scope: Scope, inner_scope: Scope) -> None: - """ - Merge ORDER clause of inner query into outer query. - - Args: - outer_scope (sqlglot.optimizer.scope.Scope) - inner_scope (sqlglot.optimizer.scope.Scope) - """ - if ( - any( - outer_scope.expression.args.get(arg) - for arg in ["group", "distinct", "having", "order"] - ) - or len(outer_scope.selected_sources) != 1 - or any( - expression.find(exp.AggFunc) - for expression in outer_scope.expression.expressions - ) - ): - return - - outer_scope.expression.set("order", inner_scope.expression.args.get("order")) - - -def _merge_hints(outer_scope: Scope, inner_scope: Scope) -> None: - inner_scope_hint = inner_scope.expression.args.get("hint") - if not inner_scope_hint: - return - outer_scope_hint = outer_scope.expression.args.get("hint") - if outer_scope_hint: - for hint_expression in inner_scope_hint.expressions: - outer_scope_hint.append("expressions", hint_expression) - else: - outer_scope.expression.set("hint", inner_scope_hint) - - -def _pop_cte(inner_scope: Scope) -> None: - """ - Remove CTE from the AST. - - Args: - inner_scope (sqlglot.optimizer.scope.Scope) - """ - cte = inner_scope.expression.parent - with_ = cte.parent - if len(with_.expressions) == 1: - with_.pop() - else: - cte.pop() diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/normalize.py b/third_party/bigframes_vendored/sqlglot/optimizer/normalize.py deleted file mode 100644 index daa4bfb84d0..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/normalize.py +++ /dev/null @@ -1,216 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/normalize.py - -from __future__ import annotations - -import logging - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.errors import OptimizeError -from bigframes_vendored.sqlglot.helper import while_changing -from bigframes_vendored.sqlglot.optimizer.scope import find_all_in_scope -from bigframes_vendored.sqlglot.optimizer.simplify import Simplifier, flatten - -logger = logging.getLogger("sqlglot") - - -def normalize(expression: exp.Expression, dnf: bool = False, max_distance: int = 128): - """ - Rewrite sqlglot AST into conjunctive normal form or disjunctive normal form. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("(x AND y) OR z") - >>> normalize(expression, dnf=False).sql() - '(x OR z) AND (y OR z)' - - Args: - expression: expression to normalize - dnf: rewrite in disjunctive normal form instead. - max_distance (int): the maximal estimated distance from cnf/dnf to attempt conversion - Returns: - sqlglot.Expression: normalized expression - """ - simplifier = Simplifier(annotate_new_expressions=False) - - for node in tuple(expression.walk(prune=lambda e: isinstance(e, exp.Connector))): - if isinstance(node, exp.Connector): - if normalized(node, dnf=dnf): - continue - root = node is expression - original = node.copy() - - node.transform(simplifier.rewrite_between, copy=False) - distance = normalization_distance(node, dnf=dnf, max_=max_distance) - - if distance > max_distance: - logger.info( - f"Skipping normalization because distance {distance} exceeds max {max_distance}" - ) - return expression - - try: - node = node.replace( - while_changing( - node, - lambda e: distributive_law( - e, dnf, max_distance, simplifier=simplifier - ), - ) - ) - except OptimizeError as e: - logger.info(e) - node.replace(original) - if root: - return original - return expression - - if root: - expression = node - - return expression - - -def normalized(expression: exp.Expression, dnf: bool = False) -> bool: - """ - Checks whether a given expression is in a normal form of interest. - - Example: - >>> from sqlglot import parse_one - >>> normalized(parse_one("(a AND b) OR c OR (d AND e)"), dnf=True) - True - >>> normalized(parse_one("(a OR b) AND c")) # Checks CNF by default - True - >>> normalized(parse_one("a AND (b OR c)"), dnf=True) - False - - Args: - expression: The expression to check if it's normalized. - dnf: Whether to check if the expression is in Disjunctive Normal Form (DNF). - Default: False, i.e. we check if it's in Conjunctive Normal Form (CNF). - """ - ancestor, root = (exp.And, exp.Or) if dnf else (exp.Or, exp.And) - return not any( - connector.find_ancestor(ancestor) - for connector in find_all_in_scope(expression, root) - ) - - -def normalization_distance( - expression: exp.Expression, dnf: bool = False, max_: float = float("inf") -) -> int: - """ - The difference in the number of predicates between a given expression and its normalized form. - - This is used as an estimate of the cost of the conversion which is exponential in complexity. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("(a AND b) OR (c AND d)") - >>> normalization_distance(expression) - 4 - - Args: - expression: The expression to compute the normalization distance for. - dnf: Whether to check if the expression is in Disjunctive Normal Form (DNF). - Default: False, i.e. we check if it's in Conjunctive Normal Form (CNF). - max_: stop early if count exceeds this. - - Returns: - The normalization distance. - """ - total = -(sum(1 for _ in expression.find_all(exp.Connector)) + 1) - - for length in _predicate_lengths(expression, dnf, max_): - total += length - if total > max_: - return total - - return total - - -def _predicate_lengths(expression, dnf, max_=float("inf"), depth=0): - """ - Returns a list of predicate lengths when expanded to normalized form. - - (A AND B) OR C -> [2, 2] because len(A OR C), len(B OR C). - """ - if depth > max_: - yield depth - return - - expression = expression.unnest() - - if not isinstance(expression, exp.Connector): - yield 1 - return - - depth += 1 - left, right = expression.args.values() - - if isinstance(expression, exp.And if dnf else exp.Or): - for a in _predicate_lengths(left, dnf, max_, depth): - for b in _predicate_lengths(right, dnf, max_, depth): - yield a + b - else: - yield from _predicate_lengths(left, dnf, max_, depth) - yield from _predicate_lengths(right, dnf, max_, depth) - - -def distributive_law(expression, dnf, max_distance, simplifier=None): - """ - x OR (y AND z) -> (x OR y) AND (x OR z) - (x AND y) OR (y AND z) -> (x OR y) AND (x OR z) AND (y OR y) AND (y OR z) - """ - if normalized(expression, dnf=dnf): - return expression - - distance = normalization_distance(expression, dnf=dnf, max_=max_distance) - - if distance > max_distance: - raise OptimizeError( - f"Normalization distance {distance} exceeds max {max_distance}" - ) - - exp.replace_children(expression, lambda e: distributive_law(e, dnf, max_distance)) - to_exp, from_exp = (exp.Or, exp.And) if dnf else (exp.And, exp.Or) - - if isinstance(expression, from_exp): - a, b = expression.unnest_operands() - - from_func = exp.and_ if from_exp == exp.And else exp.or_ - to_func = exp.and_ if to_exp == exp.And else exp.or_ - - simplifier = simplifier or Simplifier(annotate_new_expressions=False) - - if isinstance(a, to_exp) and isinstance(b, to_exp): - if len(tuple(a.find_all(exp.Connector))) > len( - tuple(b.find_all(exp.Connector)) - ): - return _distribute(a, b, from_func, to_func, simplifier) - return _distribute(b, a, from_func, to_func, simplifier) - if isinstance(a, to_exp): - return _distribute(b, a, from_func, to_func, simplifier) - if isinstance(b, to_exp): - return _distribute(a, b, from_func, to_func, simplifier) - - return expression - - -def _distribute(a, b, from_func, to_func, simplifier): - if isinstance(a, exp.Connector): - exp.replace_children( - a, - lambda c: to_func( - simplifier.uniq_sort(flatten(from_func(c, b.left))), - simplifier.uniq_sort(flatten(from_func(c, b.right))), - copy=False, - ), - ) - else: - a = to_func( - simplifier.uniq_sort(flatten(from_func(a, b.left))), - simplifier.uniq_sort(flatten(from_func(a, b.right))), - copy=False, - ) - - return a diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/normalize_identifiers.py b/third_party/bigframes_vendored/sqlglot/optimizer/normalize_identifiers.py deleted file mode 100644 index eacf4305c49..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/normalize_identifiers.py +++ /dev/null @@ -1,86 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/normalize_identifiers.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - - -@t.overload -def normalize_identifiers( - expression: E, - dialect: DialectType = None, - store_original_column_identifiers: bool = False, -) -> E: ... - - -@t.overload -def normalize_identifiers( - expression: str, - dialect: DialectType = None, - store_original_column_identifiers: bool = False, -) -> exp.Identifier: ... - - -def normalize_identifiers( - expression, dialect=None, store_original_column_identifiers=False -): - """ - Normalize identifiers by converting them to either lower or upper case, - ensuring the semantics are preserved in each case (e.g. by respecting - case-sensitivity). - - This transformation reflects how identifiers would be resolved by the engine corresponding - to each SQL dialect, and plays a very important role in the standardization of the AST. - - It's possible to make this a no-op by adding a special comment next to the - identifier of interest: - - SELECT a /* sqlglot.meta case_sensitive */ FROM table - - In this example, the identifier `a` will not be normalized. - - Note: - Some dialects (e.g. DuckDB) treat all identifiers as case-insensitive even - when they're quoted, so in these cases all identifiers are normalized. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one('SELECT Bar.A AS A FROM "Foo".Bar') - >>> normalize_identifiers(expression).sql() - 'SELECT bar.a AS a FROM "Foo".bar' - >>> normalize_identifiers("foo", dialect="snowflake").sql(dialect="snowflake") - 'FOO' - - Args: - expression: The expression to transform. - dialect: The dialect to use in order to decide how to normalize identifiers. - store_original_column_identifiers: Whether to store the original column identifiers in - the meta data of the expression in case we want to undo the normalization at a later point. - - Returns: - The transformed expression. - """ - dialect = Dialect.get_or_raise(dialect) - - if isinstance(expression, str): - expression = exp.parse_identifier(expression, dialect=dialect) - - for node in expression.walk(prune=lambda n: n.meta.get("case_sensitive")): - if not node.meta.get("case_sensitive"): - if store_original_column_identifiers and isinstance(node, exp.Column): - # TODO: This does not handle non-column cases, e.g PARSE_JSON(...).key - parent = node - while parent and isinstance(parent.parent, exp.Dot): - parent = parent.parent - - node.meta["dot_parts"] = [p.name for p in parent.parts] - - dialect.normalize_identifier(node) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/optimize_joins.py b/third_party/bigframes_vendored/sqlglot/optimizer/optimize_joins.py deleted file mode 100644 index d09d8cc6ce0..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/optimize_joins.py +++ /dev/null @@ -1,128 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/optimize_joins.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.helper import tsort - -JOIN_ATTRS = ("on", "side", "kind", "using", "method") - - -def optimize_joins(expression): - """ - Removes cross joins if possible and reorder joins based on predicate dependencies. - - Example: - >>> from sqlglot import parse_one - >>> optimize_joins(parse_one("SELECT * FROM x CROSS JOIN y JOIN z ON x.a = z.a AND y.a = z.a")).sql() - 'SELECT * FROM x JOIN z ON x.a = z.a AND TRUE JOIN y ON y.a = z.a' - """ - - for select in expression.find_all(exp.Select): - joins = select.args.get("joins", []) - - if not _is_reorderable(joins): - continue - - references = {} - cross_joins = [] - - for join in joins: - tables = other_table_names(join) - - if tables: - for table in tables: - references[table] = references.get(table, []) + [join] - else: - cross_joins.append((join.alias_or_name, join)) - - for name, join in cross_joins: - for dep in references.get(name, []): - on = dep.args["on"] - - if isinstance(on, exp.Connector): - if len(other_table_names(dep)) < 2: - continue - - operator = type(on) - for predicate in on.flatten(): - if name in exp.column_table_names(predicate): - predicate.replace(exp.true()) - predicate = exp._combine( - [join.args.get("on"), predicate], operator, copy=False - ) - join.on(predicate, append=False, copy=False) - - expression = reorder_joins(expression) - expression = normalize(expression) - return expression - - -def reorder_joins(expression): - """ - Reorder joins by topological sort order based on predicate references. - """ - for from_ in expression.find_all(exp.From): - parent = from_.parent - joins = parent.args.get("joins", []) - - if not _is_reorderable(joins): - continue - - joins_by_name = {join.alias_or_name: join for join in joins} - dag = {name: other_table_names(join) for name, join in joins_by_name.items()} - parent.set( - "joins", - [ - joins_by_name[name] - for name in tsort(dag) - if name != from_.alias_or_name and name in joins_by_name - ], - ) - return expression - - -def normalize(expression): - """ - Remove INNER and OUTER from joins as they are optional. - """ - for join in expression.find_all(exp.Join): - if not any(join.args.get(k) for k in JOIN_ATTRS): - join.set("kind", "CROSS") - - if join.kind == "CROSS": - join.set("on", None) - else: - if join.kind in ("INNER", "OUTER"): - join.set("kind", None) - - if not join.args.get("on") and not join.args.get("using"): - join.set("on", exp.true()) - return expression - - -def other_table_names(join: exp.Join) -> t.Set[str]: - on = join.args.get("on") - return exp.column_table_names(on, join.alias_or_name) if on else set() - - -def _is_reorderable(joins: t.List[exp.Join]) -> bool: - """ - Checks if joins can be reordered without changing query semantics. - - Joins with a side (LEFT, RIGHT, FULL) cannot be reordered easily, - the order affects which rows are included in the result. - - Example: - >>> from sqlglot import parse_one, exp - >>> from sqlglot.optimizer.optimize_joins import _is_reorderable - >>> ast = parse_one("SELECT * FROM x JOIN y ON x.id = y.id JOIN z ON y.id = z.id") - >>> _is_reorderable(ast.find(exp.Select).args.get("joins", [])) - True - >>> ast = parse_one("SELECT * FROM x LEFT JOIN y ON x.id = y.id JOIN z ON y.id = z.id") - >>> _is_reorderable(ast.find(exp.Select).args.get("joins", [])) - False - """ - return not any(join.side for join in joins) diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/optimizer.py b/third_party/bigframes_vendored/sqlglot/optimizer/optimizer.py deleted file mode 100644 index ba13d17383e..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/optimizer.py +++ /dev/null @@ -1,106 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/optimizer.py - -from __future__ import annotations - -import inspect -import typing as t - -from bigframes_vendored.sqlglot import Schema, exp -from bigframes_vendored.sqlglot.dialects.dialect import DialectType -from bigframes_vendored.sqlglot.optimizer.annotate_types import annotate_types -from bigframes_vendored.sqlglot.optimizer.canonicalize import canonicalize -from bigframes_vendored.sqlglot.optimizer.eliminate_ctes import eliminate_ctes -from bigframes_vendored.sqlglot.optimizer.eliminate_joins import eliminate_joins -from bigframes_vendored.sqlglot.optimizer.eliminate_subqueries import ( - eliminate_subqueries, -) -from bigframes_vendored.sqlglot.optimizer.merge_subqueries import merge_subqueries -from bigframes_vendored.sqlglot.optimizer.normalize import normalize -from bigframes_vendored.sqlglot.optimizer.optimize_joins import optimize_joins -from bigframes_vendored.sqlglot.optimizer.pushdown_predicates import pushdown_predicates -from bigframes_vendored.sqlglot.optimizer.pushdown_projections import ( - pushdown_projections, -) -from bigframes_vendored.sqlglot.optimizer.qualify import qualify -from bigframes_vendored.sqlglot.optimizer.qualify_columns import quote_identifiers -from bigframes_vendored.sqlglot.optimizer.simplify import simplify -from bigframes_vendored.sqlglot.optimizer.unnest_subqueries import unnest_subqueries -from bigframes_vendored.sqlglot.schema import ensure_schema - -RULES = ( - qualify, - pushdown_projections, - normalize, - unnest_subqueries, - pushdown_predicates, - optimize_joins, - eliminate_subqueries, - merge_subqueries, - eliminate_joins, - eliminate_ctes, - quote_identifiers, - annotate_types, - canonicalize, - simplify, -) - - -def optimize( - expression: str | exp.Expression, - schema: t.Optional[dict | Schema] = None, - db: t.Optional[str | exp.Identifier] = None, - catalog: t.Optional[str | exp.Identifier] = None, - dialect: DialectType = None, - rules: t.Sequence[t.Callable] = RULES, - sql: t.Optional[str] = None, - **kwargs, -) -> exp.Expression: - """ - Rewrite a sqlglot AST into an optimized form. - - Args: - expression: expression to optimize - schema: database schema. - This can either be an instance of `sqlglot.optimizer.Schema` or a mapping in one of - the following forms: - 1. {table: {col: type}} - 2. {db: {table: {col: type}}} - 3. {catalog: {db: {table: {col: type}}}} - If no schema is provided then the default schema defined at `sqlgot.schema` will be used - db: specify the default database, as might be set by a `USE DATABASE db` statement - catalog: specify the default catalog, as might be set by a `USE CATALOG c` statement - dialect: The dialect to parse the sql string. - rules: sequence of optimizer rules to use. - Many of the rules require tables and columns to be qualified. - Do not remove `qualify` from the sequence of rules unless you know what you're doing! - sql: Original SQL string for error highlighting. If not provided, errors will not include - highlighting. Requires that the expression has position metadata from parsing. - **kwargs: If a rule has a keyword argument with a same name in **kwargs, it will be passed in. - - Returns: - The optimized expression. - """ - schema = ensure_schema(schema, dialect=dialect) - possible_kwargs = { - "db": db, - "catalog": catalog, - "schema": schema, - "dialect": dialect, - "sql": sql, - "isolate_tables": True, # needed for other optimizations to perform well - "quote_identifiers": False, - **kwargs, - } - - optimized = exp.maybe_parse(expression, dialect=dialect, copy=True) - for rule in rules: - # Find any additional rule parameters, beyond `expression` - rule_params = inspect.getfullargspec(rule).args - rule_kwargs = { - param: possible_kwargs[param] - for param in rule_params - if param in possible_kwargs - } - optimized = rule(optimized, **rule_kwargs) - - return optimized diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_predicates.py b/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_predicates.py deleted file mode 100644 index 092d513ac7d..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_predicates.py +++ /dev/null @@ -1,237 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/pushdown_predicates.py - -from bigframes_vendored.sqlglot import Dialect, exp -from bigframes_vendored.sqlglot.optimizer.normalize import normalized -from bigframes_vendored.sqlglot.optimizer.scope import build_scope, find_in_scope -from bigframes_vendored.sqlglot.optimizer.simplify import simplify - - -def pushdown_predicates(expression, dialect=None): - """ - Rewrite sqlglot AST to pushdown predicates in FROMS and JOINS - - Example: - >>> import sqlglot - >>> sql = "SELECT y.a AS a FROM (SELECT x.a AS a FROM x AS x) AS y WHERE y.a = 1" - >>> expression = sqlglot.parse_one(sql) - >>> pushdown_predicates(expression).sql() - 'SELECT y.a AS a FROM (SELECT x.a AS a FROM x AS x WHERE x.a = 1) AS y WHERE TRUE' - - Args: - expression (sqlglot.Expression): expression to optimize - Returns: - sqlglot.Expression: optimized expression - """ - from bigframes_vendored.sqlglot.dialects.athena import Athena - from bigframes_vendored.sqlglot.dialects.presto import Presto - - root = build_scope(expression) - - dialect = Dialect.get_or_raise(dialect) - unnest_requires_cross_join = isinstance(dialect, (Athena, Presto)) - - if root: - scope_ref_count = root.ref_count() - - for scope in reversed(list(root.traverse())): - select = scope.expression - where = select.args.get("where") - if where: - selected_sources = scope.selected_sources - join_index = { - join.alias_or_name: i - for i, join in enumerate(select.args.get("joins") or []) - } - - # a right join can only push down to itself and not the source FROM table - # presto, trino and athena don't support inner joins where the RHS is an UNNEST expression - pushdown_allowed = True - for k, (node, source) in selected_sources.items(): - parent = node.find_ancestor(exp.Join, exp.From) - if isinstance(parent, exp.Join): - if parent.side == "RIGHT": - selected_sources = {k: (node, source)} - break - if isinstance(node, exp.Unnest) and unnest_requires_cross_join: - pushdown_allowed = False - break - - if pushdown_allowed: - pushdown( - where.this, - selected_sources, - scope_ref_count, - dialect, - join_index, - ) - - # joins should only pushdown into itself, not to other joins - # so we limit the selected sources to only itself - for join in select.args.get("joins") or []: - name = join.alias_or_name - if name in scope.selected_sources: - pushdown( - join.args.get("on"), - {name: scope.selected_sources[name]}, - scope_ref_count, - dialect, - ) - - return expression - - -def pushdown(condition, sources, scope_ref_count, dialect, join_index=None): - if not condition: - return - - condition = condition.replace(simplify(condition, dialect=dialect)) - cnf_like = normalized(condition) or not normalized(condition, dnf=True) - - predicates = list( - condition.flatten() - if isinstance(condition, exp.And if cnf_like else exp.Or) - else [condition] - ) - - if cnf_like: - pushdown_cnf(predicates, sources, scope_ref_count, join_index=join_index) - else: - pushdown_dnf(predicates, sources, scope_ref_count) - - -def pushdown_cnf(predicates, sources, scope_ref_count, join_index=None): - """ - If the predicates are in CNF like form, we can simply replace each block in the parent. - """ - join_index = join_index or {} - for predicate in predicates: - for node in nodes_for_predicate(predicate, sources, scope_ref_count).values(): - if isinstance(node, exp.Join): - name = node.alias_or_name - predicate_tables = exp.column_table_names(predicate, name) - - # Don't push the predicate if it references tables that appear in later joins - this_index = join_index[name] - if all( - join_index.get(table, -1) < this_index for table in predicate_tables - ): - predicate.replace(exp.true()) - node.on(predicate, copy=False) - break - if isinstance(node, exp.Select): - predicate.replace(exp.true()) - inner_predicate = replace_aliases(node, predicate) - if find_in_scope(inner_predicate, exp.AggFunc): - node.having(inner_predicate, copy=False) - else: - node.where(inner_predicate, copy=False) - - -def pushdown_dnf(predicates, sources, scope_ref_count): - """ - If the predicates are in DNF form, we can only push down conditions that are in all blocks. - Additionally, we can't remove predicates from their original form. - """ - # find all the tables that can be pushdown too - # these are tables that are referenced in all blocks of a DNF - # (a.x AND b.x) OR (a.y AND c.y) - # only table a can be push down - pushdown_tables = set() - - for a in predicates: - a_tables = exp.column_table_names(a) - - for b in predicates: - a_tables &= exp.column_table_names(b) - - pushdown_tables.update(a_tables) - - conditions = {} - - # pushdown all predicates to their respective nodes - for table in sorted(pushdown_tables): - for predicate in predicates: - nodes = nodes_for_predicate(predicate, sources, scope_ref_count) - - if table not in nodes: - continue - - conditions[table] = ( - exp.or_(conditions[table], predicate) - if table in conditions - else predicate - ) - - for name, node in nodes.items(): - if name not in conditions: - continue - - predicate = conditions[name] - - if isinstance(node, exp.Join): - node.on(predicate, copy=False) - elif isinstance(node, exp.Select): - inner_predicate = replace_aliases(node, predicate) - if find_in_scope(inner_predicate, exp.AggFunc): - node.having(inner_predicate, copy=False) - else: - node.where(inner_predicate, copy=False) - - -def nodes_for_predicate(predicate, sources, scope_ref_count): - nodes = {} - tables = exp.column_table_names(predicate) - where_condition = isinstance( - predicate.find_ancestor(exp.Join, exp.Where), exp.Where - ) - - for table in sorted(tables): - node, source = sources.get(table) or (None, None) - - # if the predicate is in a where statement we can try to push it down - # we want to find the root join or from statement - if node and where_condition: - node = node.find_ancestor(exp.Join, exp.From) - - # a node can reference a CTE which should be pushed down - if isinstance(node, exp.From) and not isinstance(source, exp.Table): - with_ = source.parent.expression.args.get("with_") - if with_ and with_.recursive: - return {} - node = source.expression - - if isinstance(node, exp.Join): - if node.side and node.side != "RIGHT": - return {} - nodes[table] = node - elif isinstance(node, exp.Select) and len(tables) == 1: - # We can't push down window expressions - has_window_expression = any( - select for select in node.selects if select.find(exp.Window) - ) - # we can't push down predicates to select statements if they are referenced in - # multiple places. - if ( - not node.args.get("group") - and scope_ref_count[id(source)] < 2 - and not has_window_expression - ): - nodes[table] = node - return nodes - - -def replace_aliases(source, predicate): - aliases = {} - - for select in source.selects: - if isinstance(select, exp.Alias): - aliases[select.alias] = select.this - else: - aliases[select.name] = select - - def _replace_alias(column): - if isinstance(column, exp.Column) and column.name in aliases: - return aliases[column.name].copy() - return column - - return predicate.transform(_replace_alias) diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_projections.py b/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_projections.py deleted file mode 100644 index b83dcb2c563..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/pushdown_projections.py +++ /dev/null @@ -1,183 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/pushdown_projections.py - -from __future__ import annotations - -import typing as t -from collections import defaultdict - -from bigframes_vendored.sqlglot import alias, exp -from bigframes_vendored.sqlglot.errors import OptimizeError -from bigframes_vendored.sqlglot.helper import seq_get -from bigframes_vendored.sqlglot.optimizer.qualify_columns import Resolver -from bigframes_vendored.sqlglot.optimizer.scope import Scope, traverse_scope -from bigframes_vendored.sqlglot.schema import ensure_schema - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - from bigframes_vendored.sqlglot.schema import Schema - -# Sentinel value that means an outer query selecting ALL columns -SELECT_ALL = object() - - -# Selection to use if selection list is empty -def default_selection(is_agg: bool) -> exp.Alias: - return alias(exp.Max(this=exp.Literal.number(1)) if is_agg else "1", "_") - - -def pushdown_projections( - expression: E, - schema: t.Optional[t.Dict | Schema] = None, - remove_unused_selections: bool = True, - dialect: DialectType = None, -) -> E: - """ - Rewrite sqlglot AST to remove unused columns projections. - - Example: - >>> import sqlglot - >>> sql = "SELECT y.a AS a FROM (SELECT x.a AS a, x.b AS b FROM x) AS y" - >>> expression = sqlglot.parse_one(sql) - >>> pushdown_projections(expression).sql() - 'SELECT y.a AS a FROM (SELECT x.a AS a FROM x) AS y' - - Args: - expression (sqlglot.Expression): expression to optimize - remove_unused_selections (bool): remove selects that are unused - Returns: - sqlglot.Expression: optimized expression - """ - # Map of Scope to all columns being selected by outer queries. - schema = ensure_schema(schema, dialect=dialect) - source_column_alias_count: t.Dict[exp.Expression | Scope, int] = {} - referenced_columns: t.DefaultDict[Scope, t.Set[str | object]] = defaultdict(set) - - # We build the scope tree (which is traversed in DFS postorder), then iterate - # over the result in reverse order. This should ensure that the set of selected - # columns for a particular scope are completely build by the time we get to it. - for scope in reversed(traverse_scope(expression)): - parent_selections = referenced_columns.get(scope, {SELECT_ALL}) - alias_count = source_column_alias_count.get(scope, 0) - - # We can't remove columns SELECT DISTINCT nor UNION DISTINCT. - if scope.expression.args.get("distinct"): - parent_selections = {SELECT_ALL} - - if isinstance(scope.expression, exp.SetOperation): - set_op = scope.expression - if not (set_op.kind or set_op.side): - # Do not optimize this set operation if it's using the BigQuery specific - # kind / side syntax (e.g INNER UNION ALL BY NAME) which changes the semantics of the operation - left, right = scope.union_scopes - if len(left.expression.selects) != len(right.expression.selects): - scope_sql = scope.expression.sql(dialect=dialect) - raise OptimizeError( - f"Invalid set operation due to column mismatch: {scope_sql}." - ) - - referenced_columns[left] = parent_selections - - if any(select.is_star for select in right.expression.selects): - referenced_columns[right] = parent_selections - elif not any(select.is_star for select in left.expression.selects): - if scope.expression.args.get("by_name"): - referenced_columns[right] = referenced_columns[left] - else: - referenced_columns[right] = { - right.expression.selects[i].alias_or_name - for i, select in enumerate(left.expression.selects) - if SELECT_ALL in parent_selections - or select.alias_or_name in parent_selections - } - - if isinstance(scope.expression, exp.Select): - if remove_unused_selections: - _remove_unused_selections(scope, parent_selections, schema, alias_count) - - if scope.expression.is_star: - continue - - # Group columns by source name - selects = defaultdict(set) - for col in scope.columns: - table_name = col.table - col_name = col.name - selects[table_name].add(col_name) - - # Push the selected columns down to the next scope - for name, (node, source) in scope.selected_sources.items(): - if isinstance(source, Scope): - select = seq_get(source.expression.selects, 0) - - if scope.pivots or isinstance(select, exp.QueryTransform): - columns = {SELECT_ALL} - else: - columns = selects.get(name) or set() - - referenced_columns[source].update(columns) - - column_aliases = node.alias_column_names - if column_aliases: - source_column_alias_count[source] = len(column_aliases) - - return expression - - -def _remove_unused_selections(scope, parent_selections, schema, alias_count): - order = scope.expression.args.get("order") - - if order: - # Assume columns without a qualified table are references to output columns - order_refs = {c.name for c in order.find_all(exp.Column) if not c.table} - else: - order_refs = set() - - new_selections = [] - removed = False - star = False - is_agg = False - - select_all = SELECT_ALL in parent_selections - - for selection in scope.expression.selects: - name = selection.alias_or_name - - if ( - select_all - or name in parent_selections - or name in order_refs - or alias_count > 0 - ): - new_selections.append(selection) - alias_count -= 1 - else: - if selection.is_star: - star = True - removed = True - - if not is_agg and selection.find(exp.AggFunc): - is_agg = True - - if star: - resolver = Resolver(scope, schema) - names = {s.alias_or_name for s in new_selections} - - for name in sorted(parent_selections): - if name not in names: - new_selections.append( - alias( - exp.column(name, table=resolver.get_table(name)), - name, - copy=False, - ) - ) - - # If there are no remaining selections, just select a single constant - if not new_selections: - new_selections.append(default_selection(is_agg)) - - scope.expression.select(*new_selections, append=False, copy=False) - - if removed: - scope.clear_cache() diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/qualify.py b/third_party/bigframes_vendored/sqlglot/optimizer/qualify.py deleted file mode 100644 index cf518b06015..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/qualify.py +++ /dev/null @@ -1,124 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/qualify.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType -from bigframes_vendored.sqlglot.optimizer.isolate_table_selects import ( - isolate_table_selects, -) -from bigframes_vendored.sqlglot.optimizer.normalize_identifiers import ( - normalize_identifiers, -) -from bigframes_vendored.sqlglot.optimizer.qualify_columns import ( - qualify_columns as qualify_columns_func, -) -from bigframes_vendored.sqlglot.optimizer.qualify_columns import ( - quote_identifiers as quote_identifiers_func, -) -from bigframes_vendored.sqlglot.optimizer.qualify_columns import ( - validate_qualify_columns as validate_qualify_columns_func, -) -from bigframes_vendored.sqlglot.optimizer.qualify_tables import qualify_tables -from bigframes_vendored.sqlglot.schema import Schema, ensure_schema - - -def qualify( - expression: exp.Expression, - dialect: DialectType = None, - db: t.Optional[str] = None, - catalog: t.Optional[str] = None, - schema: t.Optional[dict | Schema] = None, - expand_alias_refs: bool = True, - expand_stars: bool = True, - infer_schema: t.Optional[bool] = None, - isolate_tables: bool = False, - qualify_columns: bool = True, - allow_partial_qualification: bool = False, - validate_qualify_columns: bool = True, - quote_identifiers: bool = True, - identify: bool = True, - canonicalize_table_aliases: bool = False, - on_qualify: t.Optional[t.Callable[[exp.Expression], None]] = None, - sql: t.Optional[str] = None, -) -> exp.Expression: - """ - Rewrite sqlglot AST to have normalized and qualified tables and columns. - - This step is necessary for all further SQLGlot optimizations. - - Example: - >>> import sqlglot - >>> schema = {"tbl": {"col": "INT"}} - >>> expression = sqlglot.parse_one("SELECT col FROM tbl") - >>> qualify(expression, schema=schema).sql() - 'SELECT "tbl"."col" AS "col" FROM "tbl" AS "tbl"' - - Args: - expression: Expression to qualify. - db: Default database name for tables. - catalog: Default catalog name for tables. - schema: Schema to infer column names and types. - expand_alias_refs: Whether to expand references to aliases. - expand_stars: Whether to expand star queries. This is a necessary step - for most of the optimizer's rules to work; do not set to False unless you - know what you're doing! - infer_schema: Whether to infer the schema if missing. - isolate_tables: Whether to isolate table selects. - qualify_columns: Whether to qualify columns. - allow_partial_qualification: Whether to allow partial qualification. - validate_qualify_columns: Whether to validate columns. - quote_identifiers: Whether to run the quote_identifiers step. - This step is necessary to ensure correctness for case sensitive queries. - But this flag is provided in case this step is performed at a later time. - identify: If True, quote all identifiers, else only necessary ones. - canonicalize_table_aliases: Whether to use canonical aliases (_0, _1, ...) for all sources - instead of preserving table names. - on_qualify: Callback after a table has been qualified. - sql: Original SQL string for error highlighting. If not provided, errors will not include - highlighting. Requires that the expression has position metadata from parsing. - - Returns: - The qualified expression. - """ - schema = ensure_schema(schema, dialect=dialect) - dialect = Dialect.get_or_raise(dialect) - - expression = normalize_identifiers( - expression, - dialect=dialect, - store_original_column_identifiers=True, - ) - expression = qualify_tables( - expression, - db=db, - catalog=catalog, - dialect=dialect, - on_qualify=on_qualify, - canonicalize_table_aliases=canonicalize_table_aliases, - ) - - if isolate_tables: - expression = isolate_table_selects(expression, schema=schema) - - if qualify_columns: - expression = qualify_columns_func( - expression, - schema, - expand_alias_refs=expand_alias_refs, - expand_stars=expand_stars, - infer_schema=infer_schema, - allow_partial_qualification=allow_partial_qualification, - ) - - if quote_identifiers: - expression = quote_identifiers_func( - expression, dialect=dialect, identify=identify - ) - - if validate_qualify_columns: - validate_qualify_columns_func(expression, sql=sql) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/qualify_columns.py b/third_party/bigframes_vendored/sqlglot/optimizer/qualify_columns.py deleted file mode 100644 index 51a0a6d4dc0..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/qualify_columns.py +++ /dev/null @@ -1,1053 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/qualify_columns.py - -from __future__ import annotations - -import itertools -import typing as t - -from bigframes_vendored.sqlglot import alias, exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType -from bigframes_vendored.sqlglot.errors import OptimizeError, highlight_sql -from bigframes_vendored.sqlglot.helper import seq_get -from bigframes_vendored.sqlglot.optimizer.annotate_types import TypeAnnotator -from bigframes_vendored.sqlglot.optimizer.resolver import Resolver -from bigframes_vendored.sqlglot.optimizer.scope import ( - Scope, - build_scope, - traverse_scope, - walk_in_scope, -) -from bigframes_vendored.sqlglot.optimizer.simplify import simplify_parens -from bigframes_vendored.sqlglot.schema import Schema, ensure_schema - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - - -def qualify_columns( - expression: exp.Expression, - schema: t.Dict | Schema, - expand_alias_refs: bool = True, - expand_stars: bool = True, - infer_schema: t.Optional[bool] = None, - allow_partial_qualification: bool = False, - dialect: DialectType = None, -) -> exp.Expression: - """ - Rewrite sqlglot AST to have fully qualified columns. - - Example: - >>> import sqlglot - >>> schema = {"tbl": {"col": "INT"}} - >>> expression = sqlglot.parse_one("SELECT col FROM tbl") - >>> qualify_columns(expression, schema).sql() - 'SELECT tbl.col AS col FROM tbl' - - Args: - expression: Expression to qualify. - schema: Database schema. - expand_alias_refs: Whether to expand references to aliases. - expand_stars: Whether to expand star queries. This is a necessary step - for most of the optimizer's rules to work; do not set to False unless you - know what you're doing! - infer_schema: Whether to infer the schema if missing. - allow_partial_qualification: Whether to allow partial qualification. - - Returns: - The qualified expression. - - Notes: - - Currently only handles a single PIVOT or UNPIVOT operator - """ - schema = ensure_schema(schema, dialect=dialect) - annotator = TypeAnnotator(schema) - infer_schema = schema.empty if infer_schema is None else infer_schema - dialect = schema.dialect or Dialect() - pseudocolumns = dialect.PSEUDOCOLUMNS - - for scope in traverse_scope(expression): - if dialect.PREFER_CTE_ALIAS_COLUMN: - pushdown_cte_alias_columns(scope) - - scope_expression = scope.expression - is_select = isinstance(scope_expression, exp.Select) - - _separate_pseudocolumns(scope, pseudocolumns) - - resolver = Resolver(scope, schema, infer_schema=infer_schema) - _pop_table_column_aliases(scope.ctes) - _pop_table_column_aliases(scope.derived_tables) - using_column_tables = _expand_using(scope, resolver) - - if ( - schema.empty or dialect.FORCE_EARLY_ALIAS_REF_EXPANSION - ) and expand_alias_refs: - _expand_alias_refs( - scope, - resolver, - dialect, - expand_only_groupby=dialect.EXPAND_ONLY_GROUP_ALIAS_REF, - ) - - _convert_columns_to_dots(scope, resolver) - _qualify_columns( - scope, - resolver, - allow_partial_qualification=allow_partial_qualification, - ) - - if not schema.empty and expand_alias_refs: - _expand_alias_refs(scope, resolver, dialect) - - if is_select: - if expand_stars: - _expand_stars( - scope, - resolver, - using_column_tables, - pseudocolumns, - annotator, - ) - qualify_outputs(scope) - - _expand_group_by(scope, dialect) - - # DISTINCT ON and ORDER BY follow the same rules (tested in DuckDB, Postgres, ClickHouse) - # https://www.postgresql.org/docs/current/sql-select.html#SQL-DISTINCT - _expand_order_by_and_distinct_on(scope, resolver) - - if dialect.ANNOTATE_ALL_SCOPES: - annotator.annotate_scope(scope) - - return expression - - -def validate_qualify_columns(expression: E, sql: t.Optional[str] = None) -> E: - """Raise an `OptimizeError` if any columns aren't qualified""" - all_unqualified_columns = [] - for scope in traverse_scope(expression): - if isinstance(scope.expression, exp.Select): - unqualified_columns = scope.unqualified_columns - - if ( - scope.external_columns - and not scope.is_correlated_subquery - and not scope.pivots - ): - column = scope.external_columns[0] - for_table = f" for table: '{column.table}'" if column.table else "" - line = column.this.meta.get("line") - col = column.this.meta.get("col") - start = column.this.meta.get("start") - end = column.this.meta.get("end") - - error_msg = f"Column '{column.name}' could not be resolved{for_table}." - if line and col: - error_msg += f" Line: {line}, Col: {col}" - if sql and start is not None and end is not None: - formatted_sql = highlight_sql(sql, [(start, end)])[0] - error_msg += f"\n {formatted_sql}" - - raise OptimizeError(error_msg) - - if unqualified_columns and scope.pivots and scope.pivots[0].unpivot: - # New columns produced by the UNPIVOT can't be qualified, but there may be columns - # under the UNPIVOT's IN clause that can and should be qualified. We recompute - # this list here to ensure those in the former category will be excluded. - unpivot_columns = set(_unpivot_columns(scope.pivots[0])) - unqualified_columns = [ - c for c in unqualified_columns if c not in unpivot_columns - ] - - all_unqualified_columns.extend(unqualified_columns) - - if all_unqualified_columns: - first_column = all_unqualified_columns[0] - line = first_column.this.meta.get("line") - col = first_column.this.meta.get("col") - start = first_column.this.meta.get("start") - end = first_column.this.meta.get("end") - - error_msg = f"Ambiguous column '{first_column.name}'" - if line and col: - error_msg += f" (Line: {line}, Col: {col})" - if sql and start is not None and end is not None: - formatted_sql = highlight_sql(sql, [(start, end)])[0] - error_msg += f"\n {formatted_sql}" - - raise OptimizeError(error_msg) - - return expression - - -def _separate_pseudocolumns(scope: Scope, pseudocolumns: t.Set[str]) -> None: - if not pseudocolumns: - return - - has_pseudocolumns = False - scope_expression = scope.expression - - for column in scope.columns: - name = column.name.upper() - if name not in pseudocolumns: - continue - - if name != "LEVEL" or ( - isinstance(scope_expression, exp.Select) - and scope_expression.args.get("connect") - ): - column.replace(exp.Pseudocolumn(**column.args)) - has_pseudocolumns = True - - if has_pseudocolumns: - scope.clear_cache() - - -def _unpivot_columns(unpivot: exp.Pivot) -> t.Iterator[exp.Column]: - name_columns = [ - field.this - for field in unpivot.fields - if isinstance(field, exp.In) and isinstance(field.this, exp.Column) - ] - value_columns = (c for e in unpivot.expressions for c in e.find_all(exp.Column)) - - return itertools.chain(name_columns, value_columns) - - -def _pop_table_column_aliases(derived_tables: t.List[exp.CTE | exp.Subquery]) -> None: - """ - Remove table column aliases. - - For example, `col1` and `col2` will be dropped in SELECT ... FROM (SELECT ...) AS foo(col1, col2) - """ - for derived_table in derived_tables: - if ( - isinstance(derived_table.parent, exp.With) - and derived_table.parent.recursive - ): - continue - table_alias = derived_table.args.get("alias") - if table_alias: - table_alias.set("columns", None) - - -def _expand_using(scope: Scope, resolver: Resolver) -> t.Dict[str, t.Any]: - columns = {} - - def _update_source_columns(source_name: str) -> None: - for column_name in resolver.get_source_columns(source_name): - if column_name not in columns: - columns[column_name] = source_name - - joins = list(scope.find_all(exp.Join)) - names = {join.alias_or_name for join in joins} - ordered = [key for key in scope.selected_sources if key not in names] - - if names and not ordered: - raise OptimizeError(f"Joins {names} missing source table {scope.expression}") - - # Mapping of automatically joined column names to an ordered set of source names (dict). - column_tables: t.Dict[str, t.Dict[str, t.Any]] = {} - - for source_name in ordered: - _update_source_columns(source_name) - - for i, join in enumerate(joins): - source_table = ordered[-1] - if source_table: - _update_source_columns(source_table) - - join_table = join.alias_or_name - ordered.append(join_table) - - using = join.args.get("using") - if not using: - continue - - join_columns = resolver.get_source_columns(join_table) - conditions = [] - using_identifier_count = len(using) - is_semi_or_anti_join = join.is_semi_or_anti_join - - for identifier in using: - identifier = identifier.name - table = columns.get(identifier) - - if not table or identifier not in join_columns: - if (columns and "*" not in columns) and join_columns: - raise OptimizeError(f"Cannot automatically join: {identifier}") - - table = table or source_table - - if i == 0 or using_identifier_count == 1: - lhs: exp.Expression = exp.column(identifier, table=table) - else: - coalesce_columns = [ - exp.column(identifier, table=t) - for t in ordered[:-1] - if identifier in resolver.get_source_columns(t) - ] - if len(coalesce_columns) > 1: - lhs = exp.func("coalesce", *coalesce_columns) - else: - lhs = exp.column(identifier, table=table) - - conditions.append(lhs.eq(exp.column(identifier, table=join_table))) - - # Set all values in the dict to None, because we only care about the key ordering - tables = column_tables.setdefault(identifier, {}) - - # Do not update the dict if this was a SEMI/ANTI join in - # order to avoid generating COALESCE columns for this join pair - if not is_semi_or_anti_join: - if table not in tables: - tables[table] = None - if join_table not in tables: - tables[join_table] = None - - join.set("using", None) - join.set("on", exp.and_(*conditions, copy=False)) - - if column_tables: - for column in scope.columns: - if not column.table and column.name in column_tables: - tables = column_tables[column.name] - coalesce_args = [ - exp.column(column.name, table=table) for table in tables - ] - replacement: exp.Expression = exp.func("coalesce", *coalesce_args) - - if isinstance(column.parent, exp.Select): - # Ensure the USING column keeps its name if it's projected - replacement = alias(replacement, alias=column.name, copy=False) - elif isinstance(column.parent, exp.Struct): - # Ensure the USING column keeps its name if it's an anonymous STRUCT field - replacement = exp.PropertyEQ( - this=exp.to_identifier(column.name), expression=replacement - ) - - scope.replace(column, replacement) - - return column_tables - - -def _expand_alias_refs( - scope: Scope, - resolver: Resolver, - dialect: Dialect, - expand_only_groupby: bool = False, -) -> None: - """ - Expand references to aliases. - Example: - SELECT y.foo AS bar, bar * 2 AS baz FROM y - => SELECT y.foo AS bar, y.foo * 2 AS baz FROM y - """ - expression = scope.expression - - if not isinstance(expression, exp.Select) or dialect.DISABLES_ALIAS_REF_EXPANSION: - return - - alias_to_expression: t.Dict[str, t.Tuple[exp.Expression, int]] = {} - projections = {s.alias_or_name for s in expression.selects} - replaced = False - - def replace_columns( - node: t.Optional[exp.Expression], - resolve_table: bool = False, - literal_index: bool = False, - ) -> None: - nonlocal replaced - is_group_by = isinstance(node, exp.Group) - is_having = isinstance(node, exp.Having) - if not node or (expand_only_groupby and not is_group_by): - return - - for column in walk_in_scope(node, prune=lambda node: node.is_star): - if not isinstance(column, exp.Column): - continue - - # BigQuery's GROUP BY allows alias expansion only for standalone names, e.g: - # SELECT FUNC(col) AS col FROM t GROUP BY col --> Can be expanded - # SELECT FUNC(col) AS col FROM t GROUP BY FUNC(col) --> Shouldn't be expanded, will result to FUNC(FUNC(col)) - # This not required for the HAVING clause as it can evaluate expressions using both the alias & the table columns - if expand_only_groupby and is_group_by and column.parent is not node: - continue - - skip_replace = False - table = ( - resolver.get_table(column.name) - if resolve_table and not column.table - else None - ) - alias_expr, i = alias_to_expression.get(column.name, (None, 1)) - - if alias_expr: - skip_replace = bool( - alias_expr.find(exp.AggFunc) - and column.find_ancestor(exp.AggFunc) - and not isinstance( - column.find_ancestor(exp.Window, exp.Select), exp.Window - ) - ) - - # BigQuery's having clause gets confused if an alias matches a source. - # SELECT x.a, max(x.b) as x FROM x GROUP BY 1 HAVING x > 1; - # If "HAVING x" is expanded to "HAVING max(x.b)", BQ would blindly replace the "x" reference with the projection MAX(x.b) - # i.e HAVING MAX(MAX(x.b).b), resulting in the error: "Aggregations of aggregations are not allowed" - if is_having and dialect.PROJECTION_ALIASES_SHADOW_SOURCE_NAMES: - skip_replace = skip_replace or any( - node.parts[0].name in projections - for node in alias_expr.find_all(exp.Column) - ) - elif dialect.PROJECTION_ALIASES_SHADOW_SOURCE_NAMES and ( - is_group_by or is_having - ): - column_table = table.name if table else column.table - if column_table in projections: - # BigQuery's GROUP BY and HAVING clauses get confused if the column name - # matches a source name and a projection. For instance: - # SELECT id, ARRAY_AGG(col) AS custom_fields FROM custom_fields GROUP BY id HAVING id >= 1 - # We should not qualify "id" with "custom_fields" in either clause, since the aggregation shadows the actual table - # and we'd get the error: "Column custom_fields contains an aggregation function, which is not allowed in GROUP BY clause" - column.replace(exp.to_identifier(column.name)) - replaced = True - return - - if table and (not alias_expr or skip_replace): - column.set("table", table) - elif not column.table and alias_expr and not skip_replace: - if (isinstance(alias_expr, exp.Literal) or alias_expr.is_number) and ( - literal_index or resolve_table - ): - if literal_index: - column.replace(exp.Literal.number(i)) - replaced = True - else: - replaced = True - column = column.replace(exp.paren(alias_expr)) - simplified = simplify_parens(column, dialect) - if simplified is not column: - column.replace(simplified) - - for i, projection in enumerate(expression.selects): - replace_columns(projection) - if isinstance(projection, exp.Alias): - alias_to_expression[projection.alias] = (projection.this, i + 1) - - parent_scope = scope - on_right_sub_tree = False - while parent_scope and not parent_scope.is_cte: - if parent_scope.is_union: - on_right_sub_tree = ( - parent_scope.parent.expression.right is parent_scope.expression - ) - parent_scope = parent_scope.parent - - # We shouldn't expand aliases if they match the recursive CTE's columns - # and we are in the recursive part (right sub tree) of the CTE - if parent_scope and on_right_sub_tree: - cte = parent_scope.expression.parent - if cte.find_ancestor(exp.With).recursive: - for recursive_cte_column in cte.args["alias"].columns or cte.this.selects: - alias_to_expression.pop(recursive_cte_column.output_name, None) - - replace_columns(expression.args.get("where")) - replace_columns(expression.args.get("group"), literal_index=True) - replace_columns(expression.args.get("having"), resolve_table=True) - replace_columns(expression.args.get("qualify"), resolve_table=True) - - if dialect.SUPPORTS_ALIAS_REFS_IN_JOIN_CONDITIONS: - for join in expression.args.get("joins") or []: - replace_columns(join) - - if replaced: - scope.clear_cache() - - -def _expand_group_by(scope: Scope, dialect: Dialect) -> None: - expression = scope.expression - group = expression.args.get("group") - if not group: - return - - group.set( - "expressions", _expand_positional_references(scope, group.expressions, dialect) - ) - expression.set("group", group) - - -def _expand_order_by_and_distinct_on(scope: Scope, resolver: Resolver) -> None: - for modifier_key in ("order", "distinct"): - modifier = scope.expression.args.get(modifier_key) - if isinstance(modifier, exp.Distinct): - modifier = modifier.args.get("on") - - if not isinstance(modifier, exp.Expression): - continue - - modifier_expressions = modifier.expressions - if modifier_key == "order": - modifier_expressions = [ordered.this for ordered in modifier_expressions] - - for original, expanded in zip( - modifier_expressions, - _expand_positional_references( - scope, modifier_expressions, resolver.dialect, alias=True - ), - ): - for agg in original.find_all(exp.AggFunc): - for col in agg.find_all(exp.Column): - if not col.table: - col.set("table", resolver.get_table(col.name)) - - original.replace(expanded) - - if scope.expression.args.get("group"): - selects = { - s.this: exp.column(s.alias_or_name) for s in scope.expression.selects - } - - for expression in modifier_expressions: - expression.replace( - exp.to_identifier(_select_by_pos(scope, expression).alias) - if expression.is_int - else selects.get(expression, expression) - ) - - -def _expand_positional_references( - scope: Scope, - expressions: t.Iterable[exp.Expression], - dialect: Dialect, - alias: bool = False, -) -> t.List[exp.Expression]: - new_nodes: t.List[exp.Expression] = [] - ambiguous_projections = None - - for node in expressions: - if node.is_int: - select = _select_by_pos(scope, t.cast(exp.Literal, node)) - - if alias: - new_nodes.append(exp.column(select.args["alias"].copy())) - else: - select = select.this - - if dialect.PROJECTION_ALIASES_SHADOW_SOURCE_NAMES: - if ambiguous_projections is None: - # When a projection name is also a source name and it is referenced in the - # GROUP BY clause, BQ can't understand what the identifier corresponds to - ambiguous_projections = { - s.alias_or_name - for s in scope.expression.selects - if s.alias_or_name in scope.selected_sources - } - - ambiguous = any( - column.parts[0].name in ambiguous_projections - for column in select.find_all(exp.Column) - ) - else: - ambiguous = False - - if ( - isinstance(select, exp.CONSTANTS) - or select.is_number - or select.find(exp.Explode, exp.Unnest) - or ambiguous - ): - new_nodes.append(node) - else: - new_nodes.append(select.copy()) - else: - new_nodes.append(node) - - return new_nodes - - -def _select_by_pos(scope: Scope, node: exp.Literal) -> exp.Alias: - try: - return scope.expression.selects[int(node.this) - 1].assert_is(exp.Alias) - except IndexError: - raise OptimizeError(f"Unknown output column: {node.name}") - - -def _convert_columns_to_dots(scope: Scope, resolver: Resolver) -> None: - """ - Converts `Column` instances that represent STRUCT or JSON field lookup into chained `Dots`. - - These lookups may be parsed as columns (e.g. "col"."field"."field2"), but they need to be - normalized to `Dot(Dot(...(., field1), field2, ...))` to be qualified properly. - """ - converted = False - for column in itertools.chain(scope.columns, scope.stars): - if isinstance(column, exp.Dot): - continue - - column_table: t.Optional[str | exp.Identifier] = column.table - dot_parts = column.meta.pop("dot_parts", []) - if ( - column_table - and column_table not in scope.sources - and ( - not scope.parent - or column_table not in scope.parent.sources - or not scope.is_correlated_subquery - ) - ): - root, *parts = column.parts - - if root.name in scope.sources: - # The struct is already qualified, but we still need to change the AST - column_table = root - root, *parts = parts - was_qualified = True - else: - column_table = resolver.get_table(root.name) - was_qualified = False - - if column_table: - converted = True - new_column = exp.column(root, table=column_table) - - if dot_parts: - # Remove the actual column parts from the rest of dot parts - new_column.meta["dot_parts"] = dot_parts[ - 2 if was_qualified else 1 : - ] - - column.replace(exp.Dot.build([new_column, *parts])) - - if converted: - # We want to re-aggregate the converted columns, otherwise they'd be skipped in - # a `for column in scope.columns` iteration, even though they shouldn't be - scope.clear_cache() - - -def _qualify_columns( - scope: Scope, - resolver: Resolver, - allow_partial_qualification: bool, -) -> None: - """Disambiguate columns, ensuring each column specifies a source""" - for column in scope.columns: - column_table = column.table - column_name = column.name - - if column_table and column_table in scope.sources: - source_columns = resolver.get_source_columns(column_table) - if ( - not allow_partial_qualification - and source_columns - and column_name not in source_columns - and "*" not in source_columns - ): - raise OptimizeError(f"Unknown column: {column_name}") - - if not column_table: - if scope.pivots and not column.find_ancestor(exp.Pivot): - # If the column is under the Pivot expression, we need to qualify it - # using the name of the pivoted source instead of the pivot's alias - column.set("table", exp.to_identifier(scope.pivots[0].alias)) - continue - - # column_table can be a '' because bigquery unnest has no table alias - column_table = resolver.get_table(column) - - if column_table: - column.set("table", column_table) - elif ( - resolver.dialect.TABLES_REFERENCEABLE_AS_COLUMNS - and len(column.parts) == 1 - and column_name in scope.selected_sources - ): - # BigQuery and Postgres allow tables to be referenced as columns, treating them as structs/records - scope.replace(column, exp.TableColumn(this=column.this)) - - for pivot in scope.pivots: - for column in pivot.find_all(exp.Column): - if not column.table and column.name in resolver.all_columns: - column_table = resolver.get_table(column.name) - if column_table: - column.set("table", column_table) - - -def _expand_struct_stars_no_parens( - expression: exp.Dot, -) -> t.List[exp.Alias]: - """[BigQuery] Expand/Flatten foo.bar.* where bar is a struct column""" - - dot_column = expression.find(exp.Column) - if not isinstance(dot_column, exp.Column) or not dot_column.is_type( - exp.DataType.Type.STRUCT - ): - return [] - - # All nested struct values are ColumnDefs, so normalize the first exp.Column in one - dot_column = dot_column.copy() - starting_struct = exp.ColumnDef(this=dot_column.this, kind=dot_column.type) - - # First part is the table name and last part is the star so they can be dropped - dot_parts = expression.parts[1:-1] - - # If we're expanding a nested struct eg. t.c.f1.f2.* find the last struct (f2 in this case) - for part in dot_parts[1:]: - for field in t.cast(exp.DataType, starting_struct.kind).expressions: - # Unable to expand star unless all fields are named - if not isinstance(field.this, exp.Identifier): - return [] - - if field.name == part.name and field.kind.is_type(exp.DataType.Type.STRUCT): - starting_struct = field - break - else: - # There is no matching field in the struct - return [] - - taken_names = set() - new_selections = [] - - for field in t.cast(exp.DataType, starting_struct.kind).expressions: - name = field.name - - # Ambiguous or anonymous fields can't be expanded - if name in taken_names or not isinstance(field.this, exp.Identifier): - return [] - - taken_names.add(name) - - this = field.this.copy() - root, *parts = [part.copy() for part in itertools.chain(dot_parts, [this])] - new_column = exp.column( - t.cast(exp.Identifier, root), - table=dot_column.args.get("table"), - fields=t.cast(t.List[exp.Identifier], parts), - ) - new_selections.append(alias(new_column, this, copy=False)) - - return new_selections - - -def _expand_struct_stars_with_parens(expression: exp.Dot) -> t.List[exp.Alias]: - """[RisingWave] Expand/Flatten (.bar).*, where bar is a struct column""" - - # it is not ().* pattern, which means we can't expand - if not isinstance(expression.this, exp.Paren): - return [] - - # find column definition to get data-type - dot_column = expression.find(exp.Column) - if not isinstance(dot_column, exp.Column) or not dot_column.is_type( - exp.DataType.Type.STRUCT - ): - return [] - - parent = dot_column.parent - starting_struct = dot_column.type - - # walk up AST and down into struct definition in sync - while parent is not None: - if isinstance(parent, exp.Paren): - parent = parent.parent - continue - - # if parent is not a dot, then something is wrong - if not isinstance(parent, exp.Dot): - return [] - - # if the rhs of the dot is star we are done - rhs = parent.right - if isinstance(rhs, exp.Star): - break - - # if it is not identifier, then something is wrong - if not isinstance(rhs, exp.Identifier): - return [] - - # Check if current rhs identifier is in struct - matched = False - for struct_field_def in t.cast(exp.DataType, starting_struct).expressions: - if struct_field_def.name == rhs.name: - matched = True - starting_struct = struct_field_def.kind # update struct - break - - if not matched: - return [] - - parent = parent.parent - - # build new aliases to expand star - new_selections = [] - - # fetch the outermost parentheses for new aliaes - outer_paren = expression.this - - for struct_field_def in t.cast(exp.DataType, starting_struct).expressions: - new_identifier = struct_field_def.this.copy() - new_dot = exp.Dot.build([outer_paren.copy(), new_identifier]) - new_alias = alias(new_dot, new_identifier, copy=False) - new_selections.append(new_alias) - - return new_selections - - -def _expand_stars( - scope: Scope, - resolver: Resolver, - using_column_tables: t.Dict[str, t.Any], - pseudocolumns: t.Set[str], - annotator: TypeAnnotator, -) -> None: - """Expand stars to lists of column selections""" - - new_selections: t.List[exp.Expression] = [] - except_columns: t.Dict[int, t.Set[str]] = {} - replace_columns: t.Dict[int, t.Dict[str, exp.Alias]] = {} - rename_columns: t.Dict[int, t.Dict[str, str]] = {} - - coalesced_columns = set() - dialect = resolver.dialect - - pivot_output_columns = None - pivot_exclude_columns: t.Set[str] = set() - - pivot = t.cast(t.Optional[exp.Pivot], seq_get(scope.pivots, 0)) - if isinstance(pivot, exp.Pivot) and not pivot.alias_column_names: - if pivot.unpivot: - pivot_output_columns = [c.output_name for c in _unpivot_columns(pivot)] - - for field in pivot.fields: - if isinstance(field, exp.In): - pivot_exclude_columns.update( - c.output_name - for e in field.expressions - for c in e.find_all(exp.Column) - ) - - else: - pivot_exclude_columns = set( - c.output_name for c in pivot.find_all(exp.Column) - ) - - pivot_output_columns = [ - c.output_name for c in pivot.args.get("columns", []) - ] - if not pivot_output_columns: - pivot_output_columns = [c.alias_or_name for c in pivot.expressions] - - if dialect.SUPPORTS_STRUCT_STAR_EXPANSION and any( - isinstance(col, exp.Dot) for col in scope.stars - ): - # Found struct expansion, annotate scope ahead of time - annotator.annotate_scope(scope) - - for expression in scope.expression.selects: - tables = [] - if isinstance(expression, exp.Star): - tables.extend(scope.selected_sources) - _add_except_columns(expression, tables, except_columns) - _add_replace_columns(expression, tables, replace_columns) - _add_rename_columns(expression, tables, rename_columns) - elif expression.is_star: - if not isinstance(expression, exp.Dot): - tables.append(expression.table) - _add_except_columns(expression.this, tables, except_columns) - _add_replace_columns(expression.this, tables, replace_columns) - _add_rename_columns(expression.this, tables, rename_columns) - elif ( - dialect.SUPPORTS_STRUCT_STAR_EXPANSION - and not dialect.REQUIRES_PARENTHESIZED_STRUCT_ACCESS - ): - struct_fields = _expand_struct_stars_no_parens(expression) - if struct_fields: - new_selections.extend(struct_fields) - continue - elif dialect.REQUIRES_PARENTHESIZED_STRUCT_ACCESS: - struct_fields = _expand_struct_stars_with_parens(expression) - if struct_fields: - new_selections.extend(struct_fields) - continue - - if not tables: - new_selections.append(expression) - continue - - for table in tables: - if table not in scope.sources: - raise OptimizeError(f"Unknown table: {table}") - - columns = resolver.get_source_columns(table, only_visible=True) - columns = columns or scope.outer_columns - - if pseudocolumns and dialect.EXCLUDES_PSEUDOCOLUMNS_FROM_STAR: - columns = [ - name for name in columns if name.upper() not in pseudocolumns - ] - - if not columns or "*" in columns: - return - - table_id = id(table) - columns_to_exclude = except_columns.get(table_id) or set() - renamed_columns = rename_columns.get(table_id, {}) - replaced_columns = replace_columns.get(table_id, {}) - - if pivot: - if pivot_output_columns and pivot_exclude_columns: - pivot_columns = [ - c for c in columns if c not in pivot_exclude_columns - ] - pivot_columns.extend(pivot_output_columns) - else: - pivot_columns = pivot.alias_column_names - - if pivot_columns: - new_selections.extend( - alias(exp.column(name, table=pivot.alias), name, copy=False) - for name in pivot_columns - if name not in columns_to_exclude - ) - continue - - for name in columns: - if name in columns_to_exclude or name in coalesced_columns: - continue - if name in using_column_tables and table in using_column_tables[name]: - coalesced_columns.add(name) - tables = using_column_tables[name] - coalesce_args = [exp.column(name, table=table) for table in tables] - - new_selections.append( - alias( - exp.func("coalesce", *coalesce_args), alias=name, copy=False - ) - ) - else: - alias_ = renamed_columns.get(name, name) - selection_expr = replaced_columns.get(name) or exp.column( - name, table=table - ) - new_selections.append( - alias(selection_expr, alias_, copy=False) - if alias_ != name - else selection_expr - ) - - # Ensures we don't overwrite the initial selections with an empty list - if new_selections and isinstance(scope.expression, exp.Select): - scope.expression.set("expressions", new_selections) - - -def _add_except_columns( - expression: exp.Expression, tables, except_columns: t.Dict[int, t.Set[str]] -) -> None: - except_ = expression.args.get("except_") - - if not except_: - return - - columns = {e.name for e in except_} - - for table in tables: - except_columns[id(table)] = columns - - -def _add_rename_columns( - expression: exp.Expression, tables, rename_columns: t.Dict[int, t.Dict[str, str]] -) -> None: - rename = expression.args.get("rename") - - if not rename: - return - - columns = {e.this.name: e.alias for e in rename} - - for table in tables: - rename_columns[id(table)] = columns - - -def _add_replace_columns( - expression: exp.Expression, - tables, - replace_columns: t.Dict[int, t.Dict[str, exp.Alias]], -) -> None: - replace = expression.args.get("replace") - - if not replace: - return - - columns = {e.alias: e for e in replace} - - for table in tables: - replace_columns[id(table)] = columns - - -def qualify_outputs(scope_or_expression: Scope | exp.Expression) -> None: - """Ensure all output columns are aliased""" - if isinstance(scope_or_expression, exp.Expression): - scope = build_scope(scope_or_expression) - if not isinstance(scope, Scope): - return - else: - scope = scope_or_expression - - new_selections = [] - for i, (selection, aliased_column) in enumerate( - itertools.zip_longest(scope.expression.selects, scope.outer_columns) - ): - if selection is None or isinstance(selection, exp.QueryTransform): - break - - if isinstance(selection, exp.Subquery): - if not selection.output_name: - selection.set( - "alias", exp.TableAlias(this=exp.to_identifier(f"_col_{i}")) - ) - elif ( - not isinstance(selection, (exp.Alias, exp.Aliases)) - and not selection.is_star - ): - selection = alias( - selection, - alias=selection.output_name or f"_col_{i}", - copy=False, - ) - if aliased_column: - selection.set("alias", exp.to_identifier(aliased_column)) - - new_selections.append(selection) - - if new_selections and isinstance(scope.expression, exp.Select): - scope.expression.set("expressions", new_selections) - - -def quote_identifiers( - expression: E, dialect: DialectType = None, identify: bool = True -) -> E: - """Makes sure all identifiers that need to be quoted are quoted.""" - return expression.transform( - Dialect.get_or_raise(dialect).quote_identifier, identify=identify, copy=False - ) # type: ignore - - -def pushdown_cte_alias_columns(scope: Scope) -> None: - """ - Pushes down the CTE alias columns into the projection, - - This step is useful in Snowflake where the CTE alias columns can be referenced in the HAVING. - - Args: - scope: Scope to find ctes to pushdown aliases. - """ - for cte in scope.ctes: - if cte.alias_column_names and isinstance(cte.this, exp.Select): - new_expressions = [] - for _alias, projection in zip(cte.alias_column_names, cte.this.expressions): - if isinstance(projection, exp.Alias): - projection.set("alias", exp.to_identifier(_alias)) - else: - projection = alias(projection, alias=_alias) - new_expressions.append(projection) - cte.this.set("expressions", new_expressions) diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/qualify_tables.py b/third_party/bigframes_vendored/sqlglot/optimizer/qualify_tables.py deleted file mode 100644 index 42e99f668e4..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/qualify_tables.py +++ /dev/null @@ -1,227 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/qualify_tables.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType -from bigframes_vendored.sqlglot.helper import ensure_list, name_sequence, seq_get -from bigframes_vendored.sqlglot.optimizer.normalize_identifiers import ( - normalize_identifiers, -) -from bigframes_vendored.sqlglot.optimizer.scope import Scope, traverse_scope - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - - -def qualify_tables( - expression: E, - db: t.Optional[str | exp.Identifier] = None, - catalog: t.Optional[str | exp.Identifier] = None, - on_qualify: t.Optional[t.Callable[[exp.Table], None]] = None, - dialect: DialectType = None, - canonicalize_table_aliases: bool = False, -) -> E: - """ - Rewrite sqlglot AST to have fully qualified tables. Join constructs such as - (t1 JOIN t2) AS t will be expanded into (SELECT * FROM t1 AS t1, t2 AS t2) AS t. - - Examples: - >>> import sqlglot - >>> expression = sqlglot.parse_one("SELECT 1 FROM tbl") - >>> qualify_tables(expression, db="db").sql() - 'SELECT 1 FROM db.tbl AS tbl' - >>> - >>> expression = sqlglot.parse_one("SELECT 1 FROM (t1 JOIN t2) AS t") - >>> qualify_tables(expression).sql() - 'SELECT 1 FROM (SELECT * FROM t1 AS t1, t2 AS t2) AS t' - - Args: - expression: Expression to qualify - db: Database name - catalog: Catalog name - on_qualify: Callback after a table has been qualified. - dialect: The dialect to parse catalog and schema into. - canonicalize_table_aliases: Whether to use canonical aliases (_0, _1, ...) for all sources - instead of preserving table names. Defaults to False. - - Returns: - The qualified expression. - """ - dialect = Dialect.get_or_raise(dialect) - next_alias_name = name_sequence("_") - - if db := db or None: - db = exp.parse_identifier(db, dialect=dialect) - db.meta["is_table"] = True - db = normalize_identifiers(db, dialect=dialect) - if catalog := catalog or None: - catalog = exp.parse_identifier(catalog, dialect=dialect) - catalog.meta["is_table"] = True - catalog = normalize_identifiers(catalog, dialect=dialect) - - def _qualify(table: exp.Table) -> None: - if isinstance(table.this, exp.Identifier): - if db and not table.args.get("db"): - table.set("db", db.copy()) - if catalog and not table.args.get("catalog") and table.args.get("db"): - table.set("catalog", catalog.copy()) - - if (db or catalog) and not isinstance(expression, exp.Query): - with_ = expression.args.get("with_") or exp.With() - cte_names = {cte.alias_or_name for cte in with_.expressions} - - for node in expression.walk(prune=lambda n: isinstance(n, exp.Query)): - if isinstance(node, exp.Table) and node.name not in cte_names: - _qualify(node) - - def _set_alias( - expression: exp.Expression, - canonical_aliases: t.Dict[str, str], - target_alias: t.Optional[str] = None, - scope: t.Optional[Scope] = None, - normalize: bool = False, - columns: t.Optional[t.List[t.Union[str, exp.Identifier]]] = None, - ) -> None: - alias = expression.args.get("alias") or exp.TableAlias() - - if canonicalize_table_aliases: - new_alias_name = next_alias_name() - canonical_aliases[alias.name or target_alias or ""] = new_alias_name - elif not alias.name: - new_alias_name = target_alias or next_alias_name() - if normalize and target_alias: - new_alias_name = normalize_identifiers( - new_alias_name, dialect=dialect - ).name - else: - return - - alias.set("this", exp.to_identifier(new_alias_name)) - - if columns: - alias.set("columns", [exp.to_identifier(c) for c in columns]) - - expression.set("alias", alias) - - if scope: - scope.rename_source(None, new_alias_name) - - for scope in traverse_scope(expression): - local_columns = scope.local_columns - canonical_aliases: t.Dict[str, str] = {} - - for query in scope.subqueries: - subquery = query.parent - if isinstance(subquery, exp.Subquery): - subquery.unwrap().replace(subquery) - - for derived_table in scope.derived_tables: - unnested = derived_table.unnest() - if isinstance(unnested, exp.Table): - joins = unnested.args.get("joins") - unnested.set("joins", None) - derived_table.this.replace( - exp.select("*").from_(unnested.copy(), copy=False) - ) - derived_table.this.set("joins", joins) - - _set_alias(derived_table, canonical_aliases, scope=scope) - if pivot := seq_get(derived_table.args.get("pivots") or [], 0): - _set_alias(pivot, canonical_aliases) - - table_aliases = {} - - for name, source in scope.sources.items(): - if isinstance(source, exp.Table): - # When the name is empty, it means that we have a non-table source, e.g. a pivoted cte - is_real_table_source = bool(name) - - if pivot := seq_get(source.args.get("pivots") or [], 0): - name = source.name - - table_this = source.this - table_alias = source.args.get("alias") - function_columns: t.List[t.Union[str, exp.Identifier]] = [] - if isinstance(table_this, exp.Func): - if not table_alias: - function_columns = ensure_list( - dialect.DEFAULT_FUNCTIONS_COLUMN_NAMES.get(type(table_this)) - ) - elif columns := table_alias.columns: - function_columns = columns - elif type(table_this) in dialect.DEFAULT_FUNCTIONS_COLUMN_NAMES: - function_columns = ensure_list(source.alias_or_name) - source.set("alias", None) - name = None - - _set_alias( - source, - canonical_aliases, - target_alias=name or source.name or None, - normalize=True, - columns=function_columns, - ) - - source_fqn = ".".join(p.name for p in source.parts) - table_aliases[source_fqn] = source.args["alias"].this.copy() - - if pivot: - target_alias = source.alias if pivot.unpivot else None - _set_alias( - pivot, - canonical_aliases, - target_alias=target_alias, - normalize=True, - ) - - # This case corresponds to a pivoted CTE, we don't want to qualify that - if isinstance(scope.sources.get(source.alias_or_name), Scope): - continue - - if is_real_table_source: - _qualify(source) - - if on_qualify: - on_qualify(source) - elif isinstance(source, Scope) and source.is_udtf: - _set_alias(udtf := source.expression, canonical_aliases) - - table_alias = udtf.args["alias"] - - if isinstance(udtf, exp.Values) and not table_alias.columns: - column_aliases = [ - normalize_identifiers(i, dialect=dialect) - for i in dialect.generate_values_aliases(udtf) - ] - table_alias.set("columns", column_aliases) - - for table in scope.tables: - if not table.alias and isinstance(table.parent, (exp.From, exp.Join)): - _set_alias(table, canonical_aliases, target_alias=table.name) - - for column in local_columns: - table = column.table - - if column.db: - table_alias = table_aliases.get( - ".".join(p.name for p in column.parts[0:-1]) - ) - - if table_alias: - for p in exp.COLUMN_PARTS[1:]: - column.set(p, None) - - column.set("table", table_alias.copy()) - elif ( - canonical_aliases - and table - and (canonical_table := canonical_aliases.get(table, "")) - != column.table - ): - # Amend existing aliases, e.g. t.c -> _0.c if t is aliased to _0 - column.set("table", exp.to_identifier(canonical_table)) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/resolver.py b/third_party/bigframes_vendored/sqlglot/optimizer/resolver.py deleted file mode 100644 index 02b216ff0e6..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/resolver.py +++ /dev/null @@ -1,399 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/resolver.py - -from __future__ import annotations - -import itertools -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect -from bigframes_vendored.sqlglot.errors import OptimizeError -from bigframes_vendored.sqlglot.helper import SingleValuedMapping, seq_get -from bigframes_vendored.sqlglot.optimizer.scope import Scope - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.schema import Schema - - -class Resolver: - """ - Helper for resolving columns. - - This is a class so we can lazily load some things and easily share them across functions. - """ - - def __init__(self, scope: Scope, schema: Schema, infer_schema: bool = True): - self.scope = scope - self.schema = schema - self.dialect = schema.dialect or Dialect() - self._source_columns: t.Optional[t.Dict[str, t.Sequence[str]]] = None - self._unambiguous_columns: t.Optional[t.Mapping[str, str]] = None - self._all_columns: t.Optional[t.Set[str]] = None - self._infer_schema = infer_schema - self._get_source_columns_cache: t.Dict[t.Tuple[str, bool], t.Sequence[str]] = {} - - def get_table(self, column: str | exp.Column) -> t.Optional[exp.Identifier]: - """ - Get the table for a column name. - - Args: - column: The column expression (or column name) to find the table for. - Returns: - The table name if it can be found/inferred. - """ - column_name = column if isinstance(column, str) else column.name - - table_name = self._get_table_name_from_sources(column_name) - - if not table_name and isinstance(column, exp.Column): - # Fall-back case: If we couldn't find the `table_name` from ALL of the sources, - # attempt to disambiguate the column based on other characteristics e.g if this column is in a join condition, - # we may be able to disambiguate based on the source order. - if join_context := self._get_column_join_context(column): - # In this case, the return value will be the join that _may_ be able to disambiguate the column - # and we can use the source columns available at that join to get the table name - # catch OptimizeError if column is still ambiguous and try to resolve with schema inference below - try: - table_name = self._get_table_name_from_sources( - column_name, self._get_available_source_columns(join_context) - ) - except OptimizeError: - pass - - if not table_name and self._infer_schema: - sources_without_schema = tuple( - source - for source, columns in self._get_all_source_columns().items() - if not columns or "*" in columns - ) - if len(sources_without_schema) == 1: - table_name = sources_without_schema[0] - - if table_name not in self.scope.selected_sources: - return exp.to_identifier(table_name) - - node, _ = self.scope.selected_sources.get(table_name) - - if isinstance(node, exp.Query): - while node and node.alias != table_name: - node = node.parent - - node_alias = node.args.get("alias") - if node_alias: - return exp.to_identifier(node_alias.this) - - return exp.to_identifier(table_name) - - @property - def all_columns(self) -> t.Set[str]: - """All available columns of all sources in this scope""" - if self._all_columns is None: - self._all_columns = { - column - for columns in self._get_all_source_columns().values() - for column in columns - } - return self._all_columns - - def get_source_columns_from_set_op(self, expression: exp.Expression) -> t.List[str]: - if isinstance(expression, exp.Select): - return expression.named_selects - if isinstance(expression, exp.Subquery) and isinstance( - expression.this, exp.SetOperation - ): - # Different types of SET modifiers can be chained together if they're explicitly grouped by nesting - return self.get_source_columns_from_set_op(expression.this) - if not isinstance(expression, exp.SetOperation): - raise OptimizeError(f"Unknown set operation: {expression}") - - set_op = expression - - # BigQuery specific set operations modifiers, e.g INNER UNION ALL BY NAME - on_column_list = set_op.args.get("on") - - if on_column_list: - # The resulting columns are the columns in the ON clause: - # {INNER | LEFT | FULL} UNION ALL BY NAME ON (col1, col2, ...) - columns = [col.name for col in on_column_list] - elif set_op.side or set_op.kind: - side = set_op.side - kind = set_op.kind - - # Visit the children UNIONs (if any) in a post-order traversal - left = self.get_source_columns_from_set_op(set_op.left) - right = self.get_source_columns_from_set_op(set_op.right) - - # We use dict.fromkeys to deduplicate keys and maintain insertion order - if side == "LEFT": - columns = left - elif side == "FULL": - columns = list(dict.fromkeys(left + right)) - elif kind == "INNER": - columns = list(dict.fromkeys(left).keys() & dict.fromkeys(right).keys()) - else: - columns = set_op.named_selects - - return columns - - def get_source_columns( - self, name: str, only_visible: bool = False - ) -> t.Sequence[str]: - """Resolve the source columns for a given source `name`.""" - cache_key = (name, only_visible) - if cache_key not in self._get_source_columns_cache: - if name not in self.scope.sources: - raise OptimizeError(f"Unknown table: {name}") - - source = self.scope.sources[name] - - if isinstance(source, exp.Table): - columns = self.schema.column_names(source, only_visible) - elif isinstance(source, Scope) and isinstance( - source.expression, (exp.Values, exp.Unnest) - ): - columns = source.expression.named_selects - - # in bigquery, unnest structs are automatically scoped as tables, so you can - # directly select a struct field in a query. - # this handles the case where the unnest is statically defined. - if self.dialect.UNNEST_COLUMN_ONLY and isinstance( - source.expression, exp.Unnest - ): - unnest = source.expression - - # if type is not annotated yet, try to get it from the schema - if not unnest.type or unnest.type.is_type( - exp.DataType.Type.UNKNOWN - ): - unnest_expr = seq_get(unnest.expressions, 0) - if isinstance(unnest_expr, exp.Column) and self.scope.parent: - col_type = self._get_unnest_column_type(unnest_expr) - # extract element type if it's an ARRAY - if col_type and col_type.is_type(exp.DataType.Type.ARRAY): - element_types = col_type.expressions - if element_types: - unnest.type = element_types[0].copy() - else: - if col_type: - unnest.type = col_type.copy() - # check if the result type is a STRUCT - extract struct field names - if unnest.is_type(exp.DataType.Type.STRUCT): - for k in unnest.type.expressions: # type: ignore - columns.append(k.name) - elif isinstance(source, Scope) and isinstance( - source.expression, exp.SetOperation - ): - columns = self.get_source_columns_from_set_op(source.expression) - - else: - select = seq_get(source.expression.selects, 0) - - if isinstance(select, exp.QueryTransform): - # https://spark.apache.org/docs/3.5.1/sql-ref-syntax-qry-select-transform.html - schema = select.args.get("schema") - columns = ( - [c.name for c in schema.expressions] - if schema - else ["key", "value"] - ) - else: - columns = source.expression.named_selects - - node, _ = self.scope.selected_sources.get(name) or (None, None) - if isinstance(node, Scope): - column_aliases = node.expression.alias_column_names - elif isinstance(node, exp.Expression): - column_aliases = node.alias_column_names - else: - column_aliases = [] - - if column_aliases: - # If the source's columns are aliased, their aliases shadow the corresponding column names. - # This can be expensive if there are lots of columns, so only do this if column_aliases exist. - columns = [ - alias or name - for (name, alias) in itertools.zip_longest(columns, column_aliases) - ] - - self._get_source_columns_cache[cache_key] = columns - - return self._get_source_columns_cache[cache_key] - - def _get_all_source_columns(self) -> t.Dict[str, t.Sequence[str]]: - if self._source_columns is None: - self._source_columns = { - source_name: self.get_source_columns(source_name) - for source_name, source in itertools.chain( - self.scope.selected_sources.items(), - self.scope.lateral_sources.items(), - ) - } - return self._source_columns - - def _get_table_name_from_sources( - self, - column_name: str, - source_columns: t.Optional[t.Dict[str, t.Sequence[str]]] = None, - ) -> t.Optional[str]: - if not source_columns: - # If not supplied, get all sources to calculate unambiguous columns - if self._unambiguous_columns is None: - self._unambiguous_columns = self._get_unambiguous_columns( - self._get_all_source_columns() - ) - - unambiguous_columns = self._unambiguous_columns - else: - unambiguous_columns = self._get_unambiguous_columns(source_columns) - - return unambiguous_columns.get(column_name) - - def _get_column_join_context(self, column: exp.Column) -> t.Optional[exp.Join]: - """ - Check if a column participating in a join can be qualified based on the source order. - """ - args = self.scope.expression.args - joins = args.get("joins") - - if not joins or args.get("laterals") or args.get("pivots"): - # Feature gap: We currently don't try to disambiguate columns if other sources - # (e.g laterals, pivots) exist alongside joins - return None - - join_ancestor = column.find_ancestor(exp.Join, exp.Select) - - if ( - isinstance(join_ancestor, exp.Join) - and join_ancestor.alias_or_name in self.scope.selected_sources - ): - # Ensure that the found ancestor is a join that contains an actual source, - # e.g in Clickhouse `b` is an array expression in `a ARRAY JOIN b` - return join_ancestor - - return None - - def _get_available_source_columns( - self, join_ancestor: exp.Join - ) -> t.Dict[str, t.Sequence[str]]: - """ - Get the source columns that are available at the point where a column is referenced. - - For columns in JOIN conditions, this only includes tables that have been joined - up to that point. Example: - - ``` - SELECT * FROM t_1 INNER JOIN ... INNER JOIN t_n ON t_1.a = c INNER JOIN t_n+1 ON ... - ``` ^ - | - +----------------------------------+ - | - ⌄ - The unqualified column `c` is not ambiguous if no other sources up until that - join i.e t_1, ..., t_n, contain a column named `c`. - - """ - args = self.scope.expression.args - - # Collect tables in order: FROM clause tables + joined tables up to current join - from_name = args["from_"].alias_or_name - available_sources = {from_name: self.get_source_columns(from_name)} - - for join in args["joins"][: t.cast(int, join_ancestor.index) + 1]: - available_sources[join.alias_or_name] = self.get_source_columns( - join.alias_or_name - ) - - return available_sources - - def _get_unambiguous_columns( - self, source_columns: t.Dict[str, t.Sequence[str]] - ) -> t.Mapping[str, str]: - """ - Find all the unambiguous columns in sources. - - Args: - source_columns: Mapping of names to source columns. - - Returns: - Mapping of column name to source name. - """ - if not source_columns: - return {} - - source_columns_pairs = list(source_columns.items()) - - first_table, first_columns = source_columns_pairs[0] - - if len(source_columns_pairs) == 1: - # Performance optimization - avoid copying first_columns if there is only one table. - return SingleValuedMapping(first_columns, first_table) - - unambiguous_columns = {col: first_table for col in first_columns} - all_columns = set(unambiguous_columns) - - for table, columns in source_columns_pairs[1:]: - unique = set(columns) - ambiguous = all_columns.intersection(unique) - all_columns.update(columns) - - for column in ambiguous: - unambiguous_columns.pop(column, None) - for column in unique.difference(ambiguous): - unambiguous_columns[column] = table - - return unambiguous_columns - - def _get_unnest_column_type(self, column: exp.Column) -> t.Optional[exp.DataType]: - """ - Get the type of a column being unnested, tracing through CTEs/subqueries to find the base table. - - Args: - column: The column expression being unnested. - - Returns: - The DataType of the column, or None if not found. - """ - scope = self.scope.parent - - # if column is qualified, use that table, otherwise disambiguate using the resolver - if column.table: - table_name = column.table - else: - # use the parent scope's resolver to disambiguate the column - parent_resolver = Resolver(scope, self.schema, self._infer_schema) - table_identifier = parent_resolver.get_table(column) - if not table_identifier: - return None - table_name = table_identifier.name - - source = scope.sources.get(table_name) - return self._get_column_type_from_scope(source, column) if source else None - - def _get_column_type_from_scope( - self, source: t.Union[Scope, exp.Table], column: exp.Column - ) -> t.Optional[exp.DataType]: - """ - Get a column's type by tracing through scopes/tables to find the base table. - - Args: - source: The source to search - can be a Scope (to iterate its sources) or a Table. - column: The column to find the type for. - - Returns: - The DataType of the column, or None if not found. - """ - if isinstance(source, exp.Table): - # base table - get the column type from schema - col_type: t.Optional[exp.DataType] = self.schema.get_column_type( - source, column - ) - if col_type and not col_type.is_type(exp.DataType.Type.UNKNOWN): - return col_type - elif isinstance(source, Scope): - # iterate over all sources in the scope - for source_name, nested_source in source.sources.items(): - col_type = self._get_column_type_from_scope(nested_source, column) - if col_type and not col_type.is_type(exp.DataType.Type.UNKNOWN): - return col_type - - return None diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/scope.py b/third_party/bigframes_vendored/sqlglot/optimizer/scope.py deleted file mode 100644 index 4256abc6173..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/scope.py +++ /dev/null @@ -1,983 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/scope.py - -from __future__ import annotations - -import itertools -import logging -import typing as t -from collections import defaultdict -from enum import Enum, auto - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.errors import OptimizeError -from bigframes_vendored.sqlglot.helper import ensure_collection, find_new_name, seq_get - -logger = logging.getLogger("sqlglot") - -TRAVERSABLES = (exp.Query, exp.DDL, exp.DML) - - -class ScopeType(Enum): - ROOT = auto() - SUBQUERY = auto() - DERIVED_TABLE = auto() - CTE = auto() - UNION = auto() - UDTF = auto() - - -class Scope: - """ - Selection scope. - - Attributes: - expression (exp.Select|exp.SetOperation): Root expression of this scope - sources (dict[str, exp.Table|Scope]): Mapping of source name to either - a Table expression or another Scope instance. For example: - SELECT * FROM x {"x": Table(this="x")} - SELECT * FROM x AS y {"y": Table(this="x")} - SELECT * FROM (SELECT ...) AS y {"y": Scope(...)} - lateral_sources (dict[str, exp.Table|Scope]): Sources from laterals - For example: - SELECT c FROM x LATERAL VIEW EXPLODE (a) AS c; - The LATERAL VIEW EXPLODE gets x as a source. - cte_sources (dict[str, Scope]): Sources from CTES - outer_columns (list[str]): If this is a derived table or CTE, and the outer query - defines a column list for the alias of this scope, this is that list of columns. - For example: - SELECT * FROM (SELECT ...) AS y(col1, col2) - The inner query would have `["col1", "col2"]` for its `outer_columns` - parent (Scope): Parent scope - scope_type (ScopeType): Type of this scope, relative to it's parent - subquery_scopes (list[Scope]): List of all child scopes for subqueries - cte_scopes (list[Scope]): List of all child scopes for CTEs - derived_table_scopes (list[Scope]): List of all child scopes for derived_tables - udtf_scopes (list[Scope]): List of all child scopes for user defined tabular functions - table_scopes (list[Scope]): derived_table_scopes + udtf_scopes, in the order that they're defined - union_scopes (list[Scope, Scope]): If this Scope is for a Union expression, this will be - a list of the left and right child scopes. - """ - - def __init__( - self, - expression, - sources=None, - outer_columns=None, - parent=None, - scope_type=ScopeType.ROOT, - lateral_sources=None, - cte_sources=None, - can_be_correlated=None, - ): - self.expression = expression - self.sources = sources or {} - self.lateral_sources = lateral_sources or {} - self.cte_sources = cte_sources or {} - self.sources.update(self.lateral_sources) - self.sources.update(self.cte_sources) - self.outer_columns = outer_columns or [] - self.parent = parent - self.scope_type = scope_type - self.subquery_scopes = [] - self.derived_table_scopes = [] - self.table_scopes = [] - self.cte_scopes = [] - self.union_scopes = [] - self.udtf_scopes = [] - self.can_be_correlated = can_be_correlated - self.clear_cache() - - def clear_cache(self): - self._collected = False - self._raw_columns = None - self._table_columns = None - self._stars = None - self._derived_tables = None - self._udtfs = None - self._tables = None - self._ctes = None - self._subqueries = None - self._selected_sources = None - self._columns = None - self._external_columns = None - self._local_columns = None - self._join_hints = None - self._pivots = None - self._references = None - self._semi_anti_join_tables = None - - def branch( - self, - expression, - scope_type, - sources=None, - cte_sources=None, - lateral_sources=None, - **kwargs, - ): - """Branch from the current scope to a new, inner scope""" - return Scope( - expression=expression.unnest(), - sources=sources.copy() if sources else None, - parent=self, - scope_type=scope_type, - cte_sources={**self.cte_sources, **(cte_sources or {})}, - lateral_sources=lateral_sources.copy() if lateral_sources else None, - can_be_correlated=self.can_be_correlated - or scope_type in (ScopeType.SUBQUERY, ScopeType.UDTF), - **kwargs, - ) - - def _collect(self): - self._tables = [] - self._ctes = [] - self._subqueries = [] - self._derived_tables = [] - self._udtfs = [] - self._raw_columns = [] - self._table_columns = [] - self._stars = [] - self._join_hints = [] - self._semi_anti_join_tables = set() - - for node in self.walk(bfs=False): - if node is self.expression: - continue - - if isinstance(node, exp.Dot) and node.is_star: - self._stars.append(node) - elif isinstance(node, exp.Column) and not isinstance( - node, exp.Pseudocolumn - ): - if isinstance(node.this, exp.Star): - self._stars.append(node) - else: - self._raw_columns.append(node) - elif isinstance(node, exp.Table) and not isinstance( - node.parent, exp.JoinHint - ): - parent = node.parent - if isinstance(parent, exp.Join) and parent.is_semi_or_anti_join: - self._semi_anti_join_tables.add(node.alias_or_name) - - self._tables.append(node) - elif isinstance(node, exp.JoinHint): - self._join_hints.append(node) - elif isinstance(node, exp.UDTF): - self._udtfs.append(node) - elif isinstance(node, exp.CTE): - self._ctes.append(node) - elif _is_derived_table(node) and _is_from_or_join(node): - self._derived_tables.append(node) - elif isinstance(node, exp.UNWRAPPED_QUERIES) and not _is_from_or_join(node): - self._subqueries.append(node) - elif isinstance(node, exp.TableColumn): - self._table_columns.append(node) - - self._collected = True - - def _ensure_collected(self): - if not self._collected: - self._collect() - - def walk(self, bfs=True, prune=None): - return walk_in_scope(self.expression, bfs=bfs, prune=None) - - def find(self, *expression_types, bfs=True): - return find_in_scope(self.expression, expression_types, bfs=bfs) - - def find_all(self, *expression_types, bfs=True): - return find_all_in_scope(self.expression, expression_types, bfs=bfs) - - def replace(self, old, new): - """ - Replace `old` with `new`. - - This can be used instead of `exp.Expression.replace` to ensure the `Scope` is kept up-to-date. - - Args: - old (exp.Expression): old node - new (exp.Expression): new node - """ - old.replace(new) - self.clear_cache() - - @property - def tables(self): - """ - List of tables in this scope. - - Returns: - list[exp.Table]: tables - """ - self._ensure_collected() - return self._tables - - @property - def ctes(self): - """ - List of CTEs in this scope. - - Returns: - list[exp.CTE]: ctes - """ - self._ensure_collected() - return self._ctes - - @property - def derived_tables(self): - """ - List of derived tables in this scope. - - For example: - SELECT * FROM (SELECT ...) <- that's a derived table - - Returns: - list[exp.Subquery]: derived tables - """ - self._ensure_collected() - return self._derived_tables - - @property - def udtfs(self): - """ - List of "User Defined Tabular Functions" in this scope. - - Returns: - list[exp.UDTF]: UDTFs - """ - self._ensure_collected() - return self._udtfs - - @property - def subqueries(self): - """ - List of subqueries in this scope. - - For example: - SELECT * FROM x WHERE a IN (SELECT ...) <- that's a subquery - - Returns: - list[exp.Select | exp.SetOperation]: subqueries - """ - self._ensure_collected() - return self._subqueries - - @property - def stars(self) -> t.List[exp.Column | exp.Dot]: - """ - List of star expressions (columns or dots) in this scope. - """ - self._ensure_collected() - return self._stars - - @property - def columns(self): - """ - List of columns in this scope. - - Returns: - list[exp.Column]: Column instances in this scope, plus any - Columns that reference this scope from correlated subqueries. - """ - if self._columns is None: - self._ensure_collected() - columns = self._raw_columns - - external_columns = [ - column - for scope in itertools.chain( - self.subquery_scopes, - self.udtf_scopes, - (dts for dts in self.derived_table_scopes if dts.can_be_correlated), - ) - for column in scope.external_columns - ] - - named_selects = set(self.expression.named_selects) - - self._columns = [] - for column in columns + external_columns: - ancestor = column.find_ancestor( - exp.Select, - exp.Qualify, - exp.Order, - exp.Having, - exp.Hint, - exp.Table, - exp.Star, - exp.Distinct, - ) - if ( - not ancestor - or column.table - or isinstance(ancestor, exp.Select) - or ( - isinstance(ancestor, exp.Table) - and not isinstance(ancestor.this, exp.Func) - ) - or ( - isinstance(ancestor, (exp.Order, exp.Distinct)) - and ( - isinstance(ancestor.parent, (exp.Window, exp.WithinGroup)) - or not isinstance(ancestor.parent, exp.Select) - or column.name not in named_selects - ) - ) - or ( - isinstance(ancestor, exp.Star) - and not column.arg_key == "except_" - ) - ): - self._columns.append(column) - - return self._columns - - @property - def table_columns(self): - if self._table_columns is None: - self._ensure_collected() - - return self._table_columns - - @property - def selected_sources(self): - """ - Mapping of nodes and sources that are actually selected from in this scope. - - That is, all tables in a schema are selectable at any point. But a - table only becomes a selected source if it's included in a FROM or JOIN clause. - - Returns: - dict[str, (exp.Table|exp.Select, exp.Table|Scope)]: selected sources and nodes - """ - if self._selected_sources is None: - result = {} - - for name, node in self.references: - if name in self._semi_anti_join_tables: - # The RHS table of SEMI/ANTI joins shouldn't be collected as a - # selected source - continue - - if name in result: - raise OptimizeError(f"Alias already used: {name}") - if name in self.sources: - result[name] = (node, self.sources[name]) - - self._selected_sources = result - return self._selected_sources - - @property - def references(self) -> t.List[t.Tuple[str, exp.Expression]]: - if self._references is None: - self._references = [] - - for table in self.tables: - self._references.append((table.alias_or_name, table)) - for expression in itertools.chain(self.derived_tables, self.udtfs): - self._references.append( - ( - _get_source_alias(expression), - expression - if expression.args.get("pivots") - else expression.unnest(), - ) - ) - - return self._references - - @property - def external_columns(self): - """ - Columns that appear to reference sources in outer scopes. - - Returns: - list[exp.Column]: Column instances that don't reference sources in the current scope. - """ - if self._external_columns is None: - if isinstance(self.expression, exp.SetOperation): - left, right = self.union_scopes - self._external_columns = left.external_columns + right.external_columns - else: - self._external_columns = [ - c - for c in self.columns - if c.table not in self.sources - and c.table not in self.semi_or_anti_join_tables - ] - - return self._external_columns - - @property - def local_columns(self): - """ - Columns in this scope that are not external. - - Returns: - list[exp.Column]: Column instances that reference sources in the current scope. - """ - if self._local_columns is None: - external_columns = set(self.external_columns) - self._local_columns = [c for c in self.columns if c not in external_columns] - - return self._local_columns - - @property - def unqualified_columns(self): - """ - Unqualified columns in the current scope. - - Returns: - list[exp.Column]: Unqualified columns - """ - return [c for c in self.columns if not c.table] - - @property - def join_hints(self): - """ - Hints that exist in the scope that reference tables - - Returns: - list[exp.JoinHint]: Join hints that are referenced within the scope - """ - if self._join_hints is None: - return [] - return self._join_hints - - @property - def pivots(self): - if not self._pivots: - self._pivots = [ - pivot - for _, node in self.references - for pivot in node.args.get("pivots") or [] - ] - - return self._pivots - - @property - def semi_or_anti_join_tables(self): - return self._semi_anti_join_tables or set() - - def source_columns(self, source_name): - """ - Get all columns in the current scope for a particular source. - - Args: - source_name (str): Name of the source - Returns: - list[exp.Column]: Column instances that reference `source_name` - """ - return [column for column in self.columns if column.table == source_name] - - @property - def is_subquery(self): - """Determine if this scope is a subquery""" - return self.scope_type == ScopeType.SUBQUERY - - @property - def is_derived_table(self): - """Determine if this scope is a derived table""" - return self.scope_type == ScopeType.DERIVED_TABLE - - @property - def is_union(self): - """Determine if this scope is a union""" - return self.scope_type == ScopeType.UNION - - @property - def is_cte(self): - """Determine if this scope is a common table expression""" - return self.scope_type == ScopeType.CTE - - @property - def is_root(self): - """Determine if this is the root scope""" - return self.scope_type == ScopeType.ROOT - - @property - def is_udtf(self): - """Determine if this scope is a UDTF (User Defined Table Function)""" - return self.scope_type == ScopeType.UDTF - - @property - def is_correlated_subquery(self): - """Determine if this scope is a correlated subquery""" - return bool(self.can_be_correlated and self.external_columns) - - def rename_source(self, old_name, new_name): - """Rename a source in this scope""" - old_name = old_name or "" - if old_name in self.sources: - self.sources[new_name] = self.sources.pop(old_name) - - def add_source(self, name, source): - """Add a source to this scope""" - self.sources[name] = source - self.clear_cache() - - def remove_source(self, name): - """Remove a source from this scope""" - self.sources.pop(name, None) - self.clear_cache() - - def __repr__(self): - return f"Scope<{self.expression.sql()}>" - - def traverse(self): - """ - Traverse the scope tree from this node. - - Yields: - Scope: scope instances in depth-first-search post-order - """ - stack = [self] - result = [] - while stack: - scope = stack.pop() - result.append(scope) - stack.extend( - itertools.chain( - scope.cte_scopes, - scope.union_scopes, - scope.table_scopes, - scope.subquery_scopes, - ) - ) - - yield from reversed(result) - - def ref_count(self): - """ - Count the number of times each scope in this tree is referenced. - - Returns: - dict[int, int]: Mapping of Scope instance ID to reference count - """ - scope_ref_count = defaultdict(lambda: 0) - - for scope in self.traverse(): - for _, source in scope.selected_sources.values(): - scope_ref_count[id(source)] += 1 - - for name in scope._semi_anti_join_tables: - # semi/anti join sources are not actually selected but we still need to - # increment their ref count to avoid them being optimized away - if name in scope.sources: - scope_ref_count[id(scope.sources[name])] += 1 - - return scope_ref_count - - -def traverse_scope(expression: exp.Expression) -> t.List[Scope]: - """ - Traverse an expression by its "scopes". - - "Scope" represents the current context of a Select statement. - - This is helpful for optimizing queries, where we need more information than - the expression tree itself. For example, we might care about the source - names within a subquery. Returns a list because a generator could result in - incomplete properties which is confusing. - - Examples: - >>> import sqlglot - >>> expression = sqlglot.parse_one("SELECT a FROM (SELECT a FROM x) AS y") - >>> scopes = traverse_scope(expression) - >>> scopes[0].expression.sql(), list(scopes[0].sources) - ('SELECT a FROM x', ['x']) - >>> scopes[1].expression.sql(), list(scopes[1].sources) - ('SELECT a FROM (SELECT a FROM x) AS y', ['y']) - - Args: - expression: Expression to traverse - - Returns: - A list of the created scope instances - """ - if isinstance(expression, TRAVERSABLES): - return list(_traverse_scope(Scope(expression))) - return [] - - -def build_scope(expression: exp.Expression) -> t.Optional[Scope]: - """ - Build a scope tree. - - Args: - expression: Expression to build the scope tree for. - - Returns: - The root scope - """ - return seq_get(traverse_scope(expression), -1) - - -def _traverse_scope(scope): - expression = scope.expression - - if isinstance(expression, exp.Select): - yield from _traverse_select(scope) - elif isinstance(expression, exp.SetOperation): - yield from _traverse_ctes(scope) - yield from _traverse_union(scope) - return - elif isinstance(expression, exp.Subquery): - if scope.is_root: - yield from _traverse_select(scope) - else: - yield from _traverse_subqueries(scope) - elif isinstance(expression, exp.Table): - yield from _traverse_tables(scope) - elif isinstance(expression, exp.UDTF): - yield from _traverse_udtfs(scope) - elif isinstance(expression, exp.DDL): - if isinstance(expression.expression, exp.Query): - yield from _traverse_ctes(scope) - yield from _traverse_scope( - Scope(expression.expression, cte_sources=scope.cte_sources) - ) - return - elif isinstance(expression, exp.DML): - yield from _traverse_ctes(scope) - for query in find_all_in_scope(expression, exp.Query): - # This check ensures we don't yield the CTE/nested queries twice - if not isinstance(query.parent, (exp.CTE, exp.Subquery)): - yield from _traverse_scope(Scope(query, cte_sources=scope.cte_sources)) - return - else: - logger.warning( - "Cannot traverse scope %s with type '%s'", expression, type(expression) - ) - return - - yield scope - - -def _traverse_select(scope): - yield from _traverse_ctes(scope) - yield from _traverse_tables(scope) - yield from _traverse_subqueries(scope) - - -def _traverse_union(scope): - prev_scope = None - union_scope_stack = [scope] - expression_stack = [scope.expression.right, scope.expression.left] - - while expression_stack: - expression = expression_stack.pop() - union_scope = union_scope_stack[-1] - - new_scope = union_scope.branch( - expression, - outer_columns=union_scope.outer_columns, - scope_type=ScopeType.UNION, - ) - - if isinstance(expression, exp.SetOperation): - yield from _traverse_ctes(new_scope) - - union_scope_stack.append(new_scope) - expression_stack.extend([expression.right, expression.left]) - continue - - for scope in _traverse_scope(new_scope): - yield scope - - if prev_scope: - union_scope_stack.pop() - union_scope.union_scopes = [prev_scope, scope] - prev_scope = union_scope - - yield union_scope - else: - prev_scope = scope - - -def _traverse_ctes(scope): - sources = {} - - for cte in scope.ctes: - cte_name = cte.alias - - # if the scope is a recursive cte, it must be in the form of base_case UNION recursive. - # thus the recursive scope is the first section of the union. - with_ = scope.expression.args.get("with_") - if with_ and with_.recursive: - union = cte.this - - if isinstance(union, exp.SetOperation): - sources[cte_name] = scope.branch(union.this, scope_type=ScopeType.CTE) - - child_scope = None - - for child_scope in _traverse_scope( - scope.branch( - cte.this, - cte_sources=sources, - outer_columns=cte.alias_column_names, - scope_type=ScopeType.CTE, - ) - ): - yield child_scope - - # append the final child_scope yielded - if child_scope: - sources[cte_name] = child_scope - scope.cte_scopes.append(child_scope) - - scope.sources.update(sources) - scope.cte_sources.update(sources) - - -def _is_derived_table(expression: exp.Subquery) -> bool: - """ - We represent (tbl1 JOIN tbl2) as a Subquery, but it's not really a "derived table", - as it doesn't introduce a new scope. If an alias is present, it shadows all names - under the Subquery, so that's one exception to this rule. - """ - return isinstance(expression, exp.Subquery) and bool( - expression.alias or isinstance(expression.this, exp.UNWRAPPED_QUERIES) - ) - - -def _is_from_or_join(expression: exp.Expression) -> bool: - """ - Determine if `expression` is the FROM or JOIN clause of a SELECT statement. - """ - parent = expression.parent - - # Subqueries can be arbitrarily nested - while isinstance(parent, exp.Subquery): - parent = parent.parent - - return isinstance(parent, (exp.From, exp.Join)) - - -def _traverse_tables(scope): - sources = {} - - # Traverse FROMs, JOINs, and LATERALs in the order they are defined - expressions = [] - from_ = scope.expression.args.get("from_") - if from_: - expressions.append(from_.this) - - for join in scope.expression.args.get("joins") or []: - expressions.append(join.this) - - if isinstance(scope.expression, exp.Table): - expressions.append(scope.expression) - - expressions.extend(scope.expression.args.get("laterals") or []) - - for expression in expressions: - if isinstance(expression, exp.Final): - expression = expression.this - if isinstance(expression, exp.Table): - table_name = expression.name - source_name = expression.alias_or_name - - if table_name in scope.sources and not expression.db: - # This is a reference to a parent source (e.g. a CTE), not an actual table, unless - # it is pivoted, because then we get back a new table and hence a new source. - pivots = expression.args.get("pivots") - if pivots: - sources[pivots[0].alias] = expression - else: - sources[source_name] = scope.sources[table_name] - elif source_name in sources: - sources[find_new_name(sources, table_name)] = expression - else: - sources[source_name] = expression - - # Make sure to not include the joins twice - if expression is not scope.expression: - expressions.extend( - join.this for join in expression.args.get("joins") or [] - ) - - continue - - if not isinstance(expression, exp.DerivedTable): - continue - - if isinstance(expression, exp.UDTF): - lateral_sources = sources - scope_type = ScopeType.UDTF - scopes = scope.udtf_scopes - elif _is_derived_table(expression): - lateral_sources = None - scope_type = ScopeType.DERIVED_TABLE - scopes = scope.derived_table_scopes - expressions.extend(join.this for join in expression.args.get("joins") or []) - else: - # Makes sure we check for possible sources in nested table constructs - expressions.append(expression.this) - expressions.extend(join.this for join in expression.args.get("joins") or []) - continue - - child_scope = None - - for child_scope in _traverse_scope( - scope.branch( - expression, - lateral_sources=lateral_sources, - outer_columns=expression.alias_column_names, - scope_type=scope_type, - ) - ): - yield child_scope - - # Tables without aliases will be set as "" - # This shouldn't be a problem once qualify_columns runs, as it adds aliases on everything. - # Until then, this means that only a single, unaliased derived table is allowed (rather, - # the latest one wins. - sources[_get_source_alias(expression)] = child_scope - - # append the final child_scope yielded - if child_scope: - scopes.append(child_scope) - scope.table_scopes.append(child_scope) - - scope.sources.update(sources) - - -def _traverse_subqueries(scope): - for subquery in scope.subqueries: - top = None - for child_scope in _traverse_scope( - scope.branch(subquery, scope_type=ScopeType.SUBQUERY) - ): - yield child_scope - top = child_scope - scope.subquery_scopes.append(top) - - -def _traverse_udtfs(scope): - if isinstance(scope.expression, exp.Unnest): - expressions = scope.expression.expressions - elif isinstance(scope.expression, exp.Lateral): - expressions = [scope.expression.this] - else: - expressions = [] - - sources = {} - for expression in expressions: - if isinstance(expression, exp.Subquery): - top = None - for child_scope in _traverse_scope( - scope.branch( - expression, - scope_type=ScopeType.SUBQUERY, - outer_columns=expression.alias_column_names, - ) - ): - yield child_scope - top = child_scope - sources[_get_source_alias(expression)] = child_scope - - scope.subquery_scopes.append(top) - - scope.sources.update(sources) - - -def walk_in_scope(expression, bfs=True, prune=None): - """ - Returns a generator object which visits all nodes in the syntrax tree, stopping at - nodes that start child scopes. - - Args: - expression (exp.Expression): - bfs (bool): if set to True the BFS traversal order will be applied, - otherwise the DFS traversal will be used instead. - prune ((node, parent, arg_key) -> bool): callable that returns True if - the generator should stop traversing this branch of the tree. - - Yields: - tuple[exp.Expression, Optional[exp.Expression], str]: node, parent, arg key - """ - # We'll use this variable to pass state into the dfs generator. - # Whenever we set it to True, we exclude a subtree from traversal. - crossed_scope_boundary = False - - for node in expression.walk( - bfs=bfs, prune=lambda n: crossed_scope_boundary or (prune and prune(n)) - ): - crossed_scope_boundary = False - - yield node - - if node is expression: - continue - - if ( - isinstance(node, exp.CTE) - or ( - isinstance(node.parent, (exp.From, exp.Join)) - and _is_derived_table(node) - ) - or (isinstance(node.parent, exp.UDTF) and isinstance(node, exp.Query)) - or isinstance(node, exp.UNWRAPPED_QUERIES) - ): - crossed_scope_boundary = True - - if isinstance(node, (exp.Subquery, exp.UDTF)): - # The following args are not actually in the inner scope, so we should visit them - for key in ("joins", "laterals", "pivots"): - for arg in node.args.get(key) or []: - yield from walk_in_scope(arg, bfs=bfs) - - -def find_all_in_scope(expression, expression_types, bfs=True): - """ - Returns a generator object which visits all nodes in this scope and only yields those that - match at least one of the specified expression types. - - This does NOT traverse into subscopes. - - Args: - expression (exp.Expression): - expression_types (tuple[type]|type): the expression type(s) to match. - bfs (bool): True to use breadth-first search, False to use depth-first. - - Yields: - exp.Expression: nodes - """ - for expression in walk_in_scope(expression, bfs=bfs): - if isinstance(expression, tuple(ensure_collection(expression_types))): - yield expression - - -def find_in_scope(expression, expression_types, bfs=True): - """ - Returns the first node in this scope which matches at least one of the specified types. - - This does NOT traverse into subscopes. - - Args: - expression (exp.Expression): - expression_types (tuple[type]|type): the expression type(s) to match. - bfs (bool): True to use breadth-first search, False to use depth-first. - - Returns: - exp.Expression: the node which matches the criteria or None if no node matching - the criteria was found. - """ - return next(find_all_in_scope(expression, expression_types, bfs=bfs), None) - - -def _get_source_alias(expression): - alias_arg = expression.args.get("alias") - alias_name = expression.alias - - if ( - not alias_name - and isinstance(alias_arg, exp.TableAlias) - and len(alias_arg.columns) == 1 - ): - alias_name = alias_arg.columns[0].name - - return alias_name diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/simplify.py b/third_party/bigframes_vendored/sqlglot/optimizer/simplify.py deleted file mode 100644 index 573dc9e67d3..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/simplify.py +++ /dev/null @@ -1,1796 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/simplify.py - -from __future__ import annotations - -import datetime -import functools -import itertools -import logging -import typing as t -from collections import defaultdict, deque -from functools import reduce, wraps - -import bigframes_vendored.sqlglot -from bigframes_vendored.sqlglot import Dialect, exp -from bigframes_vendored.sqlglot.helper import first, merge_ranges, while_changing -from bigframes_vendored.sqlglot.optimizer.annotate_types import TypeAnnotator -from bigframes_vendored.sqlglot.optimizer.scope import find_all_in_scope, walk_in_scope -from bigframes_vendored.sqlglot.schema import ensure_schema - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - DateRange = t.Tuple[datetime.date, datetime.date] - DateTruncBinaryTransform = t.Callable[ - [exp.Expression, datetime.date, str, Dialect, exp.DataType], - t.Optional[exp.Expression], - ] - - -logger = logging.getLogger("sqlglot") - - -# Final means that an expression should not be simplified -FINAL = "final" - -SIMPLIFIABLE = ( - exp.Binary, - exp.Func, - exp.Lambda, - exp.Predicate, - exp.Unary, -) - - -def simplify( - expression: exp.Expression, - constant_propagation: bool = False, - coalesce_simplification: bool = False, - dialect: DialectType = None, -): - """ - Rewrite sqlglot AST to simplify expressions. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("TRUE AND TRUE") - >>> simplify(expression).sql() - 'TRUE' - - Args: - expression: expression to simplify - constant_propagation: whether the constant propagation rule should be used - coalesce_simplification: whether the simplify coalesce rule should be used. - This rule tries to remove coalesce functions, which can be useful in certain analyses but - can leave the query more verbose. - Returns: - sqlglot.Expression: simplified expression - """ - return Simplifier(dialect=dialect).simplify( - expression, - constant_propagation=constant_propagation, - coalesce_simplification=coalesce_simplification, - ) - - -class UnsupportedUnit(Exception): - pass - - -def catch(*exceptions): - """Decorator that ignores a simplification function if any of `exceptions` are raised""" - - def decorator(func): - def wrapped(expression, *args, **kwargs): - try: - return func(expression, *args, **kwargs) - except exceptions: - return expression - - return wrapped - - return decorator - - -def annotate_types_on_change(func): - @wraps(func) - def _func( - self, expression: exp.Expression, *args, **kwargs - ) -> t.Optional[exp.Expression]: - new_expression = func(self, expression, *args, **kwargs) - - if new_expression is None: - return new_expression - - if self.annotate_new_expressions and expression != new_expression: - self._annotator.clear() - - # We annotate this to ensure new children nodes are also annotated - new_expression = self._annotator.annotate( - expression=new_expression, - annotate_scope=False, - ) - - # Whatever expression the original expression is transformed into needs to preserve - # the original type, otherwise the simplification could result in a different schema - new_expression.type = expression.type - - return new_expression - - return _func - - -def flatten(expression): - """ - A AND (B AND C) -> A AND B AND C - A OR (B OR C) -> A OR B OR C - """ - if isinstance(expression, exp.Connector): - for node in expression.args.values(): - child = node.unnest() - if isinstance(child, expression.__class__): - node.replace(child) - return expression - - -def simplify_parens(expression: exp.Expression, dialect: DialectType) -> exp.Expression: - if not isinstance(expression, exp.Paren): - return expression - - this = expression.this - parent = expression.parent - parent_is_predicate = isinstance(parent, exp.Predicate) - - if isinstance(this, exp.Select): - return expression - - if isinstance(parent, (exp.SubqueryPredicate, exp.Bracket)): - return expression - - if ( - Dialect.get_or_raise(dialect).REQUIRES_PARENTHESIZED_STRUCT_ACCESS - and isinstance(parent, exp.Dot) - and (isinstance(parent.right, (exp.Identifier, exp.Star))) - ): - return expression - - if ( - not isinstance(parent, (exp.Condition, exp.Binary)) - or isinstance(parent, exp.Paren) - or ( - not isinstance(this, exp.Binary) - and not (isinstance(this, (exp.Not, exp.Is)) and parent_is_predicate) - ) - or ( - isinstance(this, exp.Predicate) - and not (parent_is_predicate or isinstance(parent, exp.Neg)) - ) - or (isinstance(this, exp.Add) and isinstance(parent, exp.Add)) - or (isinstance(this, exp.Mul) and isinstance(parent, exp.Mul)) - or (isinstance(this, exp.Mul) and isinstance(parent, (exp.Add, exp.Sub))) - ): - return this - - return expression - - -def propagate_constants(expression, root=True): - """ - Propagate constants for conjunctions in DNF: - - SELECT * FROM t WHERE a = b AND b = 5 becomes - SELECT * FROM t WHERE a = 5 AND b = 5 - - Reference: https://www.sqlite.org/optoverview.html - """ - - if ( - isinstance(expression, exp.And) - and (root or not expression.same_parent) - and bigframes_vendored.sqlglot.optimizer.normalize.normalized( - expression, dnf=True - ) - ): - constant_mapping = {} - for expr in walk_in_scope( - expression, prune=lambda node: isinstance(node, exp.If) - ): - if isinstance(expr, exp.EQ): - l, r = expr.left, expr.right - - # TODO: create a helper that can be used to detect nested literal expressions such - # as CAST(123456 AS BIGINT), since we usually want to treat those as literals too - if isinstance(l, exp.Column) and isinstance(r, exp.Literal): - constant_mapping[l] = (id(l), r) - - if constant_mapping: - for column in find_all_in_scope(expression, exp.Column): - parent = column.parent - column_id, constant = constant_mapping.get(column) or (None, None) - if ( - column_id is not None - and id(column) != column_id - and not ( - isinstance(parent, exp.Is) - and isinstance(parent.expression, exp.Null) - ) - ): - column.replace(constant.copy()) - - return expression - - -def _is_number(expression: exp.Expression) -> bool: - return expression.is_number - - -def _is_interval(expression: exp.Expression) -> bool: - return ( - isinstance(expression, exp.Interval) - and extract_interval(expression) is not None - ) - - -def _is_nonnull_constant(expression: exp.Expression) -> bool: - return isinstance(expression, exp.NONNULL_CONSTANTS) or _is_date_literal(expression) - - -def _is_constant(expression: exp.Expression) -> bool: - return isinstance(expression, exp.CONSTANTS) or _is_date_literal(expression) - - -def _datetrunc_range( - date: datetime.date, unit: str, dialect: Dialect -) -> t.Optional[DateRange]: - """ - Get the date range for a DATE_TRUNC equality comparison: - - Example: - _datetrunc_range(date(2021-01-01), 'year') == (date(2021-01-01), date(2022-01-01)) - Returns: - tuple of [min, max) or None if a value can never be equal to `date` for `unit` - """ - floor = date_floor(date, unit, dialect) - - if date != floor: - # This will always be False, except for NULL values. - return None - - return floor, floor + interval(unit) - - -def _datetrunc_eq_expression( - left: exp.Expression, drange: DateRange, target_type: t.Optional[exp.DataType] -) -> exp.Expression: - """Get the logical expression for a date range""" - return exp.and_( - left >= date_literal(drange[0], target_type), - left < date_literal(drange[1], target_type), - copy=False, - ) - - -def _datetrunc_eq( - left: exp.Expression, - date: datetime.date, - unit: str, - dialect: Dialect, - target_type: t.Optional[exp.DataType], -) -> t.Optional[exp.Expression]: - drange = _datetrunc_range(date, unit, dialect) - if not drange: - return None - - return _datetrunc_eq_expression(left, drange, target_type) - - -def _datetrunc_neq( - left: exp.Expression, - date: datetime.date, - unit: str, - dialect: Dialect, - target_type: t.Optional[exp.DataType], -) -> t.Optional[exp.Expression]: - drange = _datetrunc_range(date, unit, dialect) - if not drange: - return None - - return exp.and_( - left < date_literal(drange[0], target_type), - left >= date_literal(drange[1], target_type), - copy=False, - ) - - -def always_true(expression): - return (isinstance(expression, exp.Boolean) and expression.this) or ( - isinstance(expression, exp.Literal) - and expression.is_number - and not is_zero(expression) - ) - - -def always_false(expression): - return is_false(expression) or is_null(expression) or is_zero(expression) - - -def is_zero(expression): - return isinstance(expression, exp.Literal) and expression.to_py() == 0 - - -def is_complement(a, b): - return isinstance(b, exp.Not) and b.this == a - - -def is_false(a: exp.Expression) -> bool: - return type(a) is exp.Boolean and not a.this - - -def is_null(a: exp.Expression) -> bool: - return type(a) is exp.Null - - -def eval_boolean(expression, a, b): - if isinstance(expression, (exp.EQ, exp.Is)): - return boolean_literal(a == b) - if isinstance(expression, exp.NEQ): - return boolean_literal(a != b) - if isinstance(expression, exp.GT): - return boolean_literal(a > b) - if isinstance(expression, exp.GTE): - return boolean_literal(a >= b) - if isinstance(expression, exp.LT): - return boolean_literal(a < b) - if isinstance(expression, exp.LTE): - return boolean_literal(a <= b) - return None - - -def cast_as_date(value: t.Any) -> t.Optional[datetime.date]: - if isinstance(value, datetime.datetime): - return value.date() - if isinstance(value, datetime.date): - return value - try: - return datetime.datetime.fromisoformat(value).date() - except ValueError: - return None - - -def cast_as_datetime(value: t.Any) -> t.Optional[datetime.datetime]: - if isinstance(value, datetime.datetime): - return value - if isinstance(value, datetime.date): - return datetime.datetime(year=value.year, month=value.month, day=value.day) - try: - return datetime.datetime.fromisoformat(value) - except ValueError: - return None - - -def cast_value( - value: t.Any, to: exp.DataType -) -> t.Optional[t.Union[datetime.date, datetime.date]]: - if not value: - return None - if to.is_type(exp.DataType.Type.DATE): - return cast_as_date(value) - if to.is_type(*exp.DataType.TEMPORAL_TYPES): - return cast_as_datetime(value) - return None - - -def extract_date( - cast: exp.Expression, -) -> t.Optional[t.Union[datetime.date, datetime.date]]: - if isinstance(cast, exp.Cast): - to = cast.to - elif isinstance(cast, exp.TsOrDsToDate) and not cast.args.get("format"): - to = exp.DataType.build(exp.DataType.Type.DATE) - else: - return None - - if isinstance(cast.this, exp.Literal): - value: t.Any = cast.this.name - elif isinstance(cast.this, (exp.Cast, exp.TsOrDsToDate)): - value = extract_date(cast.this) - else: - return None - return cast_value(value, to) - - -def _is_date_literal(expression: exp.Expression) -> bool: - return extract_date(expression) is not None - - -def extract_interval(expression): - try: - n = int(expression.this.to_py()) - unit = expression.text("unit").lower() - return interval(unit, n) - except (UnsupportedUnit, ModuleNotFoundError, ValueError): - return None - - -def extract_type(*expressions): - target_type = None - for expression in expressions: - target_type = ( - expression.to if isinstance(expression, exp.Cast) else expression.type - ) - if target_type: - break - - return target_type - - -def date_literal(date, target_type=None): - if not target_type or not target_type.is_type(*exp.DataType.TEMPORAL_TYPES): - target_type = ( - exp.DataType.Type.DATETIME - if isinstance(date, datetime.datetime) - else exp.DataType.Type.DATE - ) - - return exp.cast(exp.Literal.string(date), target_type) - - -def interval(unit: str, n: int = 1): - from dateutil.relativedelta import relativedelta - - if unit == "year": - return relativedelta(years=1 * n) - if unit == "quarter": - return relativedelta(months=3 * n) - if unit == "month": - return relativedelta(months=1 * n) - if unit == "week": - return relativedelta(weeks=1 * n) - if unit == "day": - return relativedelta(days=1 * n) - if unit == "hour": - return relativedelta(hours=1 * n) - if unit == "minute": - return relativedelta(minutes=1 * n) - if unit == "second": - return relativedelta(seconds=1 * n) - - raise UnsupportedUnit(f"Unsupported unit: {unit}") - - -def date_floor(d: datetime.date, unit: str, dialect: Dialect) -> datetime.date: - if unit == "year": - return d.replace(month=1, day=1) - if unit == "quarter": - if d.month <= 3: - return d.replace(month=1, day=1) - elif d.month <= 6: - return d.replace(month=4, day=1) - elif d.month <= 9: - return d.replace(month=7, day=1) - else: - return d.replace(month=10, day=1) - if unit == "month": - return d.replace(month=d.month, day=1) - if unit == "week": - # Assuming week starts on Monday (0) and ends on Sunday (6) - return d - datetime.timedelta(days=d.weekday() - dialect.WEEK_OFFSET) - if unit == "day": - return d - - raise UnsupportedUnit(f"Unsupported unit: {unit}") - - -def date_ceil(d: datetime.date, unit: str, dialect: Dialect) -> datetime.date: - floor = date_floor(d, unit, dialect) - - if floor == d: - return d - - return floor + interval(unit) - - -def boolean_literal(condition): - return exp.true() if condition else exp.false() - - -class Simplifier: - def __init__( - self, dialect: DialectType = None, annotate_new_expressions: bool = True - ): - self.dialect = Dialect.get_or_raise(dialect) - self.annotate_new_expressions = annotate_new_expressions - - self._annotator: TypeAnnotator = TypeAnnotator( - schema=ensure_schema(None, dialect=self.dialect), overwrite_types=False - ) - - # Value ranges for byte-sized signed/unsigned integers - TINYINT_MIN = -128 - TINYINT_MAX = 127 - UTINYINT_MIN = 0 - UTINYINT_MAX = 255 - - COMPLEMENT_COMPARISONS = { - exp.LT: exp.GTE, - exp.GT: exp.LTE, - exp.LTE: exp.GT, - exp.GTE: exp.LT, - exp.EQ: exp.NEQ, - exp.NEQ: exp.EQ, - } - - COMPLEMENT_SUBQUERY_PREDICATES = { - exp.All: exp.Any, - exp.Any: exp.All, - } - - LT_LTE = (exp.LT, exp.LTE) - GT_GTE = (exp.GT, exp.GTE) - - COMPARISONS = ( - *LT_LTE, - *GT_GTE, - exp.EQ, - exp.NEQ, - exp.Is, - ) - - INVERSE_COMPARISONS: t.Dict[t.Type[exp.Expression], t.Type[exp.Expression]] = { - exp.LT: exp.GT, - exp.GT: exp.LT, - exp.LTE: exp.GTE, - exp.GTE: exp.LTE, - } - - NONDETERMINISTIC = (exp.Rand, exp.Randn) - AND_OR = (exp.And, exp.Or) - - INVERSE_DATE_OPS: t.Dict[t.Type[exp.Expression], t.Type[exp.Expression]] = { - exp.DateAdd: exp.Sub, - exp.DateSub: exp.Add, - exp.DatetimeAdd: exp.Sub, - exp.DatetimeSub: exp.Add, - } - - INVERSE_OPS: t.Dict[t.Type[exp.Expression], t.Type[exp.Expression]] = { - **INVERSE_DATE_OPS, - exp.Add: exp.Sub, - exp.Sub: exp.Add, - } - - NULL_OK = (exp.NullSafeEQ, exp.NullSafeNEQ, exp.PropertyEQ) - - CONCATS = (exp.Concat, exp.DPipe) - - DATETRUNC_BINARY_COMPARISONS: t.Dict[ - t.Type[exp.Expression], DateTruncBinaryTransform - ] = { - exp.LT: lambda ll, dt, u, d, t: ll - < date_literal( - dt if dt == date_floor(dt, u, d) else date_floor(dt, u, d) + interval(u), t - ), - exp.GT: lambda ll, dt, u, d, t: ll - >= date_literal(date_floor(dt, u, d) + interval(u), t), - exp.LTE: lambda ll, dt, u, d, t: ll - < date_literal(date_floor(dt, u, d) + interval(u), t), - exp.GTE: lambda ll, dt, u, d, t: ll >= date_literal(date_ceil(dt, u, d), t), - exp.EQ: _datetrunc_eq, - exp.NEQ: _datetrunc_neq, - } - - DATETRUNC_COMPARISONS = {exp.In, *DATETRUNC_BINARY_COMPARISONS} - DATETRUNCS = (exp.DateTrunc, exp.TimestampTrunc) - - SAFE_CONNECTOR_ELIMINATION_RESULT = (exp.Connector, exp.Boolean) - - # CROSS joins result in an empty table if the right table is empty. - # So we can only simplify certain types of joins to CROSS. - # Or in other words, LEFT JOIN x ON TRUE != CROSS JOIN x - JOINS = { - ("", ""), - ("", "INNER"), - ("RIGHT", ""), - ("RIGHT", "OUTER"), - } - - def simplify( - self, - expression: exp.Expression, - constant_propagation: bool = False, - coalesce_simplification: bool = False, - ): - wheres = [] - joins = [] - - for node in expression.walk( - prune=lambda n: bool(isinstance(n, exp.Condition) or n.meta.get(FINAL)) - ): - if node.meta.get(FINAL): - continue - - # group by expressions cannot be simplified, for example - # select x + 1 + 1 FROM y GROUP BY x + 1 + 1 - # the projection must exactly match the group by key - group = node.args.get("group") - - if group and hasattr(node, "selects"): - groups = set(group.expressions) - group.meta[FINAL] = True - - for s in node.selects: - for n in s.walk(FINAL): - if n in groups: - s.meta[FINAL] = True - break - - having = node.args.get("having") - - if having: - for n in having.walk(): - if n in groups: - having.meta[FINAL] = True - break - - if isinstance(node, exp.Condition): - simplified = while_changing( - node, - lambda e: self._simplify( - e, constant_propagation, coalesce_simplification - ), - ) - - if node is expression: - expression = simplified - elif isinstance(node, exp.Where): - wheres.append(node) - elif isinstance(node, exp.Join): - # snowflake match_conditions have very strict ordering rules - if match := node.args.get("match_condition"): - match.meta[FINAL] = True - - joins.append(node) - - for where in wheres: - if always_true(where.this): - where.pop() - for join in joins: - if ( - always_true(join.args.get("on")) - and not join.args.get("using") - and not join.args.get("method") - and (join.side, join.kind) in self.JOINS - ): - join.args["on"].pop() - join.set("side", None) - join.set("kind", "CROSS") - - return expression - - def _simplify( - self, - expression: exp.Expression, - constant_propagation: bool, - coalesce_simplification: bool, - ): - pre_transformation_stack = [expression] - post_transformation_stack = [] - - while pre_transformation_stack: - original = pre_transformation_stack.pop() - node = original - - if not isinstance(node, SIMPLIFIABLE): - if isinstance(node, exp.Query): - self.simplify(node, constant_propagation, coalesce_simplification) - continue - - parent = node.parent - root = node is expression - - node = self.rewrite_between(node) - node = self.uniq_sort(node, root) - node = self.absorb_and_eliminate(node, root) - node = self.simplify_concat(node) - node = self.simplify_conditionals(node) - - if constant_propagation: - node = propagate_constants(node, root) - - if node is not original: - original.replace(node) - - for n in node.iter_expressions(reverse=True): - if n.meta.get(FINAL): - raise - pre_transformation_stack.extend( - n for n in node.iter_expressions(reverse=True) if not n.meta.get(FINAL) - ) - post_transformation_stack.append((node, parent)) - - while post_transformation_stack: - original, parent = post_transformation_stack.pop() - root = original is expression - - # Resets parent, arg_key, index pointers– this is needed because some of the - # previous transformations mutate the AST, leading to an inconsistent state - for k, v in tuple(original.args.items()): - original.set(k, v) - - # Post-order transformations - node = self.simplify_not(original) - node = flatten(node) - node = self.simplify_connectors(node, root) - node = self.remove_complements(node, root) - - if coalesce_simplification: - node = self.simplify_coalesce(node) - node.parent = parent - - node = self.simplify_literals(node, root) - node = self.simplify_equality(node) - node = simplify_parens(node, dialect=self.dialect) - node = self.simplify_datetrunc(node) - node = self.sort_comparison(node) - node = self.simplify_startswith(node) - - if node is not original: - original.replace(node) - - return node - - @annotate_types_on_change - def rewrite_between(self, expression: exp.Expression) -> exp.Expression: - """Rewrite x between y and z to x >= y AND x <= z. - - This is done because comparison simplification is only done on lt/lte/gt/gte. - """ - if isinstance(expression, exp.Between): - negate = isinstance(expression.parent, exp.Not) - - expression = exp.and_( - exp.GTE(this=expression.this.copy(), expression=expression.args["low"]), - exp.LTE( - this=expression.this.copy(), expression=expression.args["high"] - ), - copy=False, - ) - - if negate: - expression = exp.paren(expression, copy=False) - - return expression - - @annotate_types_on_change - def simplify_not(self, expression: exp.Expression) -> exp.Expression: - """ - Demorgan's Law - NOT (x OR y) -> NOT x AND NOT y - NOT (x AND y) -> NOT x OR NOT y - """ - if isinstance(expression, exp.Not): - this = expression.this - if is_null(this): - return exp.and_(exp.null(), exp.true(), copy=False) - if this.__class__ in self.COMPLEMENT_COMPARISONS: - right = this.expression - complement_subquery_predicate = self.COMPLEMENT_SUBQUERY_PREDICATES.get( - right.__class__ - ) - if complement_subquery_predicate: - right = complement_subquery_predicate(this=right.this) - - return self.COMPLEMENT_COMPARISONS[this.__class__]( - this=this.this, expression=right - ) - if isinstance(this, exp.Paren): - condition = this.unnest() - if isinstance(condition, exp.And): - return exp.paren( - exp.or_( - exp.not_(condition.left, copy=False), - exp.not_(condition.right, copy=False), - copy=False, - ), - copy=False, - ) - if isinstance(condition, exp.Or): - return exp.paren( - exp.and_( - exp.not_(condition.left, copy=False), - exp.not_(condition.right, copy=False), - copy=False, - ), - copy=False, - ) - if is_null(condition): - return exp.and_(exp.null(), exp.true(), copy=False) - if always_true(this): - return exp.false() - if is_false(this): - return exp.true() - if ( - isinstance(this, exp.Not) - and self.dialect.SAFE_TO_ELIMINATE_DOUBLE_NEGATION - ): - inner = this.this - if inner.is_type(exp.DataType.Type.BOOLEAN): - # double negation - # NOT NOT x -> x, if x is BOOLEAN type - return inner - return expression - - @annotate_types_on_change - def simplify_connectors(self, expression, root=True): - def _simplify_connectors(expression, left, right): - if isinstance(expression, exp.And): - if is_false(left) or is_false(right): - return exp.false() - if is_zero(left) or is_zero(right): - return exp.false() - if ( - (is_null(left) and is_null(right)) - or (is_null(left) and always_true(right)) - or (always_true(left) and is_null(right)) - ): - return exp.null() - if always_true(left) and always_true(right): - return exp.true() - if always_true(left): - return right - if always_true(right): - return left - return self._simplify_comparison(expression, left, right) - elif isinstance(expression, exp.Or): - if always_true(left) or always_true(right): - return exp.true() - if ( - (is_null(left) and is_null(right)) - or (is_null(left) and always_false(right)) - or (always_false(left) and is_null(right)) - ): - return exp.null() - if is_false(left): - return right - if is_false(right): - return left - return self._simplify_comparison(expression, left, right, or_=True) - - if isinstance(expression, exp.Connector): - original_parent = expression.parent - expression = self._flat_simplify(expression, _simplify_connectors, root) - - # If we reduced a connector to, e.g., a column (t1 AND ... AND tn -> Tk), then we need - # to ensure that the resulting type is boolean. We know this is true only for connectors, - # boolean values and columns that are essentially operands to a connector: - # - # A AND (((B))) - # ~ this is safe to keep because it will eventually be part of another connector - if not isinstance( - expression, self.SAFE_CONNECTOR_ELIMINATION_RESULT - ) and not expression.is_type(exp.DataType.Type.BOOLEAN): - while True: - if isinstance(original_parent, exp.Connector): - break - if not isinstance(original_parent, exp.Paren): - expression = expression.and_(exp.true(), copy=False) - break - - original_parent = original_parent.parent - - return expression - - @annotate_types_on_change - def _simplify_comparison(self, expression, left, right, or_=False): - if isinstance(left, self.COMPARISONS) and isinstance(right, self.COMPARISONS): - ll, lr = left.args.values() - rl, rr = right.args.values() - - largs = {ll, lr} - rargs = {rl, rr} - - matching = largs & rargs - columns = { - m - for m in matching - if not _is_constant(m) and not m.find(*self.NONDETERMINISTIC) - } - - if matching and columns: - try: - l0 = first(largs - columns) - r = first(rargs - columns) - except StopIteration: - return expression - - if l0.is_number and r.is_number: - l0 = l0.to_py() - r = r.to_py() - elif l0.is_string and r.is_string: - l0 = l0.name - r = r.name - else: - l0 = extract_date(l0) - if not l0: - return None - r = extract_date(r) - if not r: - return None - # python won't compare date and datetime, but many engines will upcast - l0, r = cast_as_datetime(l0), cast_as_datetime(r) - - for (a, av), (b, bv) in itertools.permutations( - ((left, l0), (right, r)) - ): - if isinstance(a, self.LT_LTE) and isinstance(b, self.LT_LTE): - return left if (av > bv if or_ else av <= bv) else right - if isinstance(a, self.GT_GTE) and isinstance(b, self.GT_GTE): - return left if (av < bv if or_ else av >= bv) else right - - # we can't ever shortcut to true because the column could be null - if not or_: - if isinstance(a, exp.LT) and isinstance(b, self.GT_GTE): - if av <= bv: - return exp.false() - elif isinstance(a, exp.GT) and isinstance(b, self.LT_LTE): - if av >= bv: - return exp.false() - elif isinstance(a, exp.EQ): - if isinstance(b, exp.LT): - return exp.false() if av >= bv else a - if isinstance(b, exp.LTE): - return exp.false() if av > bv else a - if isinstance(b, exp.GT): - return exp.false() if av <= bv else a - if isinstance(b, exp.GTE): - return exp.false() if av < bv else a - if isinstance(b, exp.NEQ): - return exp.false() if av == bv else a - return None - - @annotate_types_on_change - def remove_complements(self, expression, root=True): - """ - Removing complements. - - A AND NOT A -> FALSE (only for non-NULL A) - A OR NOT A -> TRUE (only for non-NULL A) - """ - if isinstance(expression, self.AND_OR) and (root or not expression.same_parent): - ops = set(expression.flatten()) - for op in ops: - if isinstance(op, exp.Not) and op.this in ops: - if expression.meta.get("nonnull") is True: - return ( - exp.false() - if isinstance(expression, exp.And) - else exp.true() - ) - - return expression - - @annotate_types_on_change - def uniq_sort(self, expression, root=True): - """ - Uniq and sort a connector. - - C AND A AND B AND B -> A AND B AND C - """ - if isinstance(expression, exp.Connector) and ( - root or not expression.same_parent - ): - flattened = tuple(expression.flatten()) - - if isinstance(expression, exp.Xor): - result_func = exp.xor - # Do not deduplicate XOR as A XOR A != A if A == True - deduped = None - arr = tuple((gen(e), e) for e in flattened) - else: - result_func = exp.and_ if isinstance(expression, exp.And) else exp.or_ - deduped = {gen(e): e for e in flattened} - arr = tuple(deduped.items()) - - # check if the operands are already sorted, if not sort them - # A AND C AND B -> A AND B AND C - for i, (sql, e) in enumerate(arr[1:]): - if sql < arr[i][0]: - expression = result_func(*(e for _, e in sorted(arr)), copy=False) - break - else: - # we didn't have to sort but maybe we need to dedup - if deduped and len(deduped) < len(flattened): - unique_operand = flattened[0] - if len(deduped) == 1: - expression = unique_operand.and_(exp.true(), copy=False) - else: - expression = result_func(*deduped.values(), copy=False) - - return expression - - @annotate_types_on_change - def absorb_and_eliminate(self, expression, root=True): - """ - absorption: - A AND (A OR B) -> A - A OR (A AND B) -> A - A AND (NOT A OR B) -> A AND B - A OR (NOT A AND B) -> A OR B - elimination: - (A AND B) OR (A AND NOT B) -> A - (A OR B) AND (A OR NOT B) -> A - """ - if isinstance(expression, self.AND_OR) and (root or not expression.same_parent): - kind = exp.Or if isinstance(expression, exp.And) else exp.And - - ops = tuple(expression.flatten()) - - # Initialize lookup tables: - # Set of all operands, used to find complements for absorption. - op_set = set() - # Sub-operands, used to find subsets for absorption. - subops = defaultdict(list) - # Pairs of complements, used for elimination. - pairs = defaultdict(list) - - # Populate the lookup tables - for op in ops: - op_set.add(op) - - if not isinstance(op, kind): - # In cases like: A OR (A AND B) - # Subop will be: ^ - subops[op].append({op}) - continue - - # In cases like: (A AND B) OR (A AND B AND C) - # Subops will be: ^ ^ - subset = set(op.flatten()) - for i in subset: - subops[i].append(subset) - - a, b = op.unnest_operands() - if isinstance(a, exp.Not): - pairs[frozenset((a.this, b))].append((op, b)) - if isinstance(b, exp.Not): - pairs[frozenset((a, b.this))].append((op, a)) - - for op in ops: - if not isinstance(op, kind): - continue - - a, b = op.unnest_operands() - - # Absorb - if isinstance(a, exp.Not) and a.this in op_set: - a.replace(exp.true() if kind == exp.And else exp.false()) - continue - if isinstance(b, exp.Not) and b.this in op_set: - b.replace(exp.true() if kind == exp.And else exp.false()) - continue - superset = set(op.flatten()) - if any( - any(subset < superset for subset in subops[i]) for i in superset - ): - op.replace(exp.false() if kind == exp.And else exp.true()) - continue - - # Eliminate - for other, complement in pairs[frozenset((a, b))]: - op.replace(complement) - other.replace(complement) - - return expression - - @annotate_types_on_change - @catch(ModuleNotFoundError, UnsupportedUnit) - def simplify_equality(self, expression: exp.Expression) -> exp.Expression: - """ - Use the subtraction and addition properties of equality to simplify expressions: - - x + 1 = 3 becomes x = 2 - - There are two binary operations in the above expression: + and = - Here's how we reference all the operands in the code below: - - l r - x + 1 = 3 - a b - """ - if isinstance(expression, self.COMPARISONS): - ll, r = expression.left, expression.right - - if ll.__class__ not in self.INVERSE_OPS: - return expression - - if r.is_number: - a_predicate = _is_number - b_predicate = _is_number - elif _is_date_literal(r): - a_predicate = _is_date_literal - b_predicate = _is_interval - else: - return expression - - if ll.__class__ in self.INVERSE_DATE_OPS: - ll = t.cast(exp.IntervalOp, ll) - a = ll.this - b = ll.interval() - else: - ll = t.cast(exp.Binary, ll) - a, b = ll.left, ll.right - - if not a_predicate(a) and b_predicate(b): - pass - elif not a_predicate(b) and b_predicate(a): - a, b = b, a - else: - return expression - - return expression.__class__( - this=a, expression=self.INVERSE_OPS[ll.__class__](this=r, expression=b) - ) - return expression - - @annotate_types_on_change - def simplify_literals(self, expression, root=True): - if isinstance(expression, exp.Binary) and not isinstance( - expression, exp.Connector - ): - return self._flat_simplify(expression, self._simplify_binary, root) - - if isinstance(expression, exp.Neg) and isinstance(expression.this, exp.Neg): - return expression.this.this - - if type(expression) in self.INVERSE_DATE_OPS: - return ( - self._simplify_binary( - expression, expression.this, expression.interval() - ) - or expression - ) - - return expression - - def _simplify_integer_cast(self, expr: exp.Expression) -> exp.Expression: - if isinstance(expr, exp.Cast) and isinstance(expr.this, exp.Cast): - this = self._simplify_integer_cast(expr.this) - else: - this = expr.this - - if isinstance(expr, exp.Cast) and this.is_int: - num = this.to_py() - - # Remove the (up)cast from small (byte-sized) integers in predicates which is side-effect free. Downcasts on any - # integer type might cause overflow, thus the cast cannot be eliminated and the behavior is - # engine-dependent - if ( - self.TINYINT_MIN <= num <= self.TINYINT_MAX - and expr.to.this in exp.DataType.SIGNED_INTEGER_TYPES - ) or ( - self.UTINYINT_MIN <= num <= self.UTINYINT_MAX - and expr.to.this in exp.DataType.UNSIGNED_INTEGER_TYPES - ): - return this - - return expr - - def _simplify_binary(self, expression, a, b): - if isinstance(expression, self.COMPARISONS): - a = self._simplify_integer_cast(a) - b = self._simplify_integer_cast(b) - - if isinstance(expression, exp.Is): - if isinstance(b, exp.Not): - c = b.this - not_ = True - else: - c = b - not_ = False - - if is_null(c): - if isinstance(a, exp.Literal): - return exp.true() if not_ else exp.false() - if is_null(a): - return exp.false() if not_ else exp.true() - elif isinstance(expression, self.NULL_OK): - return None - elif (is_null(a) or is_null(b)) and isinstance(expression.parent, exp.If): - return exp.null() - - if a.is_number and b.is_number: - num_a = a.to_py() - num_b = b.to_py() - - if isinstance(expression, exp.Add): - return exp.Literal.number(num_a + num_b) - if isinstance(expression, exp.Mul): - return exp.Literal.number(num_a * num_b) - - # We only simplify Sub, Div if a and b have the same parent because they're not associative - if isinstance(expression, exp.Sub): - return ( - exp.Literal.number(num_a - num_b) if a.parent is b.parent else None - ) - if isinstance(expression, exp.Div): - # engines have differing int div behavior so intdiv is not safe - if ( - isinstance(num_a, int) and isinstance(num_b, int) - ) or a.parent is not b.parent: - return None - return exp.Literal.number(num_a / num_b) - - boolean = eval_boolean(expression, num_a, num_b) - - if boolean: - return boolean - elif a.is_string and b.is_string: - boolean = eval_boolean(expression, a.this, b.this) - - if boolean: - return boolean - elif _is_date_literal(a) and isinstance(b, exp.Interval): - date, b = extract_date(a), extract_interval(b) - if date and b: - if isinstance(expression, (exp.Add, exp.DateAdd, exp.DatetimeAdd)): - return date_literal(date + b, extract_type(a)) - if isinstance(expression, (exp.Sub, exp.DateSub, exp.DatetimeSub)): - return date_literal(date - b, extract_type(a)) - elif isinstance(a, exp.Interval) and _is_date_literal(b): - a, date = extract_interval(a), extract_date(b) - # you cannot subtract a date from an interval - if a and b and isinstance(expression, exp.Add): - return date_literal(a + date, extract_type(b)) - elif _is_date_literal(a) and _is_date_literal(b): - if isinstance(expression, exp.Predicate): - a, b = extract_date(a), extract_date(b) - boolean = eval_boolean(expression, a, b) - if boolean: - return boolean - - return None - - @annotate_types_on_change - def simplify_coalesce(self, expression: exp.Expression) -> exp.Expression: - # COALESCE(x) -> x - if ( - isinstance(expression, exp.Coalesce) - and (not expression.expressions or _is_nonnull_constant(expression.this)) - # COALESCE is also used as a Spark partitioning hint - and not isinstance(expression.parent, exp.Hint) - ): - return expression.this - - if self.dialect.COALESCE_COMPARISON_NON_STANDARD: - return expression - - if not isinstance(expression, self.COMPARISONS): - return expression - - if isinstance(expression.left, exp.Coalesce): - coalesce = expression.left - other = expression.right - elif isinstance(expression.right, exp.Coalesce): - coalesce = expression.right - other = expression.left - else: - return expression - - # This transformation is valid for non-constants, - # but it really only does anything if they are both constants. - if not _is_constant(other): - return expression - - # Find the first constant arg - for arg_index, arg in enumerate(coalesce.expressions): - if _is_constant(arg): - break - else: - return expression - - coalesce.set("expressions", coalesce.expressions[:arg_index]) - - # Remove the COALESCE function. This is an optimization, skipping a simplify iteration, - # since we already remove COALESCE at the top of this function. - coalesce = coalesce if coalesce.expressions else coalesce.this - - # This expression is more complex than when we started, but it will get simplified further - return exp.paren( - exp.or_( - exp.and_( - coalesce.is_(exp.null()).not_(copy=False), - expression.copy(), - copy=False, - ), - exp.and_( - coalesce.is_(exp.null()), - type(expression)(this=arg.copy(), expression=other.copy()), - copy=False, - ), - copy=False, - ), - copy=False, - ) - - @annotate_types_on_change - def simplify_concat(self, expression): - """Reduces all groups that contain string literals by concatenating them.""" - if not isinstance(expression, self.CONCATS) or ( - # We can't reduce a CONCAT_WS call if we don't statically know the separator - isinstance(expression, exp.ConcatWs) - and not expression.expressions[0].is_string - ): - return expression - - if isinstance(expression, exp.ConcatWs): - sep_expr, *expressions = expression.expressions - sep = sep_expr.name - concat_type = exp.ConcatWs - args = {} - else: - expressions = expression.expressions - sep = "" - concat_type = exp.Concat - args = { - "safe": expression.args.get("safe"), - "coalesce": expression.args.get("coalesce"), - } - - new_args = [] - for is_string_group, group in itertools.groupby( - expressions or expression.flatten(), lambda e: e.is_string - ): - if is_string_group: - new_args.append( - exp.Literal.string(sep.join(string.name for string in group)) - ) - else: - new_args.extend(group) - - if len(new_args) == 1 and new_args[0].is_string: - return new_args[0] - - if concat_type is exp.ConcatWs: - new_args = [sep_expr] + new_args - elif isinstance(expression, exp.DPipe): - return reduce(lambda x, y: exp.DPipe(this=x, expression=y), new_args) - - return concat_type(expressions=new_args, **args) - - @annotate_types_on_change - def simplify_conditionals(self, expression): - """Simplifies expressions like IF, CASE if their condition is statically known.""" - if isinstance(expression, exp.Case): - this = expression.this - for case in expression.args["ifs"]: - cond = case.this - if this: - # Convert CASE x WHEN matching_value ... to CASE WHEN x = matching_value ... - cond = cond.replace(this.pop().eq(cond)) - - if always_true(cond): - return case.args["true"] - - if always_false(cond): - case.pop() - if not expression.args["ifs"]: - return expression.args.get("default") or exp.null() - elif isinstance(expression, exp.If) and not isinstance( - expression.parent, exp.Case - ): - if always_true(expression.this): - return expression.args["true"] - if always_false(expression.this): - return expression.args.get("false") or exp.null() - - return expression - - @annotate_types_on_change - def simplify_startswith(self, expression: exp.Expression) -> exp.Expression: - """ - Reduces a prefix check to either TRUE or FALSE if both the string and the - prefix are statically known. - - Example: - >>> from bigframes_vendored.sqlglot import parse_one - >>> Simplifier().simplify_startswith(parse_one("STARTSWITH('foo', 'f')")).sql() - 'TRUE' - """ - if ( - isinstance(expression, exp.StartsWith) - and expression.this.is_string - and expression.expression.is_string - ): - return exp.convert(expression.name.startswith(expression.expression.name)) - - return expression - - def _is_datetrunc_predicate( - self, left: exp.Expression, right: exp.Expression - ) -> bool: - return isinstance(left, self.DATETRUNCS) and _is_date_literal(right) - - @annotate_types_on_change - @catch(ModuleNotFoundError, UnsupportedUnit) - def simplify_datetrunc(self, expression: exp.Expression) -> exp.Expression: - """Simplify expressions like `DATE_TRUNC('year', x) >= CAST('2021-01-01' AS DATE)`""" - comparison = expression.__class__ - - if isinstance(expression, self.DATETRUNCS): - this = expression.this - trunc_type = extract_type(this) - date = extract_date(this) - if date and expression.unit: - return date_literal( - date_floor(date, expression.unit.name.lower(), self.dialect), - trunc_type, - ) - elif comparison not in self.DATETRUNC_COMPARISONS: - return expression - - if isinstance(expression, exp.Binary): - ll, r = expression.left, expression.right - - if not self._is_datetrunc_predicate(ll, r): - return expression - - ll = t.cast(exp.DateTrunc, ll) - trunc_arg = ll.this - unit = ll.unit.name.lower() - date = extract_date(r) - - if not date: - return expression - - return ( - self.DATETRUNC_BINARY_COMPARISONS[comparison]( - trunc_arg, date, unit, self.dialect, extract_type(r) - ) - or expression - ) - - if isinstance(expression, exp.In): - ll = expression.this - rs = expression.expressions - - if rs and all(self._is_datetrunc_predicate(ll, r) for r in rs): - ll = t.cast(exp.DateTrunc, ll) - unit = ll.unit.name.lower() - - ranges = [] - for r in rs: - date = extract_date(r) - if not date: - return expression - drange = _datetrunc_range(date, unit, self.dialect) - if drange: - ranges.append(drange) - - if not ranges: - return expression - - ranges = merge_ranges(ranges) - target_type = extract_type(*rs) - - return exp.or_( - *[ - _datetrunc_eq_expression(ll, drange, target_type) - for drange in ranges - ], - copy=False, - ) - - return expression - - @annotate_types_on_change - def sort_comparison(self, expression: exp.Expression) -> exp.Expression: - if expression.__class__ in self.COMPLEMENT_COMPARISONS: - l, r = expression.this, expression.expression - l_column = isinstance(l, exp.Column) - r_column = isinstance(r, exp.Column) - l_const = _is_constant(l) - r_const = _is_constant(r) - - if ( - (l_column and not r_column) - or (r_const and not l_const) - or isinstance(r, exp.SubqueryPredicate) - ): - return expression - if ( - (r_column and not l_column) - or (l_const and not r_const) - or (gen(l) > gen(r)) - ): - return self.INVERSE_COMPARISONS.get( - expression.__class__, expression.__class__ - )(this=r, expression=l) - return expression - - def _flat_simplify(self, expression, simplifier, root=True): - if root or not expression.same_parent: - operands = [] - queue = deque(expression.flatten(unnest=False)) - size = len(queue) - - while queue: - a = queue.popleft() - - for b in queue: - result = simplifier(expression, a, b) - - if result and result is not expression: - queue.remove(b) - queue.appendleft(result) - break - else: - operands.append(a) - - if len(operands) < size: - return functools.reduce( - lambda a, b: expression.__class__(this=a, expression=b), operands - ) - return expression - - -def gen(expression: t.Any, comments: bool = False) -> str: - """Simple pseudo sql generator for quickly generating sortable and uniq strings. - - Sorting and deduping sql is a necessary step for optimization. Calling the actual - generator is expensive so we have a bare minimum sql generator here. - - Args: - expression: the expression to convert into a SQL string. - comments: whether to include the expression's comments. - """ - return Gen().gen(expression, comments=comments) - - -class Gen: - def __init__(self): - self.stack = [] - self.sqls = [] - - def gen(self, expression: exp.Expression, comments: bool = False) -> str: - self.stack = [expression] - self.sqls.clear() - - while self.stack: - node = self.stack.pop() - - if isinstance(node, exp.Expression): - if comments and node.comments: - self.stack.append(f" /*{','.join(node.comments)}*/") - - exp_handler_name = f"{node.key}_sql" - - if hasattr(self, exp_handler_name): - getattr(self, exp_handler_name)(node) - elif isinstance(node, exp.Func): - self._function(node) - else: - key = node.key.upper() - self.stack.append(f"{key} " if self._args(node) else key) - elif type(node) is list: - for n in reversed(node): - if n is not None: - self.stack.extend((n, ",")) - if node: - self.stack.pop() - else: - if node is not None: - self.sqls.append(str(node)) - - return "".join(self.sqls) - - def add_sql(self, e: exp.Add) -> None: - self._binary(e, " + ") - - def alias_sql(self, e: exp.Alias) -> None: - self.stack.extend( - ( - e.args.get("alias"), - " AS ", - e.args.get("this"), - ) - ) - - def and_sql(self, e: exp.And) -> None: - self._binary(e, " AND ") - - def anonymous_sql(self, e: exp.Anonymous) -> None: - this = e.this - if isinstance(this, str): - name = this.upper() - elif isinstance(this, exp.Identifier): - name = this.this - name = f'"{name}"' if this.quoted else name.upper() - else: - raise ValueError( - f"Anonymous.this expects a str or an Identifier, got '{this.__class__.__name__}'." - ) - - self.stack.extend( - ( - ")", - e.expressions, - "(", - name, - ) - ) - - def between_sql(self, e: exp.Between) -> None: - self.stack.extend( - ( - e.args.get("high"), - " AND ", - e.args.get("low"), - " BETWEEN ", - e.this, - ) - ) - - def boolean_sql(self, e: exp.Boolean) -> None: - self.stack.append("TRUE" if e.this else "FALSE") - - def bracket_sql(self, e: exp.Bracket) -> None: - self.stack.extend( - ( - "]", - e.expressions, - "[", - e.this, - ) - ) - - def column_sql(self, e: exp.Column) -> None: - for p in reversed(e.parts): - self.stack.extend((p, ".")) - self.stack.pop() - - def datatype_sql(self, e: exp.DataType) -> None: - self._args(e, 1) - self.stack.append(f"{e.this.name} ") - - def div_sql(self, e: exp.Div) -> None: - self._binary(e, " / ") - - def dot_sql(self, e: exp.Dot) -> None: - self._binary(e, ".") - - def eq_sql(self, e: exp.EQ) -> None: - self._binary(e, " = ") - - def from_sql(self, e: exp.From) -> None: - self.stack.extend((e.this, "FROM ")) - - def gt_sql(self, e: exp.GT) -> None: - self._binary(e, " > ") - - def gte_sql(self, e: exp.GTE) -> None: - self._binary(e, " >= ") - - def identifier_sql(self, e: exp.Identifier) -> None: - self.stack.append(f'"{e.this}"' if e.quoted else e.this) - - def ilike_sql(self, e: exp.ILike) -> None: - self._binary(e, " ILIKE ") - - def in_sql(self, e: exp.In) -> None: - self.stack.append(")") - self._args(e, 1) - self.stack.extend( - ( - "(", - " IN ", - e.this, - ) - ) - - def intdiv_sql(self, e: exp.IntDiv) -> None: - self._binary(e, " DIV ") - - def is_sql(self, e: exp.Is) -> None: - self._binary(e, " IS ") - - def like_sql(self, e: exp.Like) -> None: - self._binary(e, " Like ") - - def literal_sql(self, e: exp.Literal) -> None: - self.stack.append(f"'{e.this}'" if e.is_string else e.this) - - def lt_sql(self, e: exp.LT) -> None: - self._binary(e, " < ") - - def lte_sql(self, e: exp.LTE) -> None: - self._binary(e, " <= ") - - def mod_sql(self, e: exp.Mod) -> None: - self._binary(e, " % ") - - def mul_sql(self, e: exp.Mul) -> None: - self._binary(e, " * ") - - def neg_sql(self, e: exp.Neg) -> None: - self._unary(e, "-") - - def neq_sql(self, e: exp.NEQ) -> None: - self._binary(e, " <> ") - - def not_sql(self, e: exp.Not) -> None: - self._unary(e, "NOT ") - - def null_sql(self, e: exp.Null) -> None: - self.stack.append("NULL") - - def or_sql(self, e: exp.Or) -> None: - self._binary(e, " OR ") - - def paren_sql(self, e: exp.Paren) -> None: - self.stack.extend( - ( - ")", - e.this, - "(", - ) - ) - - def sub_sql(self, e: exp.Sub) -> None: - self._binary(e, " - ") - - def subquery_sql(self, e: exp.Subquery) -> None: - self._args(e, 2) - alias = e.args.get("alias") - if alias: - self.stack.append(alias) - self.stack.extend((")", e.this, "(")) - - def table_sql(self, e: exp.Table) -> None: - self._args(e, 4) - alias = e.args.get("alias") - if alias: - self.stack.append(alias) - for p in reversed(e.parts): - self.stack.extend((p, ".")) - self.stack.pop() - - def tablealias_sql(self, e: exp.TableAlias) -> None: - columns = e.columns - - if columns: - self.stack.extend((")", columns, "(")) - - self.stack.extend((e.this, " AS ")) - - def var_sql(self, e: exp.Var) -> None: - self.stack.append(e.this) - - def _binary(self, e: exp.Binary, op: str) -> None: - self.stack.extend((e.expression, op, e.this)) - - def _unary(self, e: exp.Unary, op: str) -> None: - self.stack.extend((e.this, op)) - - def _function(self, e: exp.Func) -> None: - self.stack.extend( - ( - ")", - list(e.args.values()), - "(", - e.sql_name(), - ) - ) - - def _args(self, node: exp.Expression, arg_index: int = 0) -> bool: - kvs = [] - arg_types = list(node.arg_types)[arg_index:] if arg_index else node.arg_types - - for k in arg_types: - v = node.args.get(k) - - if v is not None: - kvs.append([f":{k}", v]) - if kvs: - self.stack.append(kvs) - return True - return False diff --git a/third_party/bigframes_vendored/sqlglot/optimizer/unnest_subqueries.py b/third_party/bigframes_vendored/sqlglot/optimizer/unnest_subqueries.py deleted file mode 100644 index 0e3431bfa9b..00000000000 --- a/third_party/bigframes_vendored/sqlglot/optimizer/unnest_subqueries.py +++ /dev/null @@ -1,331 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/optimizer/unnest_subqueries.py - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.helper import name_sequence -from bigframes_vendored.sqlglot.optimizer.scope import ( - ScopeType, - find_in_scope, - traverse_scope, -) - - -def unnest_subqueries(expression): - """ - Rewrite sqlglot AST to convert some predicates with subqueries into joins. - - Convert scalar subqueries into cross joins. - Convert correlated or vectorized subqueries into a group by so it is not a many to many left join. - - Example: - >>> import sqlglot - >>> expression = sqlglot.parse_one("SELECT * FROM x AS x WHERE (SELECT y.a AS a FROM y AS y WHERE x.a = y.a) = 1 ") - >>> unnest_subqueries(expression).sql() - 'SELECT * FROM x AS x LEFT JOIN (SELECT y.a AS a FROM y AS y WHERE TRUE GROUP BY y.a) AS _u_0 ON x.a = _u_0.a WHERE _u_0.a = 1' - - Args: - expression (sqlglot.Expression): expression to unnest - Returns: - sqlglot.Expression: unnested expression - """ - next_alias_name = name_sequence("_u_") - - for scope in traverse_scope(expression): - select = scope.expression - parent = select.parent_select - if not parent: - continue - if scope.external_columns: - decorrelate(select, parent, scope.external_columns, next_alias_name) - elif scope.scope_type == ScopeType.SUBQUERY: - unnest(select, parent, next_alias_name) - - return expression - - -def unnest(select, parent_select, next_alias_name): - if len(select.selects) > 1: - return - - predicate = select.find_ancestor(exp.Condition) - if ( - not predicate - or parent_select is not predicate.parent_select - or not parent_select.args.get("from_") - ): - return - - if isinstance(select, exp.SetOperation): - select = exp.select(*select.selects).from_(select.subquery(next_alias_name())) - - alias = next_alias_name() - clause = predicate.find_ancestor(exp.Having, exp.Where, exp.Join) - - # This subquery returns a scalar and can just be converted to a cross join - if not isinstance(predicate, (exp.In, exp.Any)): - column = exp.column(select.selects[0].alias_or_name, alias) - - clause_parent_select = clause.parent_select if clause else None - - if ( - isinstance(clause, exp.Having) and clause_parent_select is parent_select - ) or ( - (not clause or clause_parent_select is not parent_select) - and ( - parent_select.args.get("group") - or any( - find_in_scope(select, exp.AggFunc) - for select in parent_select.selects - ) - ) - ): - column = exp.Max(this=column) - elif not isinstance(select.parent, exp.Subquery): - return - - join_type = "CROSS" - on_clause = None - if isinstance(predicate, exp.Exists): - # If a subquery returns no rows, cross-joining against it incorrectly eliminates all rows - # from the parent query. Therefore, we use a LEFT JOIN that always matches (ON TRUE), then - # check for non-NULL column values to determine whether the subquery contained rows. - column = column.is_(exp.null()).not_() - join_type = "LEFT" - on_clause = exp.true() - - _replace(select.parent, column) - parent_select.join( - select, on=on_clause, join_type=join_type, join_alias=alias, copy=False - ) - return - - if select.find(exp.Limit, exp.Offset): - return - - if isinstance(predicate, exp.Any): - predicate = predicate.find_ancestor(exp.EQ) - - if not predicate or parent_select is not predicate.parent_select: - return - - column = _other_operand(predicate) - value = select.selects[0] - - join_key = exp.column(value.alias, alias) - join_key_not_null = join_key.is_(exp.null()).not_() - - if isinstance(clause, exp.Join): - _replace(predicate, exp.true()) - parent_select.where(join_key_not_null, copy=False) - else: - _replace(predicate, join_key_not_null) - - group = select.args.get("group") - - if group: - if {value.this} != set(group.expressions): - select = ( - exp.select(exp.alias_(exp.column(value.alias, "_q"), value.alias)) - .from_(select.subquery("_q", copy=False), copy=False) - .group_by(exp.column(value.alias, "_q"), copy=False) - ) - elif not find_in_scope(value.this, exp.AggFunc): - select = select.group_by(value.this, copy=False) - - parent_select.join( - select, - on=column.eq(join_key), - join_type="LEFT", - join_alias=alias, - copy=False, - ) - - -def decorrelate(select, parent_select, external_columns, next_alias_name): - where = select.args.get("where") - - if not where or where.find(exp.Or) or select.find(exp.Limit, exp.Offset): - return - - table_alias = next_alias_name() - keys = [] - - # for all external columns in the where statement, find the relevant predicate - # keys to convert it into a join - for column in external_columns: - if column.find_ancestor(exp.Where) is not where: - return - - predicate = column.find_ancestor(exp.Predicate) - - if not predicate or predicate.find_ancestor(exp.Where) is not where: - return - - if isinstance(predicate, exp.Binary): - key = ( - predicate.right - if any(node is column for node in predicate.left.walk()) - else predicate.left - ) - else: - return - - keys.append((key, column, predicate)) - - if not any(isinstance(predicate, exp.EQ) for *_, predicate in keys): - return - - is_subquery_projection = any( - node is select.parent - for node in map(lambda s: s.unalias(), parent_select.selects) - if isinstance(node, exp.Subquery) - ) - - value = select.selects[0] - key_aliases = {} - group_by = [] - - for key, _, predicate in keys: - # if we filter on the value of the subquery, it needs to be unique - if key == value.this: - key_aliases[key] = value.alias - group_by.append(key) - else: - if key not in key_aliases: - key_aliases[key] = next_alias_name() - # all predicates that are equalities must also be in the unique - # so that we don't do a many to many join - if isinstance(predicate, exp.EQ) and key not in group_by: - group_by.append(key) - - parent_predicate = select.find_ancestor(exp.Predicate) - - # if the value of the subquery is not an agg or a key, we need to collect it into an array - # so that it can be grouped. For subquery projections, we use a MAX aggregation instead. - agg_func = exp.Max if is_subquery_projection else exp.ArrayAgg - if not value.find(exp.AggFunc) and value.this not in group_by: - select.select( - exp.alias_(agg_func(this=value.this), value.alias, quoted=False), - append=False, - copy=False, - ) - - # exists queries should not have any selects as it only checks if there are any rows - # all selects will be added by the optimizer and only used for join keys - if isinstance(parent_predicate, exp.Exists): - select.set("expressions", []) - - for key, alias in key_aliases.items(): - if key in group_by: - # add all keys to the projections of the subquery - # so that we can use it as a join key - if isinstance(parent_predicate, exp.Exists) or key != value.this: - select.select(f"{key} AS {alias}", copy=False) - else: - select.select( - exp.alias_(agg_func(this=key.copy()), alias, quoted=False), copy=False - ) - - alias = exp.column(value.alias, table_alias) - other = _other_operand(parent_predicate) - op_type = type(parent_predicate.parent) if parent_predicate else None - - if isinstance(parent_predicate, exp.Exists): - alias = exp.column(list(key_aliases.values())[0], table_alias) - parent_predicate = _replace(parent_predicate, f"NOT {alias} IS NULL") - elif isinstance(parent_predicate, exp.All): - assert issubclass(op_type, exp.Binary) - predicate = op_type(this=other, expression=exp.column("_x")) - parent_predicate = _replace( - parent_predicate.parent, f"ARRAY_ALL({alias}, _x -> {predicate})" - ) - elif isinstance(parent_predicate, exp.Any): - assert issubclass(op_type, exp.Binary) - if value.this in group_by: - predicate = op_type(this=other, expression=alias) - parent_predicate = _replace(parent_predicate.parent, predicate) - else: - predicate = op_type(this=other, expression=exp.column("_x")) - parent_predicate = _replace( - parent_predicate, f"ARRAY_ANY({alias}, _x -> {predicate})" - ) - elif isinstance(parent_predicate, exp.In): - if value.this in group_by: - parent_predicate = _replace(parent_predicate, f"{other} = {alias}") - else: - parent_predicate = _replace( - parent_predicate, - f"ARRAY_ANY({alias}, _x -> _x = {parent_predicate.this})", - ) - else: - if is_subquery_projection and select.parent.alias: - alias = exp.alias_(alias, select.parent.alias) - - # COUNT always returns 0 on empty datasets, so we need take that into consideration here - # by transforming all counts into 0 and using that as the coalesced value - if value.find(exp.Count): - - def remove_aggs(node): - if isinstance(node, exp.Count): - return exp.Literal.number(0) - elif isinstance(node, exp.AggFunc): - return exp.null() - return node - - alias = exp.Coalesce( - this=alias, expressions=[value.this.transform(remove_aggs)] - ) - - select.parent.replace(alias) - - for key, column, predicate in keys: - predicate.replace(exp.true()) - nested = exp.column(key_aliases[key], table_alias) - - if is_subquery_projection: - key.replace(nested) - if not isinstance(predicate, exp.EQ): - parent_select.where(predicate, copy=False) - continue - - if key in group_by: - key.replace(nested) - elif isinstance(predicate, exp.EQ): - parent_predicate = _replace( - parent_predicate, - f"({parent_predicate} AND ARRAY_CONTAINS({nested}, {column}))", - ) - else: - key.replace(exp.to_identifier("_x")) - parent_predicate = _replace( - parent_predicate, - f"({parent_predicate} AND ARRAY_ANY({nested}, _x -> {predicate}))", - ) - - parent_select.join( - select.group_by(*group_by, copy=False), - on=[predicate for *_, predicate in keys if isinstance(predicate, exp.EQ)], - join_type="LEFT", - join_alias=table_alias, - copy=False, - ) - - -def _replace(expression, condition): - return expression.replace(exp.condition(condition)) - - -def _other_operand(expression): - if isinstance(expression, exp.In): - return expression.this - - if isinstance(expression, (exp.Any, exp.All)): - return _other_operand(expression.parent) - - if isinstance(expression, exp.Binary): - return ( - expression.right - if isinstance(expression.left, (exp.Subquery, exp.Any, exp.Exists, exp.All)) - else expression.left - ) - - return None diff --git a/third_party/bigframes_vendored/sqlglot/parser.py b/third_party/bigframes_vendored/sqlglot/parser.py deleted file mode 100644 index 706649f43fb..00000000000 --- a/third_party/bigframes_vendored/sqlglot/parser.py +++ /dev/null @@ -1,9719 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/parser.py - -from __future__ import annotations - -import itertools -import logging -import re -import typing as t -from collections import defaultdict - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.errors import ( - ErrorLevel, - ParseError, - TokenError, - concat_messages, - highlight_sql, - merge_errors, -) -from bigframes_vendored.sqlglot.helper import apply_index_offset, ensure_list, seq_get -from bigframes_vendored.sqlglot.time import format_time -from bigframes_vendored.sqlglot.tokens import Token, Tokenizer, TokenType -from bigframes_vendored.sqlglot.trie import TrieResult, in_trie, new_trie - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E, Lit - from bigframes_vendored.sqlglot.dialects.dialect import Dialect, DialectType - - T = t.TypeVar("T") - TCeilFloor = t.TypeVar("TCeilFloor", exp.Ceil, exp.Floor) - -logger = logging.getLogger("sqlglot") - -OPTIONS_TYPE = t.Dict[str, t.Sequence[t.Union[t.Sequence[str], str]]] - -# Used to detect alphabetical characters and +/- in timestamp literals -TIME_ZONE_RE: t.Pattern[str] = re.compile(r":.*?[a-zA-Z\+\-]") - - -def build_var_map(args: t.List) -> exp.StarMap | exp.VarMap: - if len(args) == 1 and args[0].is_star: - return exp.StarMap(this=args[0]) - - keys = [] - values = [] - for i in range(0, len(args), 2): - keys.append(args[i]) - values.append(args[i + 1]) - - return exp.VarMap( - keys=exp.array(*keys, copy=False), values=exp.array(*values, copy=False) - ) - - -def build_like(args: t.List) -> exp.Escape | exp.Like: - like = exp.Like(this=seq_get(args, 1), expression=seq_get(args, 0)) - return exp.Escape(this=like, expression=seq_get(args, 2)) if len(args) > 2 else like - - -def binary_range_parser( - expr_type: t.Type[exp.Expression], reverse_args: bool = False -) -> t.Callable[[Parser, t.Optional[exp.Expression]], t.Optional[exp.Expression]]: - def _parse_binary_range( - self: Parser, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - expression = self._parse_bitwise() - if reverse_args: - this, expression = expression, this - return self._parse_escape( - self.expression(expr_type, this=this, expression=expression) - ) - - return _parse_binary_range - - -def build_logarithm(args: t.List, dialect: Dialect) -> exp.Func: - # Default argument order is base, expression - this = seq_get(args, 0) - expression = seq_get(args, 1) - - if expression: - if not dialect.LOG_BASE_FIRST: - this, expression = expression, this - return exp.Log(this=this, expression=expression) - - return (exp.Ln if dialect.parser_class.LOG_DEFAULTS_TO_LN else exp.Log)(this=this) - - -def build_hex(args: t.List, dialect: Dialect) -> exp.Hex | exp.LowerHex: - arg = seq_get(args, 0) - return exp.LowerHex(this=arg) if dialect.HEX_LOWERCASE else exp.Hex(this=arg) - - -def build_lower(args: t.List) -> exp.Lower | exp.Hex: - # LOWER(HEX(..)) can be simplified to LowerHex to simplify its transpilation - arg = seq_get(args, 0) - return ( - exp.LowerHex(this=arg.this) if isinstance(arg, exp.Hex) else exp.Lower(this=arg) - ) - - -def build_upper(args: t.List) -> exp.Upper | exp.Hex: - # UPPER(HEX(..)) can be simplified to Hex to simplify its transpilation - arg = seq_get(args, 0) - return exp.Hex(this=arg.this) if isinstance(arg, exp.Hex) else exp.Upper(this=arg) - - -def build_extract_json_with_path( - expr_type: t.Type[E], -) -> t.Callable[[t.List, Dialect], E]: - def _builder(args: t.List, dialect: Dialect) -> E: - expression = expr_type( - this=seq_get(args, 0), expression=dialect.to_json_path(seq_get(args, 1)) - ) - if len(args) > 2 and expr_type is exp.JSONExtract: - expression.set("expressions", args[2:]) - if expr_type is exp.JSONExtractScalar: - expression.set("scalar_only", dialect.JSON_EXTRACT_SCALAR_SCALAR_ONLY) - - return expression - - return _builder - - -def build_mod(args: t.List) -> exp.Mod: - this = seq_get(args, 0) - expression = seq_get(args, 1) - - # Wrap the operands if they are binary nodes, e.g. MOD(a + 1, 7) -> (a + 1) % 7 - this = exp.Paren(this=this) if isinstance(this, exp.Binary) else this - expression = ( - exp.Paren(this=expression) if isinstance(expression, exp.Binary) else expression - ) - - return exp.Mod(this=this, expression=expression) - - -def build_pad(args: t.List, is_left: bool = True): - return exp.Pad( - this=seq_get(args, 0), - expression=seq_get(args, 1), - fill_pattern=seq_get(args, 2), - is_left=is_left, - ) - - -def build_array_constructor( - exp_class: t.Type[E], args: t.List, bracket_kind: TokenType, dialect: Dialect -) -> exp.Expression: - array_exp = exp_class(expressions=args) - - if exp_class == exp.Array and dialect.HAS_DISTINCT_ARRAY_CONSTRUCTORS: - array_exp.set("bracket_notation", bracket_kind == TokenType.L_BRACKET) - - return array_exp - - -def build_convert_timezone( - args: t.List, default_source_tz: t.Optional[str] = None -) -> t.Union[exp.ConvertTimezone, exp.Anonymous]: - if len(args) == 2: - source_tz = exp.Literal.string(default_source_tz) if default_source_tz else None - return exp.ConvertTimezone( - source_tz=source_tz, target_tz=seq_get(args, 0), timestamp=seq_get(args, 1) - ) - - return exp.ConvertTimezone.from_arg_list(args) - - -def build_trim(args: t.List, is_left: bool = True): - return exp.Trim( - this=seq_get(args, 0), - expression=seq_get(args, 1), - position="LEADING" if is_left else "TRAILING", - ) - - -def build_coalesce( - args: t.List, is_nvl: t.Optional[bool] = None, is_null: t.Optional[bool] = None -) -> exp.Coalesce: - return exp.Coalesce( - this=seq_get(args, 0), expressions=args[1:], is_nvl=is_nvl, is_null=is_null - ) - - -def build_locate_strposition(args: t.List): - return exp.StrPosition( - this=seq_get(args, 1), - substr=seq_get(args, 0), - position=seq_get(args, 2), - ) - - -class _Parser(type): - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - - klass.SHOW_TRIE = new_trie(key.split(" ") for key in klass.SHOW_PARSERS) - klass.SET_TRIE = new_trie(key.split(" ") for key in klass.SET_PARSERS) - - return klass - - -class Parser(metaclass=_Parser): - """ - Parser consumes a list of tokens produced by the Tokenizer and produces a parsed syntax tree. - - Args: - error_level: The desired error level. - Default: ErrorLevel.IMMEDIATE - error_message_context: The amount of context to capture from a query string when displaying - the error message (in number of characters). - Default: 100 - max_errors: Maximum number of error messages to include in a raised ParseError. - This is only relevant if error_level is ErrorLevel.RAISE. - Default: 3 - """ - - FUNCTIONS: t.Dict[str, t.Callable] = { - **{name: func.from_arg_list for name, func in exp.FUNCTION_BY_NAME.items()}, - **dict.fromkeys(("COALESCE", "IFNULL", "NVL"), build_coalesce), - "ARRAY": lambda args, dialect: exp.Array(expressions=args), - "ARRAYAGG": lambda args, dialect: exp.ArrayAgg( - this=seq_get(args, 0), - nulls_excluded=dialect.ARRAY_AGG_INCLUDES_NULLS is None or None, - ), - "ARRAY_AGG": lambda args, dialect: exp.ArrayAgg( - this=seq_get(args, 0), - nulls_excluded=dialect.ARRAY_AGG_INCLUDES_NULLS is None or None, - ), - "CHAR": lambda args: exp.Chr(expressions=args), - "CHR": lambda args: exp.Chr(expressions=args), - "COUNT": lambda args: exp.Count( - this=seq_get(args, 0), expressions=args[1:], big_int=True - ), - "CONCAT": lambda args, dialect: exp.Concat( - expressions=args, - safe=not dialect.STRICT_STRING_CONCAT, - coalesce=dialect.CONCAT_COALESCE, - ), - "CONCAT_WS": lambda args, dialect: exp.ConcatWs( - expressions=args, - safe=not dialect.STRICT_STRING_CONCAT, - coalesce=dialect.CONCAT_COALESCE, - ), - "CONVERT_TIMEZONE": build_convert_timezone, - "DATE_TO_DATE_STR": lambda args: exp.Cast( - this=seq_get(args, 0), - to=exp.DataType(this=exp.DataType.Type.TEXT), - ), - "GENERATE_DATE_ARRAY": lambda args: exp.GenerateDateArray( - start=seq_get(args, 0), - end=seq_get(args, 1), - step=seq_get(args, 2) - or exp.Interval(this=exp.Literal.string(1), unit=exp.var("DAY")), - ), - "GENERATE_UUID": lambda args, dialect: exp.Uuid( - is_string=dialect.UUID_IS_STRING_TYPE or None - ), - "GLOB": lambda args: exp.Glob( - this=seq_get(args, 1), expression=seq_get(args, 0) - ), - "GREATEST": lambda args, dialect: exp.Greatest( - this=seq_get(args, 0), - expressions=args[1:], - ignore_nulls=dialect.LEAST_GREATEST_IGNORES_NULLS, - ), - "LEAST": lambda args, dialect: exp.Least( - this=seq_get(args, 0), - expressions=args[1:], - ignore_nulls=dialect.LEAST_GREATEST_IGNORES_NULLS, - ), - "HEX": build_hex, - "JSON_EXTRACT": build_extract_json_with_path(exp.JSONExtract), - "JSON_EXTRACT_SCALAR": build_extract_json_with_path(exp.JSONExtractScalar), - "JSON_EXTRACT_PATH_TEXT": build_extract_json_with_path(exp.JSONExtractScalar), - "LIKE": build_like, - "LOG": build_logarithm, - "LOG2": lambda args: exp.Log( - this=exp.Literal.number(2), expression=seq_get(args, 0) - ), - "LOG10": lambda args: exp.Log( - this=exp.Literal.number(10), expression=seq_get(args, 0) - ), - "LOWER": build_lower, - "LPAD": lambda args: build_pad(args), - "LEFTPAD": lambda args: build_pad(args), - "LTRIM": lambda args: build_trim(args), - "MOD": build_mod, - "RIGHTPAD": lambda args: build_pad(args, is_left=False), - "RPAD": lambda args: build_pad(args, is_left=False), - "RTRIM": lambda args: build_trim(args, is_left=False), - "SCOPE_RESOLUTION": lambda args: ( - exp.ScopeResolution(expression=seq_get(args, 0)) - if len(args) != 2 - else exp.ScopeResolution(this=seq_get(args, 0), expression=seq_get(args, 1)) - ), - "STRPOS": exp.StrPosition.from_arg_list, - "CHARINDEX": lambda args: build_locate_strposition(args), - "INSTR": exp.StrPosition.from_arg_list, - "LOCATE": lambda args: build_locate_strposition(args), - "TIME_TO_TIME_STR": lambda args: exp.Cast( - this=seq_get(args, 0), - to=exp.DataType(this=exp.DataType.Type.TEXT), - ), - "TO_HEX": build_hex, - "TS_OR_DS_TO_DATE_STR": lambda args: exp.Substring( - this=exp.Cast( - this=seq_get(args, 0), - to=exp.DataType(this=exp.DataType.Type.TEXT), - ), - start=exp.Literal.number(1), - length=exp.Literal.number(10), - ), - "UNNEST": lambda args: exp.Unnest(expressions=ensure_list(seq_get(args, 0))), - "UPPER": build_upper, - "UUID": lambda args, dialect: exp.Uuid( - is_string=dialect.UUID_IS_STRING_TYPE or None - ), - "VAR_MAP": build_var_map, - } - - NO_PAREN_FUNCTIONS = { - TokenType.CURRENT_DATE: exp.CurrentDate, - TokenType.CURRENT_DATETIME: exp.CurrentDate, - TokenType.CURRENT_TIME: exp.CurrentTime, - TokenType.CURRENT_TIMESTAMP: exp.CurrentTimestamp, - TokenType.CURRENT_USER: exp.CurrentUser, - TokenType.LOCALTIME: exp.Localtime, - TokenType.LOCALTIMESTAMP: exp.Localtimestamp, - TokenType.CURRENT_ROLE: exp.CurrentRole, - } - - STRUCT_TYPE_TOKENS = { - TokenType.FILE, - TokenType.NESTED, - TokenType.OBJECT, - TokenType.STRUCT, - TokenType.UNION, - } - - NESTED_TYPE_TOKENS = { - TokenType.ARRAY, - TokenType.LIST, - TokenType.LOWCARDINALITY, - TokenType.MAP, - TokenType.NULLABLE, - TokenType.RANGE, - *STRUCT_TYPE_TOKENS, - } - - ENUM_TYPE_TOKENS = { - TokenType.DYNAMIC, - TokenType.ENUM, - TokenType.ENUM8, - TokenType.ENUM16, - } - - AGGREGATE_TYPE_TOKENS = { - TokenType.AGGREGATEFUNCTION, - TokenType.SIMPLEAGGREGATEFUNCTION, - } - - TYPE_TOKENS = { - TokenType.BIT, - TokenType.BOOLEAN, - TokenType.TINYINT, - TokenType.UTINYINT, - TokenType.SMALLINT, - TokenType.USMALLINT, - TokenType.INT, - TokenType.UINT, - TokenType.BIGINT, - TokenType.UBIGINT, - TokenType.BIGNUM, - TokenType.INT128, - TokenType.UINT128, - TokenType.INT256, - TokenType.UINT256, - TokenType.MEDIUMINT, - TokenType.UMEDIUMINT, - TokenType.FIXEDSTRING, - TokenType.FLOAT, - TokenType.DOUBLE, - TokenType.UDOUBLE, - TokenType.CHAR, - TokenType.NCHAR, - TokenType.VARCHAR, - TokenType.NVARCHAR, - TokenType.BPCHAR, - TokenType.TEXT, - TokenType.MEDIUMTEXT, - TokenType.LONGTEXT, - TokenType.BLOB, - TokenType.MEDIUMBLOB, - TokenType.LONGBLOB, - TokenType.BINARY, - TokenType.VARBINARY, - TokenType.JSON, - TokenType.JSONB, - TokenType.INTERVAL, - TokenType.TINYBLOB, - TokenType.TINYTEXT, - TokenType.TIME, - TokenType.TIMETZ, - TokenType.TIME_NS, - TokenType.TIMESTAMP, - TokenType.TIMESTAMP_S, - TokenType.TIMESTAMP_MS, - TokenType.TIMESTAMP_NS, - TokenType.TIMESTAMPTZ, - TokenType.TIMESTAMPLTZ, - TokenType.TIMESTAMPNTZ, - TokenType.DATETIME, - TokenType.DATETIME2, - TokenType.DATETIME64, - TokenType.SMALLDATETIME, - TokenType.DATE, - TokenType.DATE32, - TokenType.INT4RANGE, - TokenType.INT4MULTIRANGE, - TokenType.INT8RANGE, - TokenType.INT8MULTIRANGE, - TokenType.NUMRANGE, - TokenType.NUMMULTIRANGE, - TokenType.TSRANGE, - TokenType.TSMULTIRANGE, - TokenType.TSTZRANGE, - TokenType.TSTZMULTIRANGE, - TokenType.DATERANGE, - TokenType.DATEMULTIRANGE, - TokenType.DECIMAL, - TokenType.DECIMAL32, - TokenType.DECIMAL64, - TokenType.DECIMAL128, - TokenType.DECIMAL256, - TokenType.DECFLOAT, - TokenType.UDECIMAL, - TokenType.BIGDECIMAL, - TokenType.UUID, - TokenType.GEOGRAPHY, - TokenType.GEOGRAPHYPOINT, - TokenType.GEOMETRY, - TokenType.POINT, - TokenType.RING, - TokenType.LINESTRING, - TokenType.MULTILINESTRING, - TokenType.POLYGON, - TokenType.MULTIPOLYGON, - TokenType.HLLSKETCH, - TokenType.HSTORE, - TokenType.PSEUDO_TYPE, - TokenType.SUPER, - TokenType.SERIAL, - TokenType.SMALLSERIAL, - TokenType.BIGSERIAL, - TokenType.XML, - TokenType.YEAR, - TokenType.USERDEFINED, - TokenType.MONEY, - TokenType.SMALLMONEY, - TokenType.ROWVERSION, - TokenType.IMAGE, - TokenType.VARIANT, - TokenType.VECTOR, - TokenType.VOID, - TokenType.OBJECT, - TokenType.OBJECT_IDENTIFIER, - TokenType.INET, - TokenType.IPADDRESS, - TokenType.IPPREFIX, - TokenType.IPV4, - TokenType.IPV6, - TokenType.UNKNOWN, - TokenType.NOTHING, - TokenType.NULL, - TokenType.NAME, - TokenType.TDIGEST, - TokenType.DYNAMIC, - *ENUM_TYPE_TOKENS, - *NESTED_TYPE_TOKENS, - *AGGREGATE_TYPE_TOKENS, - } - - SIGNED_TO_UNSIGNED_TYPE_TOKEN = { - TokenType.BIGINT: TokenType.UBIGINT, - TokenType.INT: TokenType.UINT, - TokenType.MEDIUMINT: TokenType.UMEDIUMINT, - TokenType.SMALLINT: TokenType.USMALLINT, - TokenType.TINYINT: TokenType.UTINYINT, - TokenType.DECIMAL: TokenType.UDECIMAL, - TokenType.DOUBLE: TokenType.UDOUBLE, - } - - SUBQUERY_PREDICATES = { - TokenType.ANY: exp.Any, - TokenType.ALL: exp.All, - TokenType.EXISTS: exp.Exists, - TokenType.SOME: exp.Any, - } - - RESERVED_TOKENS = { - *Tokenizer.SINGLE_TOKENS.values(), - TokenType.SELECT, - } - {TokenType.IDENTIFIER} - - DB_CREATABLES = { - TokenType.DATABASE, - TokenType.DICTIONARY, - TokenType.FILE_FORMAT, - TokenType.MODEL, - TokenType.NAMESPACE, - TokenType.SCHEMA, - TokenType.SEMANTIC_VIEW, - TokenType.SEQUENCE, - TokenType.SINK, - TokenType.SOURCE, - TokenType.STAGE, - TokenType.STORAGE_INTEGRATION, - TokenType.STREAMLIT, - TokenType.TABLE, - TokenType.TAG, - TokenType.VIEW, - TokenType.WAREHOUSE, - } - - CREATABLES = { - TokenType.COLUMN, - TokenType.CONSTRAINT, - TokenType.FOREIGN_KEY, - TokenType.FUNCTION, - TokenType.INDEX, - TokenType.PROCEDURE, - *DB_CREATABLES, - } - - ALTERABLES = { - TokenType.INDEX, - TokenType.TABLE, - TokenType.VIEW, - TokenType.SESSION, - } - - # Tokens that can represent identifiers - ID_VAR_TOKENS = { - TokenType.ALL, - TokenType.ANALYZE, - TokenType.ATTACH, - TokenType.VAR, - TokenType.ANTI, - TokenType.APPLY, - TokenType.ASC, - TokenType.ASOF, - TokenType.AUTO_INCREMENT, - TokenType.BEGIN, - TokenType.BPCHAR, - TokenType.CACHE, - TokenType.CASE, - TokenType.COLLATE, - TokenType.COMMAND, - TokenType.COMMENT, - TokenType.COMMIT, - TokenType.CONSTRAINT, - TokenType.COPY, - TokenType.CUBE, - TokenType.CURRENT_SCHEMA, - TokenType.DEFAULT, - TokenType.DELETE, - TokenType.DESC, - TokenType.DESCRIBE, - TokenType.DETACH, - TokenType.DICTIONARY, - TokenType.DIV, - TokenType.END, - TokenType.EXECUTE, - TokenType.EXPORT, - TokenType.ESCAPE, - TokenType.FALSE, - TokenType.FIRST, - TokenType.FILTER, - TokenType.FINAL, - TokenType.FORMAT, - TokenType.FULL, - TokenType.GET, - TokenType.IDENTIFIER, - TokenType.IS, - TokenType.ISNULL, - TokenType.INTERVAL, - TokenType.KEEP, - TokenType.KILL, - TokenType.LEFT, - TokenType.LIMIT, - TokenType.LOAD, - TokenType.LOCK, - TokenType.MATCH, - TokenType.MERGE, - TokenType.NATURAL, - TokenType.NEXT, - TokenType.OFFSET, - TokenType.OPERATOR, - TokenType.ORDINALITY, - TokenType.OVER, - TokenType.OVERLAPS, - TokenType.OVERWRITE, - TokenType.PARTITION, - TokenType.PERCENT, - TokenType.PIVOT, - TokenType.PRAGMA, - TokenType.PUT, - TokenType.RANGE, - TokenType.RECURSIVE, - TokenType.REFERENCES, - TokenType.REFRESH, - TokenType.RENAME, - TokenType.REPLACE, - TokenType.RIGHT, - TokenType.ROLLUP, - TokenType.ROW, - TokenType.ROWS, - TokenType.SEMI, - TokenType.SET, - TokenType.SETTINGS, - TokenType.SHOW, - TokenType.TEMPORARY, - TokenType.TOP, - TokenType.TRUE, - TokenType.TRUNCATE, - TokenType.UNIQUE, - TokenType.UNNEST, - TokenType.UNPIVOT, - TokenType.UPDATE, - TokenType.USE, - TokenType.VOLATILE, - TokenType.WINDOW, - *ALTERABLES, - *CREATABLES, - *SUBQUERY_PREDICATES, - *TYPE_TOKENS, - *NO_PAREN_FUNCTIONS, - } - ID_VAR_TOKENS.remove(TokenType.UNION) - - TABLE_ALIAS_TOKENS = ID_VAR_TOKENS - { - TokenType.ANTI, - TokenType.ASOF, - TokenType.FULL, - TokenType.LEFT, - TokenType.LOCK, - TokenType.NATURAL, - TokenType.RIGHT, - TokenType.SEMI, - TokenType.WINDOW, - } - - ALIAS_TOKENS = ID_VAR_TOKENS - - COLON_PLACEHOLDER_TOKENS = ID_VAR_TOKENS - - ARRAY_CONSTRUCTORS = { - "ARRAY": exp.Array, - "LIST": exp.List, - } - - COMMENT_TABLE_ALIAS_TOKENS = TABLE_ALIAS_TOKENS - {TokenType.IS} - - UPDATE_ALIAS_TOKENS = TABLE_ALIAS_TOKENS - {TokenType.SET} - - TRIM_TYPES = {"LEADING", "TRAILING", "BOTH"} - - FUNC_TOKENS = { - TokenType.COLLATE, - TokenType.COMMAND, - TokenType.CURRENT_DATE, - TokenType.CURRENT_DATETIME, - TokenType.CURRENT_SCHEMA, - TokenType.CURRENT_TIMESTAMP, - TokenType.CURRENT_TIME, - TokenType.CURRENT_USER, - TokenType.CURRENT_CATALOG, - TokenType.FILTER, - TokenType.FIRST, - TokenType.FORMAT, - TokenType.GET, - TokenType.GLOB, - TokenType.IDENTIFIER, - TokenType.INDEX, - TokenType.ISNULL, - TokenType.ILIKE, - TokenType.INSERT, - TokenType.LIKE, - TokenType.LOCALTIME, - TokenType.LOCALTIMESTAMP, - TokenType.MERGE, - TokenType.NEXT, - TokenType.OFFSET, - TokenType.PRIMARY_KEY, - TokenType.RANGE, - TokenType.REPLACE, - TokenType.RLIKE, - TokenType.ROW, - TokenType.SESSION_USER, - TokenType.UNNEST, - TokenType.VAR, - TokenType.LEFT, - TokenType.RIGHT, - TokenType.SEQUENCE, - TokenType.DATE, - TokenType.DATETIME, - TokenType.TABLE, - TokenType.TIMESTAMP, - TokenType.TIMESTAMPTZ, - TokenType.TRUNCATE, - TokenType.UTC_DATE, - TokenType.UTC_TIME, - TokenType.UTC_TIMESTAMP, - TokenType.WINDOW, - TokenType.XOR, - *TYPE_TOKENS, - *SUBQUERY_PREDICATES, - } - - CONJUNCTION: t.Dict[TokenType, t.Type[exp.Expression]] = { - TokenType.AND: exp.And, - } - - ASSIGNMENT: t.Dict[TokenType, t.Type[exp.Expression]] = { - TokenType.COLON_EQ: exp.PropertyEQ, - } - - DISJUNCTION: t.Dict[TokenType, t.Type[exp.Expression]] = { - TokenType.OR: exp.Or, - } - - EQUALITY = { - TokenType.EQ: exp.EQ, - TokenType.NEQ: exp.NEQ, - TokenType.NULLSAFE_EQ: exp.NullSafeEQ, - } - - COMPARISON = { - TokenType.GT: exp.GT, - TokenType.GTE: exp.GTE, - TokenType.LT: exp.LT, - TokenType.LTE: exp.LTE, - } - - BITWISE = { - TokenType.AMP: exp.BitwiseAnd, - TokenType.CARET: exp.BitwiseXor, - TokenType.PIPE: exp.BitwiseOr, - } - - TERM = { - TokenType.DASH: exp.Sub, - TokenType.PLUS: exp.Add, - TokenType.MOD: exp.Mod, - TokenType.COLLATE: exp.Collate, - } - - FACTOR = { - TokenType.DIV: exp.IntDiv, - TokenType.LR_ARROW: exp.Distance, - TokenType.SLASH: exp.Div, - TokenType.STAR: exp.Mul, - } - - EXPONENT: t.Dict[TokenType, t.Type[exp.Expression]] = {} - - TIMES = { - TokenType.TIME, - TokenType.TIMETZ, - } - - TIMESTAMPS = { - TokenType.TIMESTAMP, - TokenType.TIMESTAMPNTZ, - TokenType.TIMESTAMPTZ, - TokenType.TIMESTAMPLTZ, - *TIMES, - } - - SET_OPERATIONS = { - TokenType.UNION, - TokenType.INTERSECT, - TokenType.EXCEPT, - } - - JOIN_METHODS = { - TokenType.ASOF, - TokenType.NATURAL, - TokenType.POSITIONAL, - } - - JOIN_SIDES = { - TokenType.LEFT, - TokenType.RIGHT, - TokenType.FULL, - } - - JOIN_KINDS = { - TokenType.ANTI, - TokenType.CROSS, - TokenType.INNER, - TokenType.OUTER, - TokenType.SEMI, - TokenType.STRAIGHT_JOIN, - } - - JOIN_HINTS: t.Set[str] = set() - - LAMBDAS = { - TokenType.ARROW: lambda self, expressions: self.expression( - exp.Lambda, - this=self._replace_lambda( - self._parse_disjunction(), - expressions, - ), - expressions=expressions, - ), - TokenType.FARROW: lambda self, expressions: self.expression( - exp.Kwarg, - this=exp.var(expressions[0].name), - expression=self._parse_disjunction(), - ), - } - - COLUMN_OPERATORS = { - TokenType.DOT: None, - TokenType.DOTCOLON: lambda self, this, to: self.expression( - exp.JSONCast, - this=this, - to=to, - ), - TokenType.DCOLON: lambda self, this, to: self.build_cast( - strict=self.STRICT_CAST, this=this, to=to - ), - TokenType.ARROW: lambda self, this, path: self.expression( - exp.JSONExtract, - this=this, - expression=self.dialect.to_json_path(path), - only_json_types=self.JSON_ARROWS_REQUIRE_JSON_TYPE, - ), - TokenType.DARROW: lambda self, this, path: self.expression( - exp.JSONExtractScalar, - this=this, - expression=self.dialect.to_json_path(path), - only_json_types=self.JSON_ARROWS_REQUIRE_JSON_TYPE, - scalar_only=self.dialect.JSON_EXTRACT_SCALAR_SCALAR_ONLY, - ), - TokenType.HASH_ARROW: lambda self, this, path: self.expression( - exp.JSONBExtract, - this=this, - expression=path, - ), - TokenType.DHASH_ARROW: lambda self, this, path: self.expression( - exp.JSONBExtractScalar, - this=this, - expression=path, - ), - TokenType.PLACEHOLDER: lambda self, this, key: self.expression( - exp.JSONBContains, - this=this, - expression=key, - ), - } - - CAST_COLUMN_OPERATORS = { - TokenType.DOTCOLON, - TokenType.DCOLON, - } - - EXPRESSION_PARSERS = { - exp.Cluster: lambda self: self._parse_sort(exp.Cluster, TokenType.CLUSTER_BY), - exp.Column: lambda self: self._parse_column(), - exp.ColumnDef: lambda self: self._parse_column_def(self._parse_column()), - exp.Condition: lambda self: self._parse_disjunction(), - exp.DataType: lambda self: self._parse_types( - allow_identifiers=False, schema=True - ), - exp.Expression: lambda self: self._parse_expression(), - exp.From: lambda self: self._parse_from(joins=True), - exp.GrantPrincipal: lambda self: self._parse_grant_principal(), - exp.GrantPrivilege: lambda self: self._parse_grant_privilege(), - exp.Group: lambda self: self._parse_group(), - exp.Having: lambda self: self._parse_having(), - exp.Hint: lambda self: self._parse_hint_body(), - exp.Identifier: lambda self: self._parse_id_var(), - exp.Join: lambda self: self._parse_join(), - exp.Lambda: lambda self: self._parse_lambda(), - exp.Lateral: lambda self: self._parse_lateral(), - exp.Limit: lambda self: self._parse_limit(), - exp.Offset: lambda self: self._parse_offset(), - exp.Order: lambda self: self._parse_order(), - exp.Ordered: lambda self: self._parse_ordered(), - exp.Properties: lambda self: self._parse_properties(), - exp.PartitionedByProperty: lambda self: self._parse_partitioned_by(), - exp.Qualify: lambda self: self._parse_qualify(), - exp.Returning: lambda self: self._parse_returning(), - exp.Select: lambda self: self._parse_select(), - exp.Sort: lambda self: self._parse_sort(exp.Sort, TokenType.SORT_BY), - exp.Table: lambda self: self._parse_table_parts(), - exp.TableAlias: lambda self: self._parse_table_alias(), - exp.Tuple: lambda self: self._parse_value(values=False), - exp.Whens: lambda self: self._parse_when_matched(), - exp.Where: lambda self: self._parse_where(), - exp.Window: lambda self: self._parse_named_window(), - exp.With: lambda self: self._parse_with(), - "JOIN_TYPE": lambda self: self._parse_join_parts(), - } - - STATEMENT_PARSERS = { - TokenType.ALTER: lambda self: self._parse_alter(), - TokenType.ANALYZE: lambda self: self._parse_analyze(), - TokenType.BEGIN: lambda self: self._parse_transaction(), - TokenType.CACHE: lambda self: self._parse_cache(), - TokenType.COMMENT: lambda self: self._parse_comment(), - TokenType.COMMIT: lambda self: self._parse_commit_or_rollback(), - TokenType.COPY: lambda self: self._parse_copy(), - TokenType.CREATE: lambda self: self._parse_create(), - TokenType.DELETE: lambda self: self._parse_delete(), - TokenType.DESC: lambda self: self._parse_describe(), - TokenType.DESCRIBE: lambda self: self._parse_describe(), - TokenType.DROP: lambda self: self._parse_drop(), - TokenType.GRANT: lambda self: self._parse_grant(), - TokenType.REVOKE: lambda self: self._parse_revoke(), - TokenType.INSERT: lambda self: self._parse_insert(), - TokenType.KILL: lambda self: self._parse_kill(), - TokenType.LOAD: lambda self: self._parse_load(), - TokenType.MERGE: lambda self: self._parse_merge(), - TokenType.PIVOT: lambda self: self._parse_simplified_pivot(), - TokenType.PRAGMA: lambda self: self.expression( - exp.Pragma, this=self._parse_expression() - ), - TokenType.REFRESH: lambda self: self._parse_refresh(), - TokenType.ROLLBACK: lambda self: self._parse_commit_or_rollback(), - TokenType.SET: lambda self: self._parse_set(), - TokenType.TRUNCATE: lambda self: self._parse_truncate_table(), - TokenType.UNCACHE: lambda self: self._parse_uncache(), - TokenType.UNPIVOT: lambda self: self._parse_simplified_pivot(is_unpivot=True), - TokenType.UPDATE: lambda self: self._parse_update(), - TokenType.USE: lambda self: self._parse_use(), - TokenType.SEMICOLON: lambda self: exp.Semicolon(), - } - - UNARY_PARSERS = { - TokenType.PLUS: lambda self: ( - self._parse_unary() - ), # Unary + is handled as a no-op - TokenType.NOT: lambda self: self.expression( - exp.Not, this=self._parse_equality() - ), - TokenType.TILDA: lambda self: self.expression( - exp.BitwiseNot, this=self._parse_unary() - ), - TokenType.DASH: lambda self: self.expression(exp.Neg, this=self._parse_unary()), - TokenType.PIPE_SLASH: lambda self: self.expression( - exp.Sqrt, this=self._parse_unary() - ), - TokenType.DPIPE_SLASH: lambda self: self.expression( - exp.Cbrt, this=self._parse_unary() - ), - } - - STRING_PARSERS = { - TokenType.HEREDOC_STRING: lambda self, token: self.expression( - exp.RawString, token=token - ), - TokenType.NATIONAL_STRING: lambda self, token: self.expression( - exp.National, token=token - ), - TokenType.RAW_STRING: lambda self, token: self.expression( - exp.RawString, token=token - ), - TokenType.STRING: lambda self, token: self.expression( - exp.Literal, token=token, is_string=True - ), - TokenType.UNICODE_STRING: lambda self, token: self.expression( - exp.UnicodeString, - token=token, - escape=self._match_text_seq("UESCAPE") and self._parse_string(), - ), - } - - NUMERIC_PARSERS = { - TokenType.BIT_STRING: lambda self, token: self.expression( - exp.BitString, token=token - ), - TokenType.BYTE_STRING: lambda self, token: self.expression( - exp.ByteString, - token=token, - is_bytes=self.dialect.BYTE_STRING_IS_BYTES_TYPE or None, - ), - TokenType.HEX_STRING: lambda self, token: self.expression( - exp.HexString, - token=token, - is_integer=self.dialect.HEX_STRING_IS_INTEGER_TYPE or None, - ), - TokenType.NUMBER: lambda self, token: self.expression( - exp.Literal, token=token, is_string=False - ), - } - - PRIMARY_PARSERS = { - **STRING_PARSERS, - **NUMERIC_PARSERS, - TokenType.INTRODUCER: lambda self, token: self._parse_introducer(token), - TokenType.NULL: lambda self, _: self.expression(exp.Null), - TokenType.TRUE: lambda self, _: self.expression(exp.Boolean, this=True), - TokenType.FALSE: lambda self, _: self.expression(exp.Boolean, this=False), - TokenType.SESSION_PARAMETER: lambda self, _: self._parse_session_parameter(), - TokenType.STAR: lambda self, _: self._parse_star_ops(), - } - - PLACEHOLDER_PARSERS = { - TokenType.PLACEHOLDER: lambda self: self.expression(exp.Placeholder), - TokenType.PARAMETER: lambda self: self._parse_parameter(), - TokenType.COLON: lambda self: ( - self.expression(exp.Placeholder, this=self._prev.text) - if self._match_set(self.COLON_PLACEHOLDER_TOKENS) - else None - ), - } - - RANGE_PARSERS = { - TokenType.AT_GT: binary_range_parser(exp.ArrayContainsAll), - TokenType.BETWEEN: lambda self, this: self._parse_between(this), - TokenType.GLOB: binary_range_parser(exp.Glob), - TokenType.ILIKE: binary_range_parser(exp.ILike), - TokenType.IN: lambda self, this: self._parse_in(this), - TokenType.IRLIKE: binary_range_parser(exp.RegexpILike), - TokenType.IS: lambda self, this: self._parse_is(this), - TokenType.LIKE: binary_range_parser(exp.Like), - TokenType.LT_AT: binary_range_parser(exp.ArrayContainsAll, reverse_args=True), - TokenType.OVERLAPS: binary_range_parser(exp.Overlaps), - TokenType.RLIKE: binary_range_parser(exp.RegexpLike), - TokenType.SIMILAR_TO: binary_range_parser(exp.SimilarTo), - TokenType.FOR: lambda self, this: self._parse_comprehension(this), - TokenType.QMARK_AMP: binary_range_parser(exp.JSONBContainsAllTopKeys), - TokenType.QMARK_PIPE: binary_range_parser(exp.JSONBContainsAnyTopKeys), - TokenType.HASH_DASH: binary_range_parser(exp.JSONBDeleteAtPath), - TokenType.ADJACENT: binary_range_parser(exp.Adjacent), - TokenType.OPERATOR: lambda self, this: self._parse_operator(this), - TokenType.AMP_LT: binary_range_parser(exp.ExtendsLeft), - TokenType.AMP_GT: binary_range_parser(exp.ExtendsRight), - } - - PIPE_SYNTAX_TRANSFORM_PARSERS = { - "AGGREGATE": lambda self, query: self._parse_pipe_syntax_aggregate(query), - "AS": lambda self, query: self._build_pipe_cte( - query, [exp.Star()], self._parse_table_alias() - ), - "EXTEND": lambda self, query: self._parse_pipe_syntax_extend(query), - "LIMIT": lambda self, query: self._parse_pipe_syntax_limit(query), - "ORDER BY": lambda self, query: query.order_by( - self._parse_order(), append=False, copy=False - ), - "PIVOT": lambda self, query: self._parse_pipe_syntax_pivot(query), - "SELECT": lambda self, query: self._parse_pipe_syntax_select(query), - "TABLESAMPLE": lambda self, query: self._parse_pipe_syntax_tablesample(query), - "UNPIVOT": lambda self, query: self._parse_pipe_syntax_pivot(query), - "WHERE": lambda self, query: query.where(self._parse_where(), copy=False), - } - - PROPERTY_PARSERS: t.Dict[str, t.Callable] = { - "ALLOWED_VALUES": lambda self: self.expression( - exp.AllowedValuesProperty, expressions=self._parse_csv(self._parse_primary) - ), - "ALGORITHM": lambda self: self._parse_property_assignment( - exp.AlgorithmProperty - ), - "AUTO": lambda self: self._parse_auto_property(), - "AUTO_INCREMENT": lambda self: self._parse_property_assignment( - exp.AutoIncrementProperty - ), - "BACKUP": lambda self: self.expression( - exp.BackupProperty, this=self._parse_var(any_token=True) - ), - "BLOCKCOMPRESSION": lambda self: self._parse_blockcompression(), - "CHARSET": lambda self, **kwargs: self._parse_character_set(**kwargs), - "CHARACTER SET": lambda self, **kwargs: self._parse_character_set(**kwargs), - "CHECKSUM": lambda self: self._parse_checksum(), - "CLUSTER BY": lambda self: self._parse_cluster(), - "CLUSTERED": lambda self: self._parse_clustered_by(), - "COLLATE": lambda self, **kwargs: self._parse_property_assignment( - exp.CollateProperty, **kwargs - ), - "COMMENT": lambda self: self._parse_property_assignment( - exp.SchemaCommentProperty - ), - "CONTAINS": lambda self: self._parse_contains_property(), - "COPY": lambda self: self._parse_copy_property(), - "DATABLOCKSIZE": lambda self, **kwargs: self._parse_datablocksize(**kwargs), - "DATA_DELETION": lambda self: self._parse_data_deletion_property(), - "DEFINER": lambda self: self._parse_definer(), - "DETERMINISTIC": lambda self: self.expression( - exp.StabilityProperty, this=exp.Literal.string("IMMUTABLE") - ), - "DISTRIBUTED": lambda self: self._parse_distributed_property(), - "DUPLICATE": lambda self: self._parse_composite_key_property( - exp.DuplicateKeyProperty - ), - "DYNAMIC": lambda self: self.expression(exp.DynamicProperty), - "DISTKEY": lambda self: self._parse_distkey(), - "DISTSTYLE": lambda self: self._parse_property_assignment( - exp.DistStyleProperty - ), - "EMPTY": lambda self: self.expression(exp.EmptyProperty), - "ENGINE": lambda self: self._parse_property_assignment(exp.EngineProperty), - "ENVIRONMENT": lambda self: self.expression( - exp.EnviromentProperty, - expressions=self._parse_wrapped_csv(self._parse_assignment), - ), - "EXECUTE": lambda self: self._parse_property_assignment(exp.ExecuteAsProperty), - "EXTERNAL": lambda self: self.expression(exp.ExternalProperty), - "FALLBACK": lambda self, **kwargs: self._parse_fallback(**kwargs), - "FORMAT": lambda self: self._parse_property_assignment(exp.FileFormatProperty), - "FREESPACE": lambda self: self._parse_freespace(), - "GLOBAL": lambda self: self.expression(exp.GlobalProperty), - "HEAP": lambda self: self.expression(exp.HeapProperty), - "ICEBERG": lambda self: self.expression(exp.IcebergProperty), - "IMMUTABLE": lambda self: self.expression( - exp.StabilityProperty, this=exp.Literal.string("IMMUTABLE") - ), - "INHERITS": lambda self: self.expression( - exp.InheritsProperty, expressions=self._parse_wrapped_csv(self._parse_table) - ), - "INPUT": lambda self: self.expression( - exp.InputModelProperty, this=self._parse_schema() - ), - "JOURNAL": lambda self, **kwargs: self._parse_journal(**kwargs), - "LANGUAGE": lambda self: self._parse_property_assignment(exp.LanguageProperty), - "LAYOUT": lambda self: self._parse_dict_property(this="LAYOUT"), - "LIFETIME": lambda self: self._parse_dict_range(this="LIFETIME"), - "LIKE": lambda self: self._parse_create_like(), - "LOCATION": lambda self: self._parse_property_assignment(exp.LocationProperty), - "LOCK": lambda self: self._parse_locking(), - "LOCKING": lambda self: self._parse_locking(), - "LOG": lambda self, **kwargs: self._parse_log(**kwargs), - "MATERIALIZED": lambda self: self.expression(exp.MaterializedProperty), - "MERGEBLOCKRATIO": lambda self, **kwargs: self._parse_mergeblockratio(**kwargs), - "MODIFIES": lambda self: self._parse_modifies_property(), - "MULTISET": lambda self: self.expression(exp.SetProperty, multi=True), - "NO": lambda self: self._parse_no_property(), - "ON": lambda self: self._parse_on_property(), - "ORDER BY": lambda self: self._parse_order(skip_order_token=True), - "OUTPUT": lambda self: self.expression( - exp.OutputModelProperty, this=self._parse_schema() - ), - "PARTITION": lambda self: self._parse_partitioned_of(), - "PARTITION BY": lambda self: self._parse_partitioned_by(), - "PARTITIONED BY": lambda self: self._parse_partitioned_by(), - "PARTITIONED_BY": lambda self: self._parse_partitioned_by(), - "PRIMARY KEY": lambda self: self._parse_primary_key(in_props=True), - "RANGE": lambda self: self._parse_dict_range(this="RANGE"), - "READS": lambda self: self._parse_reads_property(), - "REMOTE": lambda self: self._parse_remote_with_connection(), - "RETURNS": lambda self: self._parse_returns(), - "STRICT": lambda self: self.expression(exp.StrictProperty), - "STREAMING": lambda self: self.expression(exp.StreamingTableProperty), - "ROW": lambda self: self._parse_row(), - "ROW_FORMAT": lambda self: self._parse_property_assignment( - exp.RowFormatProperty - ), - "SAMPLE": lambda self: self.expression( - exp.SampleProperty, - this=self._match_text_seq("BY") and self._parse_bitwise(), - ), - "SECURE": lambda self: self.expression(exp.SecureProperty), - "SECURITY": lambda self: self._parse_security(), - "SET": lambda self: self.expression(exp.SetProperty, multi=False), - "SETTINGS": lambda self: self._parse_settings_property(), - "SHARING": lambda self: self._parse_property_assignment(exp.SharingProperty), - "SORTKEY": lambda self: self._parse_sortkey(), - "SOURCE": lambda self: self._parse_dict_property(this="SOURCE"), - "STABLE": lambda self: self.expression( - exp.StabilityProperty, this=exp.Literal.string("STABLE") - ), - "STORED": lambda self: self._parse_stored(), - "SYSTEM_VERSIONING": lambda self: self._parse_system_versioning_property(), - "TBLPROPERTIES": lambda self: self._parse_wrapped_properties(), - "TEMP": lambda self: self.expression(exp.TemporaryProperty), - "TEMPORARY": lambda self: self.expression(exp.TemporaryProperty), - "TO": lambda self: self._parse_to_table(), - "TRANSIENT": lambda self: self.expression(exp.TransientProperty), - "TRANSFORM": lambda self: self.expression( - exp.TransformModelProperty, - expressions=self._parse_wrapped_csv(self._parse_expression), - ), - "TTL": lambda self: self._parse_ttl(), - "USING": lambda self: self._parse_property_assignment(exp.FileFormatProperty), - "UNLOGGED": lambda self: self.expression(exp.UnloggedProperty), - "VOLATILE": lambda self: self._parse_volatile_property(), - "WITH": lambda self: self._parse_with_property(), - } - - CONSTRAINT_PARSERS = { - "AUTOINCREMENT": lambda self: self._parse_auto_increment(), - "AUTO_INCREMENT": lambda self: self._parse_auto_increment(), - "CASESPECIFIC": lambda self: self.expression( - exp.CaseSpecificColumnConstraint, not_=False - ), - "CHARACTER SET": lambda self: self.expression( - exp.CharacterSetColumnConstraint, this=self._parse_var_or_string() - ), - "CHECK": lambda self: self.expression( - exp.CheckColumnConstraint, - this=self._parse_wrapped(self._parse_assignment), - enforced=self._match_text_seq("ENFORCED"), - ), - "COLLATE": lambda self: self.expression( - exp.CollateColumnConstraint, - this=self._parse_identifier() or self._parse_column(), - ), - "COMMENT": lambda self: self.expression( - exp.CommentColumnConstraint, this=self._parse_string() - ), - "COMPRESS": lambda self: self._parse_compress(), - "CLUSTERED": lambda self: self.expression( - exp.ClusteredColumnConstraint, - this=self._parse_wrapped_csv(self._parse_ordered), - ), - "NONCLUSTERED": lambda self: self.expression( - exp.NonClusteredColumnConstraint, - this=self._parse_wrapped_csv(self._parse_ordered), - ), - "DEFAULT": lambda self: self.expression( - exp.DefaultColumnConstraint, this=self._parse_bitwise() - ), - "ENCODE": lambda self: self.expression( - exp.EncodeColumnConstraint, this=self._parse_var() - ), - "EPHEMERAL": lambda self: self.expression( - exp.EphemeralColumnConstraint, this=self._parse_bitwise() - ), - "EXCLUDE": lambda self: self.expression( - exp.ExcludeColumnConstraint, this=self._parse_index_params() - ), - "FOREIGN KEY": lambda self: self._parse_foreign_key(), - "FORMAT": lambda self: self.expression( - exp.DateFormatColumnConstraint, this=self._parse_var_or_string() - ), - "GENERATED": lambda self: self._parse_generated_as_identity(), - "IDENTITY": lambda self: self._parse_auto_increment(), - "INLINE": lambda self: self._parse_inline(), - "LIKE": lambda self: self._parse_create_like(), - "NOT": lambda self: self._parse_not_constraint(), - "NULL": lambda self: self.expression( - exp.NotNullColumnConstraint, allow_null=True - ), - "ON": lambda self: ( - ( - self._match(TokenType.UPDATE) - and self.expression( - exp.OnUpdateColumnConstraint, this=self._parse_function() - ) - ) - or self.expression(exp.OnProperty, this=self._parse_id_var()) - ), - "PATH": lambda self: self.expression( - exp.PathColumnConstraint, this=self._parse_string() - ), - "PERIOD": lambda self: self._parse_period_for_system_time(), - "PRIMARY KEY": lambda self: self._parse_primary_key(), - "REFERENCES": lambda self: self._parse_references(match=False), - "TITLE": lambda self: self.expression( - exp.TitleColumnConstraint, this=self._parse_var_or_string() - ), - "TTL": lambda self: self.expression( - exp.MergeTreeTTL, expressions=[self._parse_bitwise()] - ), - "UNIQUE": lambda self: self._parse_unique(), - "UPPERCASE": lambda self: self.expression(exp.UppercaseColumnConstraint), - "WITH": lambda self: self.expression( - exp.Properties, expressions=self._parse_wrapped_properties() - ), - "BUCKET": lambda self: self._parse_partitioned_by_bucket_or_truncate(), - "TRUNCATE": lambda self: self._parse_partitioned_by_bucket_or_truncate(), - } - - def _parse_partitioned_by_bucket_or_truncate(self) -> t.Optional[exp.Expression]: - if not self._match(TokenType.L_PAREN, advance=False): - # Partitioning by bucket or truncate follows the syntax: - # PARTITION BY (BUCKET(..) | TRUNCATE(..)) - # If we don't have parenthesis after each keyword, we should instead parse this as an identifier - self._retreat(self._index - 1) - return None - - klass = ( - exp.PartitionedByBucket - if self._prev.text.upper() == "BUCKET" - else exp.PartitionByTruncate - ) - - args = self._parse_wrapped_csv( - lambda: self._parse_primary() or self._parse_column() - ) - this, expression = seq_get(args, 0), seq_get(args, 1) - - if isinstance(this, exp.Literal): - # Check for Iceberg partition transforms (bucket / truncate) and ensure their arguments are in the right order - # - For Hive, it's `bucket(, )` or `truncate(, )` - # - For Trino, it's reversed - `bucket(, )` or `truncate(, )` - # Both variants are canonicalized in the latter i.e `bucket(, )` - # - # Hive ref: https://docs.aws.amazon.com/athena/latest/ug/querying-iceberg-creating-tables.html#querying-iceberg-partitioning - # Trino ref: https://docs.aws.amazon.com/athena/latest/ug/create-table-as.html#ctas-table-properties - this, expression = expression, this - - return self.expression(klass, this=this, expression=expression) - - ALTER_PARSERS = { - "ADD": lambda self: self._parse_alter_table_add(), - "AS": lambda self: self._parse_select(), - "ALTER": lambda self: self._parse_alter_table_alter(), - "CLUSTER BY": lambda self: self._parse_cluster(wrapped=True), - "DELETE": lambda self: self.expression(exp.Delete, where=self._parse_where()), - "DROP": lambda self: self._parse_alter_table_drop(), - "RENAME": lambda self: self._parse_alter_table_rename(), - "SET": lambda self: self._parse_alter_table_set(), - "SWAP": lambda self: self.expression( - exp.SwapTable, - this=self._match(TokenType.WITH) and self._parse_table(schema=True), - ), - } - - ALTER_ALTER_PARSERS = { - "DISTKEY": lambda self: self._parse_alter_diststyle(), - "DISTSTYLE": lambda self: self._parse_alter_diststyle(), - "SORTKEY": lambda self: self._parse_alter_sortkey(), - "COMPOUND": lambda self: self._parse_alter_sortkey(compound=True), - } - - SCHEMA_UNNAMED_CONSTRAINTS = { - "CHECK", - "EXCLUDE", - "FOREIGN KEY", - "LIKE", - "PERIOD", - "PRIMARY KEY", - "UNIQUE", - "BUCKET", - "TRUNCATE", - } - - NO_PAREN_FUNCTION_PARSERS = { - "ANY": lambda self: self.expression(exp.Any, this=self._parse_bitwise()), - "CASE": lambda self: self._parse_case(), - "CONNECT_BY_ROOT": lambda self: self.expression( - exp.ConnectByRoot, this=self._parse_column() - ), - "IF": lambda self: self._parse_if(), - } - - INVALID_FUNC_NAME_TOKENS = { - TokenType.IDENTIFIER, - TokenType.STRING, - } - - FUNCTIONS_WITH_ALIASED_ARGS = {"STRUCT"} - - KEY_VALUE_DEFINITIONS = (exp.Alias, exp.EQ, exp.PropertyEQ, exp.Slice) - - FUNCTION_PARSERS = { - **{ - name: lambda self: self._parse_max_min_by(exp.ArgMax) - for name in exp.ArgMax.sql_names() - }, - **{ - name: lambda self: self._parse_max_min_by(exp.ArgMin) - for name in exp.ArgMin.sql_names() - }, - "CAST": lambda self: self._parse_cast(self.STRICT_CAST), - "CEIL": lambda self: self._parse_ceil_floor(exp.Ceil), - "CONVERT": lambda self: self._parse_convert(self.STRICT_CAST), - "DECODE": lambda self: self._parse_decode(), - "EXTRACT": lambda self: self._parse_extract(), - "FLOOR": lambda self: self._parse_ceil_floor(exp.Floor), - "GAP_FILL": lambda self: self._parse_gap_fill(), - "INITCAP": lambda self: self._parse_initcap(), - "JSON_OBJECT": lambda self: self._parse_json_object(), - "JSON_OBJECTAGG": lambda self: self._parse_json_object(agg=True), - "JSON_TABLE": lambda self: self._parse_json_table(), - "MATCH": lambda self: self._parse_match_against(), - "NORMALIZE": lambda self: self._parse_normalize(), - "OPENJSON": lambda self: self._parse_open_json(), - "OVERLAY": lambda self: self._parse_overlay(), - "POSITION": lambda self: self._parse_position(), - "SAFE_CAST": lambda self: self._parse_cast(False, safe=True), - "STRING_AGG": lambda self: self._parse_string_agg(), - "SUBSTRING": lambda self: self._parse_substring(), - "TRIM": lambda self: self._parse_trim(), - "TRY_CAST": lambda self: self._parse_cast(False, safe=True), - "TRY_CONVERT": lambda self: self._parse_convert(False, safe=True), - "XMLELEMENT": lambda self: self.expression( - exp.XMLElement, - this=self._match_text_seq("NAME") and self._parse_id_var(), - expressions=self._match(TokenType.COMMA) - and self._parse_csv(self._parse_expression), - ), - "XMLTABLE": lambda self: self._parse_xml_table(), - } - - QUERY_MODIFIER_PARSERS = { - TokenType.MATCH_RECOGNIZE: lambda self: ( - "match", - self._parse_match_recognize(), - ), - TokenType.PREWHERE: lambda self: ("prewhere", self._parse_prewhere()), - TokenType.WHERE: lambda self: ("where", self._parse_where()), - TokenType.GROUP_BY: lambda self: ("group", self._parse_group()), - TokenType.HAVING: lambda self: ("having", self._parse_having()), - TokenType.QUALIFY: lambda self: ("qualify", self._parse_qualify()), - TokenType.WINDOW: lambda self: ("windows", self._parse_window_clause()), - TokenType.ORDER_BY: lambda self: ("order", self._parse_order()), - TokenType.LIMIT: lambda self: ("limit", self._parse_limit()), - TokenType.FETCH: lambda self: ("limit", self._parse_limit()), - TokenType.OFFSET: lambda self: ("offset", self._parse_offset()), - TokenType.FOR: lambda self: ("locks", self._parse_locks()), - TokenType.LOCK: lambda self: ("locks", self._parse_locks()), - TokenType.TABLE_SAMPLE: lambda self: ( - "sample", - self._parse_table_sample(as_modifier=True), - ), - TokenType.USING: lambda self: ( - "sample", - self._parse_table_sample(as_modifier=True), - ), - TokenType.CLUSTER_BY: lambda self: ( - "cluster", - self._parse_sort(exp.Cluster, TokenType.CLUSTER_BY), - ), - TokenType.DISTRIBUTE_BY: lambda self: ( - "distribute", - self._parse_sort(exp.Distribute, TokenType.DISTRIBUTE_BY), - ), - TokenType.SORT_BY: lambda self: ( - "sort", - self._parse_sort(exp.Sort, TokenType.SORT_BY), - ), - TokenType.CONNECT_BY: lambda self: ( - "connect", - self._parse_connect(skip_start_token=True), - ), - TokenType.START_WITH: lambda self: ("connect", self._parse_connect()), - } - QUERY_MODIFIER_TOKENS = set(QUERY_MODIFIER_PARSERS) - - SET_PARSERS = { - "GLOBAL": lambda self: self._parse_set_item_assignment("GLOBAL"), - "LOCAL": lambda self: self._parse_set_item_assignment("LOCAL"), - "SESSION": lambda self: self._parse_set_item_assignment("SESSION"), - "TRANSACTION": lambda self: self._parse_set_transaction(), - } - - SHOW_PARSERS: t.Dict[str, t.Callable] = {} - - TYPE_LITERAL_PARSERS = { - exp.DataType.Type.JSON: lambda self, this, _: self.expression( - exp.ParseJSON, this=this - ), - } - - TYPE_CONVERTERS: t.Dict[ - exp.DataType.Type, t.Callable[[exp.DataType], exp.DataType] - ] = {} - - DDL_SELECT_TOKENS = {TokenType.SELECT, TokenType.WITH, TokenType.L_PAREN} - - PRE_VOLATILE_TOKENS = {TokenType.CREATE, TokenType.REPLACE, TokenType.UNIQUE} - - TRANSACTION_KIND = {"DEFERRED", "IMMEDIATE", "EXCLUSIVE"} - TRANSACTION_CHARACTERISTICS: OPTIONS_TYPE = { - "ISOLATION": ( - ("LEVEL", "REPEATABLE", "READ"), - ("LEVEL", "READ", "COMMITTED"), - ("LEVEL", "READ", "UNCOMITTED"), - ("LEVEL", "SERIALIZABLE"), - ), - "READ": ("WRITE", "ONLY"), - } - - CONFLICT_ACTIONS: OPTIONS_TYPE = dict.fromkeys( - ("ABORT", "FAIL", "IGNORE", "REPLACE", "ROLLBACK", "UPDATE"), tuple() - ) - CONFLICT_ACTIONS["DO"] = ("NOTHING", "UPDATE") - - CREATE_SEQUENCE: OPTIONS_TYPE = { - "SCALE": ("EXTEND", "NOEXTEND"), - "SHARD": ("EXTEND", "NOEXTEND"), - "NO": ("CYCLE", "CACHE", "MAXVALUE", "MINVALUE"), - **dict.fromkeys( - ( - "SESSION", - "GLOBAL", - "KEEP", - "NOKEEP", - "ORDER", - "NOORDER", - "NOCACHE", - "CYCLE", - "NOCYCLE", - "NOMINVALUE", - "NOMAXVALUE", - "NOSCALE", - "NOSHARD", - ), - tuple(), - ), - } - - ISOLATED_LOADING_OPTIONS: OPTIONS_TYPE = {"FOR": ("ALL", "INSERT", "NONE")} - - USABLES: OPTIONS_TYPE = dict.fromkeys( - ("ROLE", "WAREHOUSE", "DATABASE", "SCHEMA", "CATALOG"), tuple() - ) - - CAST_ACTIONS: OPTIONS_TYPE = dict.fromkeys(("RENAME", "ADD"), ("FIELDS",)) - - SCHEMA_BINDING_OPTIONS: OPTIONS_TYPE = { - "TYPE": ("EVOLUTION",), - **dict.fromkeys(("BINDING", "COMPENSATION", "EVOLUTION"), tuple()), - } - - PROCEDURE_OPTIONS: OPTIONS_TYPE = {} - - EXECUTE_AS_OPTIONS: OPTIONS_TYPE = dict.fromkeys( - ("CALLER", "SELF", "OWNER"), tuple() - ) - - KEY_CONSTRAINT_OPTIONS: OPTIONS_TYPE = { - "NOT": ("ENFORCED",), - "MATCH": ( - "FULL", - "PARTIAL", - "SIMPLE", - ), - "INITIALLY": ("DEFERRED", "IMMEDIATE"), - "USING": ( - "BTREE", - "HASH", - ), - **dict.fromkeys(("DEFERRABLE", "NORELY", "RELY"), tuple()), - } - - WINDOW_EXCLUDE_OPTIONS: OPTIONS_TYPE = { - "NO": ("OTHERS",), - "CURRENT": ("ROW",), - **dict.fromkeys(("GROUP", "TIES"), tuple()), - } - - INSERT_ALTERNATIVES = {"ABORT", "FAIL", "IGNORE", "REPLACE", "ROLLBACK"} - - CLONE_KEYWORDS = {"CLONE", "COPY"} - HISTORICAL_DATA_PREFIX = {"AT", "BEFORE", "END"} - HISTORICAL_DATA_KIND = {"OFFSET", "STATEMENT", "STREAM", "TIMESTAMP", "VERSION"} - - OPCLASS_FOLLOW_KEYWORDS = {"ASC", "DESC", "NULLS", "WITH"} - - OPTYPE_FOLLOW_TOKENS = {TokenType.COMMA, TokenType.R_PAREN} - - TABLE_INDEX_HINT_TOKENS = {TokenType.FORCE, TokenType.IGNORE, TokenType.USE} - - VIEW_ATTRIBUTES = {"ENCRYPTION", "SCHEMABINDING", "VIEW_METADATA"} - - WINDOW_ALIAS_TOKENS = ID_VAR_TOKENS - {TokenType.RANGE, TokenType.ROWS} - WINDOW_BEFORE_PAREN_TOKENS = {TokenType.OVER} - WINDOW_SIDES = {"FOLLOWING", "PRECEDING"} - - JSON_KEY_VALUE_SEPARATOR_TOKENS = {TokenType.COLON, TokenType.COMMA, TokenType.IS} - - FETCH_TOKENS = ID_VAR_TOKENS - {TokenType.ROW, TokenType.ROWS, TokenType.PERCENT} - - ADD_CONSTRAINT_TOKENS = { - TokenType.CONSTRAINT, - TokenType.FOREIGN_KEY, - TokenType.INDEX, - TokenType.KEY, - TokenType.PRIMARY_KEY, - TokenType.UNIQUE, - } - - DISTINCT_TOKENS = {TokenType.DISTINCT} - - UNNEST_OFFSET_ALIAS_TOKENS = TABLE_ALIAS_TOKENS - SET_OPERATIONS - - SELECT_START_TOKENS = {TokenType.L_PAREN, TokenType.WITH, TokenType.SELECT} - - COPY_INTO_VARLEN_OPTIONS = { - "FILE_FORMAT", - "COPY_OPTIONS", - "FORMAT_OPTIONS", - "CREDENTIAL", - } - - IS_JSON_PREDICATE_KIND = {"VALUE", "SCALAR", "ARRAY", "OBJECT"} - - ODBC_DATETIME_LITERALS: t.Dict[str, t.Type[exp.Expression]] = {} - - ON_CONDITION_TOKENS = {"ERROR", "NULL", "TRUE", "FALSE", "EMPTY"} - - PRIVILEGE_FOLLOW_TOKENS = {TokenType.ON, TokenType.COMMA, TokenType.L_PAREN} - - # The style options for the DESCRIBE statement - DESCRIBE_STYLES = {"ANALYZE", "EXTENDED", "FORMATTED", "HISTORY"} - - SET_ASSIGNMENT_DELIMITERS = {"=", ":=", "TO"} - - # The style options for the ANALYZE statement - ANALYZE_STYLES = { - "BUFFER_USAGE_LIMIT", - "FULL", - "LOCAL", - "NO_WRITE_TO_BINLOG", - "SAMPLE", - "SKIP_LOCKED", - "VERBOSE", - } - - ANALYZE_EXPRESSION_PARSERS = { - "ALL": lambda self: self._parse_analyze_columns(), - "COMPUTE": lambda self: self._parse_analyze_statistics(), - "DELETE": lambda self: self._parse_analyze_delete(), - "DROP": lambda self: self._parse_analyze_histogram(), - "ESTIMATE": lambda self: self._parse_analyze_statistics(), - "LIST": lambda self: self._parse_analyze_list(), - "PREDICATE": lambda self: self._parse_analyze_columns(), - "UPDATE": lambda self: self._parse_analyze_histogram(), - "VALIDATE": lambda self: self._parse_analyze_validate(), - } - - PARTITION_KEYWORDS = {"PARTITION", "SUBPARTITION"} - - AMBIGUOUS_ALIAS_TOKENS = (TokenType.LIMIT, TokenType.OFFSET) - - OPERATION_MODIFIERS: t.Set[str] = set() - - RECURSIVE_CTE_SEARCH_KIND = {"BREADTH", "DEPTH", "CYCLE"} - - MODIFIABLES = (exp.Query, exp.Table, exp.TableFromRows, exp.Values) - - STRICT_CAST = True - - PREFIXED_PIVOT_COLUMNS = False - IDENTIFY_PIVOT_STRINGS = False - - LOG_DEFAULTS_TO_LN = False - - # Whether the table sample clause expects CSV syntax - TABLESAMPLE_CSV = False - - # The default method used for table sampling - DEFAULT_SAMPLING_METHOD: t.Optional[str] = None - - # Whether the SET command needs a delimiter (e.g. "=") for assignments - SET_REQUIRES_ASSIGNMENT_DELIMITER = True - - # Whether the TRIM function expects the characters to trim as its first argument - TRIM_PATTERN_FIRST = False - - # Whether string aliases are supported `SELECT COUNT(*) 'count'` - STRING_ALIASES = False - - # Whether query modifiers such as LIMIT are attached to the UNION node (vs its right operand) - MODIFIERS_ATTACHED_TO_SET_OP = True - SET_OP_MODIFIERS = {"order", "limit", "offset"} - - # Whether to parse IF statements that aren't followed by a left parenthesis as commands - NO_PAREN_IF_COMMANDS = True - - # Whether the -> and ->> operators expect documents of type JSON (e.g. Postgres) - JSON_ARROWS_REQUIRE_JSON_TYPE = False - - # Whether the `:` operator is used to extract a value from a VARIANT column - COLON_IS_VARIANT_EXTRACT = False - - # Whether or not a VALUES keyword needs to be followed by '(' to form a VALUES clause. - # If this is True and '(' is not found, the keyword will be treated as an identifier - VALUES_FOLLOWED_BY_PAREN = True - - # Whether implicit unnesting is supported, e.g. SELECT 1 FROM y.z AS z, z.a (Redshift) - SUPPORTS_IMPLICIT_UNNEST = False - - # Whether or not interval spans are supported, INTERVAL 1 YEAR TO MONTHS - INTERVAL_SPANS = True - - # Whether a PARTITION clause can follow a table reference - SUPPORTS_PARTITION_SELECTION = False - - # Whether the `name AS expr` schema/column constraint requires parentheses around `expr` - WRAPPED_TRANSFORM_COLUMN_CONSTRAINT = True - - # Whether the 'AS' keyword is optional in the CTE definition syntax - OPTIONAL_ALIAS_TOKEN_CTE = True - - # Whether renaming a column with an ALTER statement requires the presence of the COLUMN keyword - ALTER_RENAME_REQUIRES_COLUMN = True - - # Whether Alter statements are allowed to contain Partition specifications - ALTER_TABLE_PARTITIONS = False - - # Whether all join types have the same precedence, i.e., they "naturally" produce a left-deep tree. - # In standard SQL, joins that use the JOIN keyword take higher precedence than comma-joins. That is - # to say, JOIN operators happen before comma operators. This is not the case in some dialects, such - # as BigQuery, where all joins have the same precedence. - JOINS_HAVE_EQUAL_PRECEDENCE = False - - # Whether TIMESTAMP can produce a zone-aware timestamp - ZONE_AWARE_TIMESTAMP_CONSTRUCTOR = False - - # Whether map literals support arbitrary expressions as keys. - # When True, allows complex keys like arrays or literals: {[1, 2]: 3}, {1: 2} (e.g. DuckDB). - # When False, keys are typically restricted to identifiers. - MAP_KEYS_ARE_ARBITRARY_EXPRESSIONS = False - - # Whether JSON_EXTRACT requires a JSON expression as the first argument, e.g this - # is true for Snowflake but not for BigQuery which can also process strings - JSON_EXTRACT_REQUIRES_JSON_EXPRESSION = False - - # Dialects like Databricks support JOINS without join criteria - # Adding an ON TRUE, makes transpilation semantically correct for other dialects - ADD_JOIN_ON_TRUE = False - - # Whether INTERVAL spans with literal format '\d+ hh:[mm:[ss[.ff]]]' - # can omit the span unit `DAY TO MINUTE` or `DAY TO SECOND` - SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT = False - - __slots__ = ( - "error_level", - "error_message_context", - "max_errors", - "dialect", - "sql", - "errors", - "_tokens", - "_index", - "_curr", - "_next", - "_prev", - "_prev_comments", - "_pipe_cte_counter", - ) - - # Autofilled - SHOW_TRIE: t.Dict = {} - SET_TRIE: t.Dict = {} - - def __init__( - self, - error_level: t.Optional[ErrorLevel] = None, - error_message_context: int = 100, - max_errors: int = 3, - dialect: DialectType = None, - ): - from bigframes_vendored.sqlglot.dialects import Dialect - - self.error_level = error_level or ErrorLevel.IMMEDIATE - self.error_message_context = error_message_context - self.max_errors = max_errors - self.dialect = Dialect.get_or_raise(dialect) - self.reset() - - def reset(self): - self.sql = "" - self.errors = [] - self._tokens = [] - self._index = 0 - self._curr = None - self._next = None - self._prev = None - self._prev_comments = None - self._pipe_cte_counter = 0 - - def parse( - self, raw_tokens: t.List[Token], sql: t.Optional[str] = None - ) -> t.List[t.Optional[exp.Expression]]: - """ - Parses a list of tokens and returns a list of syntax trees, one tree - per parsed SQL statement. - - Args: - raw_tokens: The list of tokens. - sql: The original SQL string, used to produce helpful debug messages. - - Returns: - The list of the produced syntax trees. - """ - return self._parse( - parse_method=self.__class__._parse_statement, raw_tokens=raw_tokens, sql=sql - ) - - def parse_into( - self, - expression_types: exp.IntoType, - raw_tokens: t.List[Token], - sql: t.Optional[str] = None, - ) -> t.List[t.Optional[exp.Expression]]: - """ - Parses a list of tokens into a given Expression type. If a collection of Expression - types is given instead, this method will try to parse the token list into each one - of them, stopping at the first for which the parsing succeeds. - - Args: - expression_types: The expression type(s) to try and parse the token list into. - raw_tokens: The list of tokens. - sql: The original SQL string, used to produce helpful debug messages. - - Returns: - The target Expression. - """ - errors = [] - for expression_type in ensure_list(expression_types): - parser = self.EXPRESSION_PARSERS.get(expression_type) - if not parser: - raise TypeError(f"No parser registered for {expression_type}") - - try: - return self._parse(parser, raw_tokens, sql) - except ParseError as e: - e.errors[0]["into_expression"] = expression_type - errors.append(e) - - raise ParseError( - f"Failed to parse '{sql or raw_tokens}' into {expression_types}", - errors=merge_errors(errors), - ) from errors[-1] - - def _parse( - self, - parse_method: t.Callable[[Parser], t.Optional[exp.Expression]], - raw_tokens: t.List[Token], - sql: t.Optional[str] = None, - ) -> t.List[t.Optional[exp.Expression]]: - self.reset() - self.sql = sql or "" - - total = len(raw_tokens) - chunks: t.List[t.List[Token]] = [[]] - - for i, token in enumerate(raw_tokens): - if token.token_type == TokenType.SEMICOLON: - if token.comments: - chunks.append([token]) - - if i < total - 1: - chunks.append([]) - else: - chunks[-1].append(token) - - expressions = [] - - for tokens in chunks: - self._index = -1 - self._tokens = tokens - self._advance() - - expressions.append(parse_method(self)) - - if self._index < len(self._tokens): - self.raise_error("Invalid expression / Unexpected token") - - self.check_errors() - - return expressions - - def check_errors(self) -> None: - """Logs or raises any found errors, depending on the chosen error level setting.""" - if self.error_level == ErrorLevel.WARN: - for error in self.errors: - logger.error(str(error)) - elif self.error_level == ErrorLevel.RAISE and self.errors: - raise ParseError( - concat_messages(self.errors, self.max_errors), - errors=merge_errors(self.errors), - ) - - def raise_error(self, message: str, token: t.Optional[Token] = None) -> None: - """ - Appends an error in the list of recorded errors or raises it, depending on the chosen - error level setting. - """ - token = token or self._curr or self._prev or Token.string("") - formatted_sql, start_context, highlight, end_context = highlight_sql( - sql=self.sql, - positions=[(token.start, token.end)], - context_length=self.error_message_context, - ) - formatted_message = ( - f"{message}. Line {token.line}, Col: {token.col}.\n {formatted_sql}" - ) - - error = ParseError.new( - formatted_message, - description=message, - line=token.line, - col=token.col, - start_context=start_context, - highlight=highlight, - end_context=end_context, - ) - - if self.error_level == ErrorLevel.IMMEDIATE: - raise error - - self.errors.append(error) - - def expression( - self, - exp_class: t.Type[E], - token: t.Optional[Token] = None, - comments: t.Optional[t.List[str]] = None, - **kwargs, - ) -> E: - """ - Creates a new, validated Expression. - - Args: - exp_class: The expression class to instantiate. - comments: An optional list of comments to attach to the expression. - kwargs: The arguments to set for the expression along with their respective values. - - Returns: - The target expression. - """ - if token: - instance = exp_class(this=token.text, **kwargs) - instance.update_positions(token) - else: - instance = exp_class(**kwargs) - instance.add_comments(comments) if comments else self._add_comments(instance) - return self.validate_expression(instance) - - def _add_comments(self, expression: t.Optional[exp.Expression]) -> None: - if expression and self._prev_comments: - expression.add_comments(self._prev_comments) - self._prev_comments = None - - def validate_expression(self, expression: E, args: t.Optional[t.List] = None) -> E: - """ - Validates an Expression, making sure that all its mandatory arguments are set. - - Args: - expression: The expression to validate. - args: An optional list of items that was used to instantiate the expression, if it's a Func. - - Returns: - The validated expression. - """ - if self.error_level != ErrorLevel.IGNORE: - for error_message in expression.error_messages(args): - self.raise_error(error_message) - - return expression - - def _find_sql(self, start: Token, end: Token) -> str: - return self.sql[start.start : end.end + 1] - - def _is_connected(self) -> bool: - return self._prev and self._curr and self._prev.end + 1 == self._curr.start - - def _advance(self, times: int = 1) -> None: - self._index += times - self._curr = seq_get(self._tokens, self._index) - self._next = seq_get(self._tokens, self._index + 1) - - if self._index > 0: - self._prev = self._tokens[self._index - 1] - self._prev_comments = self._prev.comments - else: - self._prev = None - self._prev_comments = None - - def _retreat(self, index: int) -> None: - if index != self._index: - self._advance(index - self._index) - - def _warn_unsupported(self) -> None: - if len(self._tokens) <= 1: - return - - # We use _find_sql because self.sql may comprise multiple chunks, and we're only - # interested in emitting a warning for the one being currently processed. - sql = self._find_sql(self._tokens[0], self._tokens[-1])[ - : self.error_message_context - ] - - logger.warning( - f"'{sql}' contains unsupported syntax. Falling back to parsing as a 'Command'." - ) - - def _parse_command(self) -> exp.Command: - self._warn_unsupported() - return self.expression( - exp.Command, - comments=self._prev_comments, - this=self._prev.text.upper(), - expression=self._parse_string(), - ) - - def _try_parse( - self, parse_method: t.Callable[[], T], retreat: bool = False - ) -> t.Optional[T]: - """ - Attemps to backtrack if a parse function that contains a try/catch internally raises an error. - This behavior can be different depending on the uset-set ErrorLevel, so _try_parse aims to - solve this by setting & resetting the parser state accordingly - """ - index = self._index - error_level = self.error_level - - self.error_level = ErrorLevel.IMMEDIATE - try: - this = parse_method() - except ParseError: - this = None - finally: - if not this or retreat: - self._retreat(index) - self.error_level = error_level - - return this - - def _parse_comment(self, allow_exists: bool = True) -> exp.Expression: - start = self._prev - exists = self._parse_exists() if allow_exists else None - - self._match(TokenType.ON) - - materialized = self._match_text_seq("MATERIALIZED") - kind = self._match_set(self.CREATABLES) and self._prev - if not kind: - return self._parse_as_command(start) - - if kind.token_type in (TokenType.FUNCTION, TokenType.PROCEDURE): - this = self._parse_user_defined_function(kind=kind.token_type) - elif kind.token_type == TokenType.TABLE: - this = self._parse_table(alias_tokens=self.COMMENT_TABLE_ALIAS_TOKENS) - elif kind.token_type == TokenType.COLUMN: - this = self._parse_column() - else: - this = self._parse_id_var() - - self._match(TokenType.IS) - - return self.expression( - exp.Comment, - this=this, - kind=kind.text, - expression=self._parse_string(), - exists=exists, - materialized=materialized, - ) - - def _parse_to_table( - self, - ) -> exp.ToTableProperty: - table = self._parse_table_parts(schema=True) - return self.expression(exp.ToTableProperty, this=table) - - # https://clickhouse.com/docs/en/engines/table-engines/mergetree-family/mergetree#mergetree-table-ttl - def _parse_ttl(self) -> exp.Expression: - def _parse_ttl_action() -> t.Optional[exp.Expression]: - this = self._parse_bitwise() - - if self._match_text_seq("DELETE"): - return self.expression(exp.MergeTreeTTLAction, this=this, delete=True) - if self._match_text_seq("RECOMPRESS"): - return self.expression( - exp.MergeTreeTTLAction, this=this, recompress=self._parse_bitwise() - ) - if self._match_text_seq("TO", "DISK"): - return self.expression( - exp.MergeTreeTTLAction, this=this, to_disk=self._parse_string() - ) - if self._match_text_seq("TO", "VOLUME"): - return self.expression( - exp.MergeTreeTTLAction, this=this, to_volume=self._parse_string() - ) - - return this - - expressions = self._parse_csv(_parse_ttl_action) - where = self._parse_where() - group = self._parse_group() - - aggregates = None - if group and self._match(TokenType.SET): - aggregates = self._parse_csv(self._parse_set_item) - - return self.expression( - exp.MergeTreeTTL, - expressions=expressions, - where=where, - group=group, - aggregates=aggregates, - ) - - def _parse_statement(self) -> t.Optional[exp.Expression]: - if self._curr is None: - return None - - if self._match_set(self.STATEMENT_PARSERS): - comments = self._prev_comments - stmt = self.STATEMENT_PARSERS[self._prev.token_type](self) - stmt.add_comments(comments, prepend=True) - return stmt - - if self._match_set(self.dialect.tokenizer_class.COMMANDS): - return self._parse_command() - - expression = self._parse_expression() - expression = ( - self._parse_set_operations(expression) - if expression - else self._parse_select() - ) - return self._parse_query_modifiers(expression) - - def _parse_drop(self, exists: bool = False) -> exp.Drop | exp.Command: - start = self._prev - temporary = self._match(TokenType.TEMPORARY) - materialized = self._match_text_seq("MATERIALIZED") - - kind = self._match_set(self.CREATABLES) and self._prev.text.upper() - if not kind: - return self._parse_as_command(start) - - concurrently = self._match_text_seq("CONCURRENTLY") - if_exists = exists or self._parse_exists() - - if kind == "COLUMN": - this = self._parse_column() - else: - this = self._parse_table_parts( - schema=True, is_db_reference=self._prev.token_type == TokenType.SCHEMA - ) - - cluster = self._parse_on_property() if self._match(TokenType.ON) else None - - if self._match(TokenType.L_PAREN, advance=False): - expressions = self._parse_wrapped_csv(self._parse_types) - else: - expressions = None - - return self.expression( - exp.Drop, - exists=if_exists, - this=this, - expressions=expressions, - kind=self.dialect.CREATABLE_KIND_MAPPING.get(kind) or kind, - temporary=temporary, - materialized=materialized, - cascade=self._match_text_seq("CASCADE"), - constraints=self._match_text_seq("CONSTRAINTS"), - purge=self._match_text_seq("PURGE"), - cluster=cluster, - concurrently=concurrently, - ) - - def _parse_exists(self, not_: bool = False) -> t.Optional[bool]: - return ( - self._match_text_seq("IF") - and (not not_ or self._match(TokenType.NOT)) - and self._match(TokenType.EXISTS) - ) - - def _parse_create(self) -> exp.Create | exp.Command: - # Note: this can't be None because we've matched a statement parser - start = self._prev - - replace = ( - start.token_type == TokenType.REPLACE - or self._match_pair(TokenType.OR, TokenType.REPLACE) - or self._match_pair(TokenType.OR, TokenType.ALTER) - ) - refresh = self._match_pair(TokenType.OR, TokenType.REFRESH) - - unique = self._match(TokenType.UNIQUE) - - if self._match_text_seq("CLUSTERED", "COLUMNSTORE"): - clustered = True - elif self._match_text_seq( - "NONCLUSTERED", "COLUMNSTORE" - ) or self._match_text_seq("COLUMNSTORE"): - clustered = False - else: - clustered = None - - if self._match_pair(TokenType.TABLE, TokenType.FUNCTION, advance=False): - self._advance() - - properties = None - create_token = self._match_set(self.CREATABLES) and self._prev - - if not create_token: - # exp.Properties.Location.POST_CREATE - properties = self._parse_properties() - create_token = self._match_set(self.CREATABLES) and self._prev - - if not properties or not create_token: - return self._parse_as_command(start) - - concurrently = self._match_text_seq("CONCURRENTLY") - exists = self._parse_exists(not_=True) - this = None - expression: t.Optional[exp.Expression] = None - indexes = None - no_schema_binding = None - begin = None - end = None - clone = None - - def extend_props(temp_props: t.Optional[exp.Properties]) -> None: - nonlocal properties - if properties and temp_props: - properties.expressions.extend(temp_props.expressions) - elif temp_props: - properties = temp_props - - if create_token.token_type in (TokenType.FUNCTION, TokenType.PROCEDURE): - this = self._parse_user_defined_function(kind=create_token.token_type) - - # exp.Properties.Location.POST_SCHEMA ("schema" here is the UDF's type signature) - extend_props(self._parse_properties()) - - expression = self._match(TokenType.ALIAS) and self._parse_heredoc() - extend_props(self._parse_properties()) - - if not expression: - if self._match(TokenType.COMMAND): - expression = self._parse_as_command(self._prev) - else: - begin = self._match(TokenType.BEGIN) - return_ = self._match_text_seq("RETURN") - - if self._match(TokenType.STRING, advance=False): - # Takes care of BigQuery's JavaScript UDF definitions that end in an OPTIONS property - # # https://cloud.google.com/bigquery/docs/reference/standard-sql/data-definition-language#create_function_statement - expression = self._parse_string() - extend_props(self._parse_properties()) - else: - expression = self._parse_user_defined_function_expression() - - end = self._match_text_seq("END") - - if return_: - expression = self.expression(exp.Return, this=expression) - elif create_token.token_type == TokenType.INDEX: - # Postgres allows anonymous indexes, eg. CREATE INDEX IF NOT EXISTS ON t(c) - if not self._match(TokenType.ON): - index = self._parse_id_var() - anonymous = False - else: - index = None - anonymous = True - - this = self._parse_index(index=index, anonymous=anonymous) - elif create_token.token_type in self.DB_CREATABLES: - table_parts = self._parse_table_parts( - schema=True, is_db_reference=create_token.token_type == TokenType.SCHEMA - ) - - # exp.Properties.Location.POST_NAME - self._match(TokenType.COMMA) - extend_props(self._parse_properties(before=True)) - - this = self._parse_schema(this=table_parts) - - # exp.Properties.Location.POST_SCHEMA and POST_WITH - extend_props(self._parse_properties()) - - has_alias = self._match(TokenType.ALIAS) - if not self._match_set(self.DDL_SELECT_TOKENS, advance=False): - # exp.Properties.Location.POST_ALIAS - extend_props(self._parse_properties()) - - if create_token.token_type == TokenType.SEQUENCE: - expression = self._parse_types() - props = self._parse_properties() - if props: - sequence_props = exp.SequenceProperties() - options = [] - for prop in props: - if isinstance(prop, exp.SequenceProperties): - for arg, value in prop.args.items(): - if arg == "options": - options.extend(value) - else: - sequence_props.set(arg, value) - prop.pop() - - if options: - sequence_props.set("options", options) - - props.append("expressions", sequence_props) - extend_props(props) - else: - expression = self._parse_ddl_select() - - # Some dialects also support using a table as an alias instead of a SELECT. - # Here we fallback to this as an alternative. - if not expression and has_alias: - expression = self._try_parse(self._parse_table_parts) - - if create_token.token_type == TokenType.TABLE: - # exp.Properties.Location.POST_EXPRESSION - extend_props(self._parse_properties()) - - indexes = [] - while True: - index = self._parse_index() - - # exp.Properties.Location.POST_INDEX - extend_props(self._parse_properties()) - if not index: - break - else: - self._match(TokenType.COMMA) - indexes.append(index) - elif create_token.token_type == TokenType.VIEW: - if self._match_text_seq("WITH", "NO", "SCHEMA", "BINDING"): - no_schema_binding = True - elif create_token.token_type in (TokenType.SINK, TokenType.SOURCE): - extend_props(self._parse_properties()) - - shallow = self._match_text_seq("SHALLOW") - - if self._match_texts(self.CLONE_KEYWORDS): - copy = self._prev.text.lower() == "copy" - clone = self.expression( - exp.Clone, - this=self._parse_table(schema=True), - shallow=shallow, - copy=copy, - ) - - if self._curr and not self._match_set( - (TokenType.R_PAREN, TokenType.COMMA), advance=False - ): - return self._parse_as_command(start) - - create_kind_text = create_token.text.upper() - return self.expression( - exp.Create, - this=this, - kind=self.dialect.CREATABLE_KIND_MAPPING.get(create_kind_text) - or create_kind_text, - replace=replace, - refresh=refresh, - unique=unique, - expression=expression, - exists=exists, - properties=properties, - indexes=indexes, - no_schema_binding=no_schema_binding, - begin=begin, - end=end, - clone=clone, - concurrently=concurrently, - clustered=clustered, - ) - - def _parse_sequence_properties(self) -> t.Optional[exp.SequenceProperties]: - seq = exp.SequenceProperties() - - options = [] - index = self._index - - while self._curr: - self._match(TokenType.COMMA) - if self._match_text_seq("INCREMENT"): - self._match_text_seq("BY") - self._match_text_seq("=") - seq.set("increment", self._parse_term()) - elif self._match_text_seq("MINVALUE"): - seq.set("minvalue", self._parse_term()) - elif self._match_text_seq("MAXVALUE"): - seq.set("maxvalue", self._parse_term()) - elif self._match(TokenType.START_WITH) or self._match_text_seq("START"): - self._match_text_seq("=") - seq.set("start", self._parse_term()) - elif self._match_text_seq("CACHE"): - # T-SQL allows empty CACHE which is initialized dynamically - seq.set("cache", self._parse_number() or True) - elif self._match_text_seq("OWNED", "BY"): - # "OWNED BY NONE" is the default - seq.set( - "owned", - None if self._match_text_seq("NONE") else self._parse_column(), - ) - else: - opt = self._parse_var_from_options( - self.CREATE_SEQUENCE, raise_unmatched=False - ) - if opt: - options.append(opt) - else: - break - - seq.set("options", options if options else None) - return None if self._index == index else seq - - def _parse_property_before(self) -> t.Optional[exp.Expression]: - # only used for teradata currently - self._match(TokenType.COMMA) - - kwargs = { - "no": self._match_text_seq("NO"), - "dual": self._match_text_seq("DUAL"), - "before": self._match_text_seq("BEFORE"), - "default": self._match_text_seq("DEFAULT"), - "local": (self._match_text_seq("LOCAL") and "LOCAL") - or (self._match_text_seq("NOT", "LOCAL") and "NOT LOCAL"), - "after": self._match_text_seq("AFTER"), - "minimum": self._match_texts(("MIN", "MINIMUM")), - "maximum": self._match_texts(("MAX", "MAXIMUM")), - } - - if self._match_texts(self.PROPERTY_PARSERS): - parser = self.PROPERTY_PARSERS[self._prev.text.upper()] - try: - return parser(self, **{k: v for k, v in kwargs.items() if v}) - except TypeError: - self.raise_error(f"Cannot parse property '{self._prev.text}'") - - return None - - def _parse_wrapped_properties(self) -> t.List[exp.Expression]: - return self._parse_wrapped_csv(self._parse_property) - - def _parse_property(self) -> t.Optional[exp.Expression]: - if self._match_texts(self.PROPERTY_PARSERS): - return self.PROPERTY_PARSERS[self._prev.text.upper()](self) - - if self._match(TokenType.DEFAULT) and self._match_texts(self.PROPERTY_PARSERS): - return self.PROPERTY_PARSERS[self._prev.text.upper()](self, default=True) - - if self._match_text_seq("COMPOUND", "SORTKEY"): - return self._parse_sortkey(compound=True) - - if self._match_text_seq("SQL", "SECURITY"): - return self.expression( - exp.SqlSecurityProperty, - this=self._match_texts(("DEFINER", "INVOKER")) - and self._prev.text.upper(), - ) - - index = self._index - - seq_props = self._parse_sequence_properties() - if seq_props: - return seq_props - - self._retreat(index) - key = self._parse_column() - - if not self._match(TokenType.EQ): - self._retreat(index) - return None - - # Transform the key to exp.Dot if it's dotted identifiers wrapped in exp.Column or to exp.Var otherwise - if isinstance(key, exp.Column): - key = key.to_dot() if len(key.parts) > 1 else exp.var(key.name) - - value = self._parse_bitwise() or self._parse_var(any_token=True) - - # Transform the value to exp.Var if it was parsed as exp.Column(exp.Identifier()) - if isinstance(value, exp.Column): - value = exp.var(value.name) - - return self.expression(exp.Property, this=key, value=value) - - def _parse_stored( - self, - ) -> t.Union[exp.FileFormatProperty, exp.StorageHandlerProperty]: - if self._match_text_seq("BY"): - return self.expression( - exp.StorageHandlerProperty, this=self._parse_var_or_string() - ) - - self._match(TokenType.ALIAS) - input_format = ( - self._parse_string() if self._match_text_seq("INPUTFORMAT") else None - ) - output_format = ( - self._parse_string() if self._match_text_seq("OUTPUTFORMAT") else None - ) - - return self.expression( - exp.FileFormatProperty, - this=( - self.expression( - exp.InputOutputFormat, - input_format=input_format, - output_format=output_format, - ) - if input_format or output_format - else self._parse_var_or_string() - or self._parse_number() - or self._parse_id_var() - ), - hive_format=True, - ) - - def _parse_unquoted_field(self) -> t.Optional[exp.Expression]: - field = self._parse_field() - if isinstance(field, exp.Identifier) and not field.quoted: - field = exp.var(field) - - return field - - def _parse_property_assignment(self, exp_class: t.Type[E], **kwargs: t.Any) -> E: - self._match(TokenType.EQ) - self._match(TokenType.ALIAS) - - return self.expression(exp_class, this=self._parse_unquoted_field(), **kwargs) - - def _parse_properties( - self, before: t.Optional[bool] = None - ) -> t.Optional[exp.Properties]: - properties = [] - while True: - if before: - prop = self._parse_property_before() - else: - prop = self._parse_property() - if not prop: - break - for p in ensure_list(prop): - properties.append(p) - - if properties: - return self.expression(exp.Properties, expressions=properties) - - return None - - def _parse_fallback(self, no: bool = False) -> exp.FallbackProperty: - return self.expression( - exp.FallbackProperty, no=no, protection=self._match_text_seq("PROTECTION") - ) - - def _parse_security(self) -> t.Optional[exp.SecurityProperty]: - if self._match_texts(("NONE", "DEFINER", "INVOKER")): - security_specifier = self._prev.text.upper() - return self.expression(exp.SecurityProperty, this=security_specifier) - return None - - def _parse_settings_property(self) -> exp.SettingsProperty: - return self.expression( - exp.SettingsProperty, expressions=self._parse_csv(self._parse_assignment) - ) - - def _parse_volatile_property(self) -> exp.VolatileProperty | exp.StabilityProperty: - if self._index >= 2: - pre_volatile_token = self._tokens[self._index - 2] - else: - pre_volatile_token = None - - if ( - pre_volatile_token - and pre_volatile_token.token_type in self.PRE_VOLATILE_TOKENS - ): - return exp.VolatileProperty() - - return self.expression( - exp.StabilityProperty, this=exp.Literal.string("VOLATILE") - ) - - def _parse_retention_period(self) -> exp.Var: - # Parse TSQL's HISTORY_RETENTION_PERIOD: {INFINITE | DAY | DAYS | MONTH ...} - number = self._parse_number() - number_str = f"{number} " if number else "" - unit = self._parse_var(any_token=True) - return exp.var(f"{number_str}{unit}") - - def _parse_system_versioning_property( - self, with_: bool = False - ) -> exp.WithSystemVersioningProperty: - self._match(TokenType.EQ) - prop = self.expression( - exp.WithSystemVersioningProperty, - on=True, - with_=with_, - ) - - if self._match_text_seq("OFF"): - prop.set("on", False) - return prop - - self._match(TokenType.ON) - if self._match(TokenType.L_PAREN): - while self._curr and not self._match(TokenType.R_PAREN): - if self._match_text_seq("HISTORY_TABLE", "="): - prop.set("this", self._parse_table_parts()) - elif self._match_text_seq("DATA_CONSISTENCY_CHECK", "="): - prop.set( - "data_consistency", - self._advance_any() and self._prev.text.upper(), - ) - elif self._match_text_seq("HISTORY_RETENTION_PERIOD", "="): - prop.set("retention_period", self._parse_retention_period()) - - self._match(TokenType.COMMA) - - return prop - - def _parse_data_deletion_property(self) -> exp.DataDeletionProperty: - self._match(TokenType.EQ) - on = self._match_text_seq("ON") or not self._match_text_seq("OFF") - prop = self.expression(exp.DataDeletionProperty, on=on) - - if self._match(TokenType.L_PAREN): - while self._curr and not self._match(TokenType.R_PAREN): - if self._match_text_seq("FILTER_COLUMN", "="): - prop.set("filter_column", self._parse_column()) - elif self._match_text_seq("RETENTION_PERIOD", "="): - prop.set("retention_period", self._parse_retention_period()) - - self._match(TokenType.COMMA) - - return prop - - def _parse_distributed_property(self) -> exp.DistributedByProperty: - kind = "HASH" - expressions: t.Optional[t.List[exp.Expression]] = None - if self._match_text_seq("BY", "HASH"): - expressions = self._parse_wrapped_csv(self._parse_id_var) - elif self._match_text_seq("BY", "RANDOM"): - kind = "RANDOM" - - # If the BUCKETS keyword is not present, the number of buckets is AUTO - buckets: t.Optional[exp.Expression] = None - if self._match_text_seq("BUCKETS") and not self._match_text_seq("AUTO"): - buckets = self._parse_number() - - return self.expression( - exp.DistributedByProperty, - expressions=expressions, - kind=kind, - buckets=buckets, - order=self._parse_order(), - ) - - def _parse_composite_key_property(self, expr_type: t.Type[E]) -> E: - self._match_text_seq("KEY") - expressions = self._parse_wrapped_id_vars() - return self.expression(expr_type, expressions=expressions) - - def _parse_with_property( - self, - ) -> t.Optional[exp.Expression] | t.List[exp.Expression]: - if self._match_text_seq("(", "SYSTEM_VERSIONING"): - prop = self._parse_system_versioning_property(with_=True) - self._match_r_paren() - return prop - - if self._match(TokenType.L_PAREN, advance=False): - return self._parse_wrapped_properties() - - if self._match_text_seq("JOURNAL"): - return self._parse_withjournaltable() - - if self._match_texts(self.VIEW_ATTRIBUTES): - return self.expression( - exp.ViewAttributeProperty, this=self._prev.text.upper() - ) - - if self._match_text_seq("DATA"): - return self._parse_withdata(no=False) - elif self._match_text_seq("NO", "DATA"): - return self._parse_withdata(no=True) - - if self._match(TokenType.SERDE_PROPERTIES, advance=False): - return self._parse_serde_properties(with_=True) - - if self._match(TokenType.SCHEMA): - return self.expression( - exp.WithSchemaBindingProperty, - this=self._parse_var_from_options(self.SCHEMA_BINDING_OPTIONS), - ) - - if self._match_texts(self.PROCEDURE_OPTIONS, advance=False): - return self.expression( - exp.WithProcedureOptions, - expressions=self._parse_csv(self._parse_procedure_option), - ) - - if not self._next: - return None - - return self._parse_withisolatedloading() - - def _parse_procedure_option(self) -> exp.Expression | None: - if self._match_text_seq("EXECUTE", "AS"): - return self.expression( - exp.ExecuteAsProperty, - this=self._parse_var_from_options( - self.EXECUTE_AS_OPTIONS, raise_unmatched=False - ) - or self._parse_string(), - ) - - return self._parse_var_from_options(self.PROCEDURE_OPTIONS) - - # https://dev.mysql.com/doc/refman/8.0/en/create-view.html - def _parse_definer(self) -> t.Optional[exp.DefinerProperty]: - self._match(TokenType.EQ) - - user = self._parse_id_var() - self._match(TokenType.PARAMETER) - host = self._parse_id_var() or (self._match(TokenType.MOD) and self._prev.text) - - if not user or not host: - return None - - return exp.DefinerProperty(this=f"{user}@{host}") - - def _parse_withjournaltable(self) -> exp.WithJournalTableProperty: - self._match(TokenType.TABLE) - self._match(TokenType.EQ) - return self.expression( - exp.WithJournalTableProperty, this=self._parse_table_parts() - ) - - def _parse_log(self, no: bool = False) -> exp.LogProperty: - return self.expression(exp.LogProperty, no=no) - - def _parse_journal(self, **kwargs) -> exp.JournalProperty: - return self.expression(exp.JournalProperty, **kwargs) - - def _parse_checksum(self) -> exp.ChecksumProperty: - self._match(TokenType.EQ) - - on = None - if self._match(TokenType.ON): - on = True - elif self._match_text_seq("OFF"): - on = False - - return self.expression( - exp.ChecksumProperty, on=on, default=self._match(TokenType.DEFAULT) - ) - - def _parse_cluster(self, wrapped: bool = False) -> exp.Cluster: - return self.expression( - exp.Cluster, - expressions=( - self._parse_wrapped_csv(self._parse_ordered) - if wrapped - else self._parse_csv(self._parse_ordered) - ), - ) - - def _parse_clustered_by(self) -> exp.ClusteredByProperty: - self._match_text_seq("BY") - - self._match_l_paren() - expressions = self._parse_csv(self._parse_column) - self._match_r_paren() - - if self._match_text_seq("SORTED", "BY"): - self._match_l_paren() - sorted_by = self._parse_csv(self._parse_ordered) - self._match_r_paren() - else: - sorted_by = None - - self._match(TokenType.INTO) - buckets = self._parse_number() - self._match_text_seq("BUCKETS") - - return self.expression( - exp.ClusteredByProperty, - expressions=expressions, - sorted_by=sorted_by, - buckets=buckets, - ) - - def _parse_copy_property(self) -> t.Optional[exp.CopyGrantsProperty]: - if not self._match_text_seq("GRANTS"): - self._retreat(self._index - 1) - return None - - return self.expression(exp.CopyGrantsProperty) - - def _parse_freespace(self) -> exp.FreespaceProperty: - self._match(TokenType.EQ) - return self.expression( - exp.FreespaceProperty, - this=self._parse_number(), - percent=self._match(TokenType.PERCENT), - ) - - def _parse_mergeblockratio( - self, no: bool = False, default: bool = False - ) -> exp.MergeBlockRatioProperty: - if self._match(TokenType.EQ): - return self.expression( - exp.MergeBlockRatioProperty, - this=self._parse_number(), - percent=self._match(TokenType.PERCENT), - ) - - return self.expression(exp.MergeBlockRatioProperty, no=no, default=default) - - def _parse_datablocksize( - self, - default: t.Optional[bool] = None, - minimum: t.Optional[bool] = None, - maximum: t.Optional[bool] = None, - ) -> exp.DataBlocksizeProperty: - self._match(TokenType.EQ) - size = self._parse_number() - - units = None - if self._match_texts(("BYTES", "KBYTES", "KILOBYTES")): - units = self._prev.text - - return self.expression( - exp.DataBlocksizeProperty, - size=size, - units=units, - default=default, - minimum=minimum, - maximum=maximum, - ) - - def _parse_blockcompression(self) -> exp.BlockCompressionProperty: - self._match(TokenType.EQ) - always = self._match_text_seq("ALWAYS") - manual = self._match_text_seq("MANUAL") - never = self._match_text_seq("NEVER") - default = self._match_text_seq("DEFAULT") - - autotemp = None - if self._match_text_seq("AUTOTEMP"): - autotemp = self._parse_schema() - - return self.expression( - exp.BlockCompressionProperty, - always=always, - manual=manual, - never=never, - default=default, - autotemp=autotemp, - ) - - def _parse_withisolatedloading(self) -> t.Optional[exp.IsolatedLoadingProperty]: - index = self._index - no = self._match_text_seq("NO") - concurrent = self._match_text_seq("CONCURRENT") - - if not self._match_text_seq("ISOLATED", "LOADING"): - self._retreat(index) - return None - - target = self._parse_var_from_options( - self.ISOLATED_LOADING_OPTIONS, raise_unmatched=False - ) - return self.expression( - exp.IsolatedLoadingProperty, no=no, concurrent=concurrent, target=target - ) - - def _parse_locking(self) -> exp.LockingProperty: - if self._match(TokenType.TABLE): - kind = "TABLE" - elif self._match(TokenType.VIEW): - kind = "VIEW" - elif self._match(TokenType.ROW): - kind = "ROW" - elif self._match_text_seq("DATABASE"): - kind = "DATABASE" - else: - kind = None - - if kind in ("DATABASE", "TABLE", "VIEW"): - this = self._parse_table_parts() - else: - this = None - - if self._match(TokenType.FOR): - for_or_in = "FOR" - elif self._match(TokenType.IN): - for_or_in = "IN" - else: - for_or_in = None - - if self._match_text_seq("ACCESS"): - lock_type = "ACCESS" - elif self._match_texts(("EXCL", "EXCLUSIVE")): - lock_type = "EXCLUSIVE" - elif self._match_text_seq("SHARE"): - lock_type = "SHARE" - elif self._match_text_seq("READ"): - lock_type = "READ" - elif self._match_text_seq("WRITE"): - lock_type = "WRITE" - elif self._match_text_seq("CHECKSUM"): - lock_type = "CHECKSUM" - else: - lock_type = None - - override = self._match_text_seq("OVERRIDE") - - return self.expression( - exp.LockingProperty, - this=this, - kind=kind, - for_or_in=for_or_in, - lock_type=lock_type, - override=override, - ) - - def _parse_partition_by(self) -> t.List[exp.Expression]: - if self._match(TokenType.PARTITION_BY): - return self._parse_csv(self._parse_disjunction) - return [] - - def _parse_partition_bound_spec(self) -> exp.PartitionBoundSpec: - def _parse_partition_bound_expr() -> t.Optional[exp.Expression]: - if self._match_text_seq("MINVALUE"): - return exp.var("MINVALUE") - if self._match_text_seq("MAXVALUE"): - return exp.var("MAXVALUE") - return self._parse_bitwise() - - this: t.Optional[exp.Expression | t.List[exp.Expression]] = None - expression = None - from_expressions = None - to_expressions = None - - if self._match(TokenType.IN): - this = self._parse_wrapped_csv(self._parse_bitwise) - elif self._match(TokenType.FROM): - from_expressions = self._parse_wrapped_csv(_parse_partition_bound_expr) - self._match_text_seq("TO") - to_expressions = self._parse_wrapped_csv(_parse_partition_bound_expr) - elif self._match_text_seq("WITH", "(", "MODULUS"): - this = self._parse_number() - self._match_text_seq(",", "REMAINDER") - expression = self._parse_number() - self._match_r_paren() - else: - self.raise_error("Failed to parse partition bound spec.") - - return self.expression( - exp.PartitionBoundSpec, - this=this, - expression=expression, - from_expressions=from_expressions, - to_expressions=to_expressions, - ) - - # https://www.postgresql.org/docs/current/sql-createtable.html - def _parse_partitioned_of(self) -> t.Optional[exp.PartitionedOfProperty]: - if not self._match_text_seq("OF"): - self._retreat(self._index - 1) - return None - - this = self._parse_table(schema=True) - - if self._match(TokenType.DEFAULT): - expression: exp.Var | exp.PartitionBoundSpec = exp.var("DEFAULT") - elif self._match_text_seq("FOR", "VALUES"): - expression = self._parse_partition_bound_spec() - else: - self.raise_error("Expecting either DEFAULT or FOR VALUES clause.") - - return self.expression( - exp.PartitionedOfProperty, this=this, expression=expression - ) - - def _parse_partitioned_by(self) -> exp.PartitionedByProperty: - self._match(TokenType.EQ) - return self.expression( - exp.PartitionedByProperty, - this=self._parse_schema() or self._parse_bracket(self._parse_field()), - ) - - def _parse_withdata(self, no: bool = False) -> exp.WithDataProperty: - if self._match_text_seq("AND", "STATISTICS"): - statistics = True - elif self._match_text_seq("AND", "NO", "STATISTICS"): - statistics = False - else: - statistics = None - - return self.expression(exp.WithDataProperty, no=no, statistics=statistics) - - def _parse_contains_property(self) -> t.Optional[exp.SqlReadWriteProperty]: - if self._match_text_seq("SQL"): - return self.expression(exp.SqlReadWriteProperty, this="CONTAINS SQL") - return None - - def _parse_modifies_property(self) -> t.Optional[exp.SqlReadWriteProperty]: - if self._match_text_seq("SQL", "DATA"): - return self.expression(exp.SqlReadWriteProperty, this="MODIFIES SQL DATA") - return None - - def _parse_no_property(self) -> t.Optional[exp.Expression]: - if self._match_text_seq("PRIMARY", "INDEX"): - return exp.NoPrimaryIndexProperty() - if self._match_text_seq("SQL"): - return self.expression(exp.SqlReadWriteProperty, this="NO SQL") - return None - - def _parse_on_property(self) -> t.Optional[exp.Expression]: - if self._match_text_seq("COMMIT", "PRESERVE", "ROWS"): - return exp.OnCommitProperty() - if self._match_text_seq("COMMIT", "DELETE", "ROWS"): - return exp.OnCommitProperty(delete=True) - return self.expression( - exp.OnProperty, this=self._parse_schema(self._parse_id_var()) - ) - - def _parse_reads_property(self) -> t.Optional[exp.SqlReadWriteProperty]: - if self._match_text_seq("SQL", "DATA"): - return self.expression(exp.SqlReadWriteProperty, this="READS SQL DATA") - return None - - def _parse_distkey(self) -> exp.DistKeyProperty: - return self.expression( - exp.DistKeyProperty, this=self._parse_wrapped(self._parse_id_var) - ) - - def _parse_create_like(self) -> t.Optional[exp.LikeProperty]: - table = self._parse_table(schema=True) - - options = [] - while self._match_texts(("INCLUDING", "EXCLUDING")): - this = self._prev.text.upper() - - id_var = self._parse_id_var() - if not id_var: - return None - - options.append( - self.expression( - exp.Property, this=this, value=exp.var(id_var.this.upper()) - ) - ) - - return self.expression(exp.LikeProperty, this=table, expressions=options) - - def _parse_sortkey(self, compound: bool = False) -> exp.SortKeyProperty: - return self.expression( - exp.SortKeyProperty, this=self._parse_wrapped_id_vars(), compound=compound - ) - - def _parse_character_set(self, default: bool = False) -> exp.CharacterSetProperty: - self._match(TokenType.EQ) - return self.expression( - exp.CharacterSetProperty, this=self._parse_var_or_string(), default=default - ) - - def _parse_remote_with_connection(self) -> exp.RemoteWithConnectionModelProperty: - self._match_text_seq("WITH", "CONNECTION") - return self.expression( - exp.RemoteWithConnectionModelProperty, this=self._parse_table_parts() - ) - - def _parse_returns(self) -> exp.ReturnsProperty: - value: t.Optional[exp.Expression] - null = None - is_table = self._match(TokenType.TABLE) - - if is_table: - if self._match(TokenType.LT): - value = self.expression( - exp.Schema, - this="TABLE", - expressions=self._parse_csv(self._parse_struct_types), - ) - if not self._match(TokenType.GT): - self.raise_error("Expecting >") - else: - value = self._parse_schema(exp.var("TABLE")) - elif self._match_text_seq("NULL", "ON", "NULL", "INPUT"): - null = True - value = None - else: - value = self._parse_types() - - return self.expression( - exp.ReturnsProperty, this=value, is_table=is_table, null=null - ) - - def _parse_describe(self) -> exp.Describe: - kind = self._match_set(self.CREATABLES) and self._prev.text - style = self._match_texts(self.DESCRIBE_STYLES) and self._prev.text.upper() - if self._match(TokenType.DOT): - style = None - self._retreat(self._index - 2) - - format = ( - self._parse_property() - if self._match(TokenType.FORMAT, advance=False) - else None - ) - - if self._match_set(self.STATEMENT_PARSERS, advance=False): - this = self._parse_statement() - else: - this = self._parse_table(schema=True) - - properties = self._parse_properties() - expressions = properties.expressions if properties else None - partition = self._parse_partition() - return self.expression( - exp.Describe, - this=this, - style=style, - kind=kind, - expressions=expressions, - partition=partition, - format=format, - ) - - def _parse_multitable_inserts( - self, comments: t.Optional[t.List[str]] - ) -> exp.MultitableInserts: - kind = self._prev.text.upper() - expressions = [] - - def parse_conditional_insert() -> t.Optional[exp.ConditionalInsert]: - if self._match(TokenType.WHEN): - expression = self._parse_disjunction() - self._match(TokenType.THEN) - else: - expression = None - - else_ = self._match(TokenType.ELSE) - - if not self._match(TokenType.INTO): - return None - - return self.expression( - exp.ConditionalInsert, - this=self.expression( - exp.Insert, - this=self._parse_table(schema=True), - expression=self._parse_derived_table_values(), - ), - expression=expression, - else_=else_, - ) - - expression = parse_conditional_insert() - while expression is not None: - expressions.append(expression) - expression = parse_conditional_insert() - - return self.expression( - exp.MultitableInserts, - kind=kind, - comments=comments, - expressions=expressions, - source=self._parse_table(), - ) - - def _parse_insert(self) -> t.Union[exp.Insert, exp.MultitableInserts]: - comments = [] - hint = self._parse_hint() - overwrite = self._match(TokenType.OVERWRITE) - ignore = self._match(TokenType.IGNORE) - local = self._match_text_seq("LOCAL") - alternative = None - is_function = None - - if self._match_text_seq("DIRECTORY"): - this: t.Optional[exp.Expression] = self.expression( - exp.Directory, - this=self._parse_var_or_string(), - local=local, - row_format=self._parse_row_format(match_row=True), - ) - else: - if self._match_set((TokenType.FIRST, TokenType.ALL)): - comments += ensure_list(self._prev_comments) - return self._parse_multitable_inserts(comments) - - if self._match(TokenType.OR): - alternative = ( - self._match_texts(self.INSERT_ALTERNATIVES) and self._prev.text - ) - - self._match(TokenType.INTO) - comments += ensure_list(self._prev_comments) - self._match(TokenType.TABLE) - is_function = self._match(TokenType.FUNCTION) - - this = self._parse_function() if is_function else self._parse_insert_table() - - returning = self._parse_returning() # TSQL allows RETURNING before source - - return self.expression( - exp.Insert, - comments=comments, - hint=hint, - is_function=is_function, - this=this, - stored=self._match_text_seq("STORED") and self._parse_stored(), - by_name=self._match_text_seq("BY", "NAME"), - exists=self._parse_exists(), - where=self._match_pair(TokenType.REPLACE, TokenType.WHERE) - and self._parse_disjunction(), - partition=self._match(TokenType.PARTITION_BY) - and self._parse_partitioned_by(), - settings=self._match_text_seq("SETTINGS") - and self._parse_settings_property(), - default=self._match_text_seq("DEFAULT", "VALUES"), - expression=self._parse_derived_table_values() or self._parse_ddl_select(), - conflict=self._parse_on_conflict(), - returning=returning or self._parse_returning(), - overwrite=overwrite, - alternative=alternative, - ignore=ignore, - source=self._match(TokenType.TABLE) and self._parse_table(), - ) - - def _parse_insert_table(self) -> t.Optional[exp.Expression]: - this = self._parse_table(schema=True, parse_partition=True) - if isinstance(this, exp.Table) and self._match(TokenType.ALIAS, advance=False): - this.set("alias", self._parse_table_alias()) - return this - - def _parse_kill(self) -> exp.Kill: - kind = ( - exp.var(self._prev.text) - if self._match_texts(("CONNECTION", "QUERY")) - else None - ) - - return self.expression( - exp.Kill, - this=self._parse_primary(), - kind=kind, - ) - - def _parse_on_conflict(self) -> t.Optional[exp.OnConflict]: - conflict = self._match_text_seq("ON", "CONFLICT") - duplicate = self._match_text_seq("ON", "DUPLICATE", "KEY") - - if not conflict and not duplicate: - return None - - conflict_keys = None - constraint = None - - if conflict: - if self._match_text_seq("ON", "CONSTRAINT"): - constraint = self._parse_id_var() - elif self._match(TokenType.L_PAREN): - conflict_keys = self._parse_csv(self._parse_id_var) - self._match_r_paren() - - action = self._parse_var_from_options(self.CONFLICT_ACTIONS) - if self._prev.token_type == TokenType.UPDATE: - self._match(TokenType.SET) - expressions = self._parse_csv(self._parse_equality) - else: - expressions = None - - return self.expression( - exp.OnConflict, - duplicate=duplicate, - expressions=expressions, - action=action, - conflict_keys=conflict_keys, - constraint=constraint, - where=self._parse_where(), - ) - - def _parse_returning(self) -> t.Optional[exp.Returning]: - if not self._match(TokenType.RETURNING): - return None - return self.expression( - exp.Returning, - expressions=self._parse_csv(self._parse_expression), - into=self._match(TokenType.INTO) and self._parse_table_part(), - ) - - def _parse_row( - self, - ) -> t.Optional[exp.RowFormatSerdeProperty | exp.RowFormatDelimitedProperty]: - if not self._match(TokenType.FORMAT): - return None - return self._parse_row_format() - - def _parse_serde_properties( - self, with_: bool = False - ) -> t.Optional[exp.SerdeProperties]: - index = self._index - with_ = with_ or self._match_text_seq("WITH") - - if not self._match(TokenType.SERDE_PROPERTIES): - self._retreat(index) - return None - return self.expression( - exp.SerdeProperties, - expressions=self._parse_wrapped_properties(), - with_=with_, - ) - - def _parse_row_format( - self, match_row: bool = False - ) -> t.Optional[exp.RowFormatSerdeProperty | exp.RowFormatDelimitedProperty]: - if match_row and not self._match_pair(TokenType.ROW, TokenType.FORMAT): - return None - - if self._match_text_seq("SERDE"): - this = self._parse_string() - - serde_properties = self._parse_serde_properties() - - return self.expression( - exp.RowFormatSerdeProperty, this=this, serde_properties=serde_properties - ) - - self._match_text_seq("DELIMITED") - - kwargs = {} - - if self._match_text_seq("FIELDS", "TERMINATED", "BY"): - kwargs["fields"] = self._parse_string() - if self._match_text_seq("ESCAPED", "BY"): - kwargs["escaped"] = self._parse_string() - if self._match_text_seq("COLLECTION", "ITEMS", "TERMINATED", "BY"): - kwargs["collection_items"] = self._parse_string() - if self._match_text_seq("MAP", "KEYS", "TERMINATED", "BY"): - kwargs["map_keys"] = self._parse_string() - if self._match_text_seq("LINES", "TERMINATED", "BY"): - kwargs["lines"] = self._parse_string() - if self._match_text_seq("NULL", "DEFINED", "AS"): - kwargs["null"] = self._parse_string() - - return self.expression(exp.RowFormatDelimitedProperty, **kwargs) # type: ignore - - def _parse_load(self) -> exp.LoadData | exp.Command: - if self._match_text_seq("DATA"): - local = self._match_text_seq("LOCAL") - self._match_text_seq("INPATH") - inpath = self._parse_string() - overwrite = self._match(TokenType.OVERWRITE) - self._match_pair(TokenType.INTO, TokenType.TABLE) - - return self.expression( - exp.LoadData, - this=self._parse_table(schema=True), - local=local, - overwrite=overwrite, - inpath=inpath, - partition=self._parse_partition(), - input_format=self._match_text_seq("INPUTFORMAT") - and self._parse_string(), - serde=self._match_text_seq("SERDE") and self._parse_string(), - ) - return self._parse_as_command(self._prev) - - def _parse_delete(self) -> exp.Delete: - # This handles MySQL's "Multiple-Table Syntax" - # https://dev.mysql.com/doc/refman/8.0/en/delete.html - tables = None - if not self._match(TokenType.FROM, advance=False): - tables = self._parse_csv(self._parse_table) or None - - returning = self._parse_returning() - - return self.expression( - exp.Delete, - tables=tables, - this=self._match(TokenType.FROM) and self._parse_table(joins=True), - using=self._match(TokenType.USING) - and self._parse_csv(lambda: self._parse_table(joins=True)), - cluster=self._match(TokenType.ON) and self._parse_on_property(), - where=self._parse_where(), - returning=returning or self._parse_returning(), - order=self._parse_order(), - limit=self._parse_limit(), - ) - - def _parse_update(self) -> exp.Update: - kwargs: t.Dict[str, t.Any] = { - "this": self._parse_table( - joins=True, alias_tokens=self.UPDATE_ALIAS_TOKENS - ), - } - while self._curr: - if self._match(TokenType.SET): - kwargs["expressions"] = self._parse_csv(self._parse_equality) - elif self._match(TokenType.RETURNING, advance=False): - kwargs["returning"] = self._parse_returning() - elif self._match(TokenType.FROM, advance=False): - kwargs["from_"] = self._parse_from(joins=True) - elif self._match(TokenType.WHERE, advance=False): - kwargs["where"] = self._parse_where() - elif self._match(TokenType.ORDER_BY, advance=False): - kwargs["order"] = self._parse_order() - elif self._match(TokenType.LIMIT, advance=False): - kwargs["limit"] = self._parse_limit() - else: - break - - return self.expression(exp.Update, **kwargs) - - def _parse_use(self) -> exp.Use: - return self.expression( - exp.Use, - kind=self._parse_var_from_options(self.USABLES, raise_unmatched=False), - this=self._parse_table(schema=False), - ) - - def _parse_uncache(self) -> exp.Uncache: - if not self._match(TokenType.TABLE): - self.raise_error("Expecting TABLE after UNCACHE") - - return self.expression( - exp.Uncache, - exists=self._parse_exists(), - this=self._parse_table(schema=True), - ) - - def _parse_cache(self) -> exp.Cache: - lazy = self._match_text_seq("LAZY") - self._match(TokenType.TABLE) - table = self._parse_table(schema=True) - - options = [] - if self._match_text_seq("OPTIONS"): - self._match_l_paren() - k = self._parse_string() - self._match(TokenType.EQ) - v = self._parse_string() - options = [k, v] - self._match_r_paren() - - self._match(TokenType.ALIAS) - return self.expression( - exp.Cache, - this=table, - lazy=lazy, - options=options, - expression=self._parse_select(nested=True), - ) - - def _parse_partition(self) -> t.Optional[exp.Partition]: - if not self._match_texts(self.PARTITION_KEYWORDS): - return None - - return self.expression( - exp.Partition, - subpartition=self._prev.text.upper() == "SUBPARTITION", - expressions=self._parse_wrapped_csv(self._parse_disjunction), - ) - - def _parse_value(self, values: bool = True) -> t.Optional[exp.Tuple]: - def _parse_value_expression() -> t.Optional[exp.Expression]: - if self.dialect.SUPPORTS_VALUES_DEFAULT and self._match(TokenType.DEFAULT): - return exp.var(self._prev.text.upper()) - return self._parse_expression() - - if self._match(TokenType.L_PAREN): - expressions = self._parse_csv(_parse_value_expression) - self._match_r_paren() - return self.expression(exp.Tuple, expressions=expressions) - - # In some dialects we can have VALUES 1, 2 which results in 1 column & 2 rows. - expression = self._parse_expression() - if expression: - return self.expression(exp.Tuple, expressions=[expression]) - return None - - def _parse_projections(self) -> t.List[exp.Expression]: - return self._parse_expressions() - - def _parse_wrapped_select(self, table: bool = False) -> t.Optional[exp.Expression]: - if self._match_set((TokenType.PIVOT, TokenType.UNPIVOT)): - this: t.Optional[exp.Expression] = self._parse_simplified_pivot( - is_unpivot=self._prev.token_type == TokenType.UNPIVOT - ) - elif self._match(TokenType.FROM): - from_ = self._parse_from(skip_from_token=True, consume_pipe=True) - # Support parentheses for duckdb FROM-first syntax - select = self._parse_select(from_=from_) - if select: - if not select.args.get("from_"): - select.set("from_", from_) - this = select - else: - this = exp.select("*").from_(t.cast(exp.From, from_)) - this = self._parse_query_modifiers(self._parse_set_operations(this)) - else: - this = ( - self._parse_table(consume_pipe=True) - if table - else self._parse_select(nested=True, parse_set_operation=False) - ) - - # Transform exp.Values into a exp.Table to pass through parse_query_modifiers - # in case a modifier (e.g. join) is following - if table and isinstance(this, exp.Values) and this.alias: - alias = this.args["alias"].pop() - this = exp.Table(this=this, alias=alias) - - this = self._parse_query_modifiers(self._parse_set_operations(this)) - - return this - - def _parse_select( - self, - nested: bool = False, - table: bool = False, - parse_subquery_alias: bool = True, - parse_set_operation: bool = True, - consume_pipe: bool = True, - from_: t.Optional[exp.From] = None, - ) -> t.Optional[exp.Expression]: - query = self._parse_select_query( - nested=nested, - table=table, - parse_subquery_alias=parse_subquery_alias, - parse_set_operation=parse_set_operation, - ) - - if consume_pipe and self._match(TokenType.PIPE_GT, advance=False): - if not query and from_: - query = exp.select("*").from_(from_) - if isinstance(query, exp.Query): - query = self._parse_pipe_syntax_query(query) - query = query.subquery(copy=False) if query and table else query - - return query - - def _parse_select_query( - self, - nested: bool = False, - table: bool = False, - parse_subquery_alias: bool = True, - parse_set_operation: bool = True, - ) -> t.Optional[exp.Expression]: - cte = self._parse_with() - - if cte: - this = self._parse_statement() - - if not this: - self.raise_error("Failed to parse any statement following CTE") - return cte - - while isinstance(this, exp.Subquery) and this.is_wrapper: - this = this.this - - if "with_" in this.arg_types: - this.set("with_", cte) - else: - self.raise_error(f"{this.key} does not support CTE") - this = cte - - return this - - # duckdb supports leading with FROM x - from_ = ( - self._parse_from(joins=True, consume_pipe=True) - if self._match(TokenType.FROM, advance=False) - else None - ) - - if self._match(TokenType.SELECT): - comments = self._prev_comments - - hint = self._parse_hint() - - if self._next and not self._next.token_type == TokenType.DOT: - all_ = self._match(TokenType.ALL) - distinct = self._match_set(self.DISTINCT_TOKENS) - else: - all_, distinct = None, None - - kind = ( - self._match(TokenType.ALIAS) - and self._match_texts(("STRUCT", "VALUE")) - and self._prev.text.upper() - ) - - if distinct: - distinct = self.expression( - exp.Distinct, - on=self._parse_value(values=False) - if self._match(TokenType.ON) - else None, - ) - - if all_ and distinct: - self.raise_error("Cannot specify both ALL and DISTINCT after SELECT") - - operation_modifiers = [] - while self._curr and self._match_texts(self.OPERATION_MODIFIERS): - operation_modifiers.append(exp.var(self._prev.text.upper())) - - limit = self._parse_limit(top=True) - projections = self._parse_projections() - - this = self.expression( - exp.Select, - kind=kind, - hint=hint, - distinct=distinct, - expressions=projections, - limit=limit, - operation_modifiers=operation_modifiers or None, - ) - this.comments = comments - - into = self._parse_into() - if into: - this.set("into", into) - - if not from_: - from_ = self._parse_from() - - if from_: - this.set("from_", from_) - - this = self._parse_query_modifiers(this) - elif (table or nested) and self._match(TokenType.L_PAREN): - this = self._parse_wrapped_select(table=table) - - # We return early here so that the UNION isn't attached to the subquery by the - # following call to _parse_set_operations, but instead becomes the parent node - self._match_r_paren() - return self._parse_subquery(this, parse_alias=parse_subquery_alias) - elif self._match(TokenType.VALUES, advance=False): - this = self._parse_derived_table_values() - elif from_: - this = exp.select("*").from_(from_.this, copy=False) - elif self._match(TokenType.SUMMARIZE): - table = self._match(TokenType.TABLE) - this = self._parse_select() or self._parse_string() or self._parse_table() - return self.expression(exp.Summarize, this=this, table=table) - elif self._match(TokenType.DESCRIBE): - this = self._parse_describe() - else: - this = None - - return self._parse_set_operations(this) if parse_set_operation else this - - def _parse_recursive_with_search(self) -> t.Optional[exp.RecursiveWithSearch]: - self._match_text_seq("SEARCH") - - kind = ( - self._match_texts(self.RECURSIVE_CTE_SEARCH_KIND) - and self._prev.text.upper() - ) - - if not kind: - return None - - self._match_text_seq("FIRST", "BY") - - return self.expression( - exp.RecursiveWithSearch, - kind=kind, - this=self._parse_id_var(), - expression=self._match_text_seq("SET") and self._parse_id_var(), - using=self._match_text_seq("USING") and self._parse_id_var(), - ) - - def _parse_with(self, skip_with_token: bool = False) -> t.Optional[exp.With]: - if not skip_with_token and not self._match(TokenType.WITH): - return None - - comments = self._prev_comments - recursive = self._match(TokenType.RECURSIVE) - - last_comments = None - expressions = [] - while True: - cte = self._parse_cte() - if isinstance(cte, exp.CTE): - expressions.append(cte) - if last_comments: - cte.add_comments(last_comments) - - if not self._match(TokenType.COMMA) and not self._match(TokenType.WITH): - break - else: - self._match(TokenType.WITH) - - last_comments = self._prev_comments - - return self.expression( - exp.With, - comments=comments, - expressions=expressions, - recursive=recursive, - search=self._parse_recursive_with_search(), - ) - - def _parse_cte(self) -> t.Optional[exp.CTE]: - index = self._index - - alias = self._parse_table_alias(self.ID_VAR_TOKENS) - if not alias or not alias.this: - self.raise_error("Expected CTE to have alias") - - key_expressions = ( - self._parse_wrapped_id_vars() - if self._match_text_seq("USING", "KEY") - else None - ) - - if not self._match(TokenType.ALIAS) and not self.OPTIONAL_ALIAS_TOKEN_CTE: - self._retreat(index) - return None - - comments = self._prev_comments - - if self._match_text_seq("NOT", "MATERIALIZED"): - materialized = False - elif self._match_text_seq("MATERIALIZED"): - materialized = True - else: - materialized = None - - cte = self.expression( - exp.CTE, - this=self._parse_wrapped(self._parse_statement), - alias=alias, - materialized=materialized, - key_expressions=key_expressions, - comments=comments, - ) - - values = cte.this - if isinstance(values, exp.Values): - if values.alias: - cte.set("this", exp.select("*").from_(values)) - else: - cte.set( - "this", - exp.select("*").from_(exp.alias_(values, "_values", table=True)), - ) - - return cte - - def _parse_table_alias( - self, alias_tokens: t.Optional[t.Collection[TokenType]] = None - ) -> t.Optional[exp.TableAlias]: - # In some dialects, LIMIT and OFFSET can act as both identifiers and keywords (clauses) - # so this section tries to parse the clause version and if it fails, it treats the token - # as an identifier (alias) - if self._can_parse_limit_or_offset(): - return None - - any_token = self._match(TokenType.ALIAS) - alias = ( - self._parse_id_var( - any_token=any_token, tokens=alias_tokens or self.TABLE_ALIAS_TOKENS - ) - or self._parse_string_as_identifier() - ) - - index = self._index - if self._match(TokenType.L_PAREN): - columns = self._parse_csv(self._parse_function_parameter) - self._match_r_paren() if columns else self._retreat(index) - else: - columns = None - - if not alias and not columns: - return None - - table_alias = self.expression(exp.TableAlias, this=alias, columns=columns) - - # We bubble up comments from the Identifier to the TableAlias - if isinstance(alias, exp.Identifier): - table_alias.add_comments(alias.pop_comments()) - - return table_alias - - def _parse_subquery( - self, this: t.Optional[exp.Expression], parse_alias: bool = True - ) -> t.Optional[exp.Subquery]: - if not this: - return None - - return self.expression( - exp.Subquery, - this=this, - pivots=self._parse_pivots(), - alias=self._parse_table_alias() if parse_alias else None, - sample=self._parse_table_sample(), - ) - - def _implicit_unnests_to_explicit(self, this: E) -> E: - from bigframes_vendored.sqlglot.optimizer.normalize_identifiers import ( - normalize_identifiers as _norm, - ) - - refs = { - _norm(this.args["from_"].this.copy(), dialect=self.dialect).alias_or_name - } - for i, join in enumerate(this.args.get("joins") or []): - table = join.this - normalized_table = table.copy() - normalized_table.meta["maybe_column"] = True - normalized_table = _norm(normalized_table, dialect=self.dialect) - - if isinstance(table, exp.Table) and not join.args.get("on"): - if normalized_table.parts[0].name in refs: - table_as_column = table.to_column() - unnest = exp.Unnest(expressions=[table_as_column]) - - # Table.to_column creates a parent Alias node that we want to convert to - # a TableAlias and attach to the Unnest, so it matches the parser's output - if isinstance(table.args.get("alias"), exp.TableAlias): - table_as_column.replace(table_as_column.this) - exp.alias_( - unnest, None, table=[table.args["alias"].this], copy=False - ) - - table.replace(unnest) - - refs.add(normalized_table.alias_or_name) - - return this - - @t.overload - def _parse_query_modifiers(self, this: E) -> E: ... - - @t.overload - def _parse_query_modifiers(self, this: None) -> None: ... - - def _parse_query_modifiers(self, this): - if isinstance(this, self.MODIFIABLES): - for join in self._parse_joins(): - this.append("joins", join) - for lateral in iter(self._parse_lateral, None): - this.append("laterals", lateral) - - while True: - if self._match_set(self.QUERY_MODIFIER_PARSERS, advance=False): - modifier_token = self._curr - parser = self.QUERY_MODIFIER_PARSERS[modifier_token.token_type] - key, expression = parser(self) - - if expression: - if this.args.get(key): - self.raise_error( - f"Found multiple '{modifier_token.text.upper()}' clauses", - token=modifier_token, - ) - - this.set(key, expression) - if key == "limit": - offset = expression.args.get("offset") - expression.set("offset", None) - - if offset: - offset = exp.Offset(expression=offset) - this.set("offset", offset) - - limit_by_expressions = expression.expressions - expression.set("expressions", None) - offset.set("expressions", limit_by_expressions) - continue - break - - if self.SUPPORTS_IMPLICIT_UNNEST and this and this.args.get("from_"): - this = self._implicit_unnests_to_explicit(this) - - return this - - def _parse_hint_fallback_to_string(self) -> t.Optional[exp.Hint]: - start = self._curr - while self._curr: - self._advance() - - end = self._tokens[self._index - 1] - return exp.Hint(expressions=[self._find_sql(start, end)]) - - def _parse_hint_function_call(self) -> t.Optional[exp.Expression]: - return self._parse_function_call() - - def _parse_hint_body(self) -> t.Optional[exp.Hint]: - start_index = self._index - should_fallback_to_string = False - - hints = [] - try: - for hint in iter( - lambda: self._parse_csv( - lambda: ( - self._parse_hint_function_call() or self._parse_var(upper=True) - ), - ), - [], - ): - hints.extend(hint) - except ParseError: - should_fallback_to_string = True - - if should_fallback_to_string or self._curr: - self._retreat(start_index) - return self._parse_hint_fallback_to_string() - - return self.expression(exp.Hint, expressions=hints) - - def _parse_hint(self) -> t.Optional[exp.Hint]: - if self._match(TokenType.HINT) and self._prev_comments: - return exp.maybe_parse( - self._prev_comments[0], into=exp.Hint, dialect=self.dialect - ) - - return None - - def _parse_into(self) -> t.Optional[exp.Into]: - if not self._match(TokenType.INTO): - return None - - temp = self._match(TokenType.TEMPORARY) - unlogged = self._match_text_seq("UNLOGGED") - self._match(TokenType.TABLE) - - return self.expression( - exp.Into, - this=self._parse_table(schema=True), - temporary=temp, - unlogged=unlogged, - ) - - def _parse_from( - self, - joins: bool = False, - skip_from_token: bool = False, - consume_pipe: bool = False, - ) -> t.Optional[exp.From]: - if not skip_from_token and not self._match(TokenType.FROM): - return None - - return self.expression( - exp.From, - comments=self._prev_comments, - this=self._parse_table(joins=joins, consume_pipe=consume_pipe), - ) - - def _parse_match_recognize_measure(self) -> exp.MatchRecognizeMeasure: - return self.expression( - exp.MatchRecognizeMeasure, - window_frame=self._match_texts(("FINAL", "RUNNING")) - and self._prev.text.upper(), - this=self._parse_expression(), - ) - - def _parse_match_recognize(self) -> t.Optional[exp.MatchRecognize]: - if not self._match(TokenType.MATCH_RECOGNIZE): - return None - - self._match_l_paren() - - partition = self._parse_partition_by() - order = self._parse_order() - - measures = ( - self._parse_csv(self._parse_match_recognize_measure) - if self._match_text_seq("MEASURES") - else None - ) - - if self._match_text_seq("ONE", "ROW", "PER", "MATCH"): - rows = exp.var("ONE ROW PER MATCH") - elif self._match_text_seq("ALL", "ROWS", "PER", "MATCH"): - text = "ALL ROWS PER MATCH" - if self._match_text_seq("SHOW", "EMPTY", "MATCHES"): - text += " SHOW EMPTY MATCHES" - elif self._match_text_seq("OMIT", "EMPTY", "MATCHES"): - text += " OMIT EMPTY MATCHES" - elif self._match_text_seq("WITH", "UNMATCHED", "ROWS"): - text += " WITH UNMATCHED ROWS" - rows = exp.var(text) - else: - rows = None - - if self._match_text_seq("AFTER", "MATCH", "SKIP"): - text = "AFTER MATCH SKIP" - if self._match_text_seq("PAST", "LAST", "ROW"): - text += " PAST LAST ROW" - elif self._match_text_seq("TO", "NEXT", "ROW"): - text += " TO NEXT ROW" - elif self._match_text_seq("TO", "FIRST"): - text += f" TO FIRST {self._advance_any().text}" # type: ignore - elif self._match_text_seq("TO", "LAST"): - text += f" TO LAST {self._advance_any().text}" # type: ignore - after = exp.var(text) - else: - after = None - - if self._match_text_seq("PATTERN"): - self._match_l_paren() - - if not self._curr: - self.raise_error("Expecting )", self._curr) - - paren = 1 - start = self._curr - - while self._curr and paren > 0: - if self._curr.token_type == TokenType.L_PAREN: - paren += 1 - if self._curr.token_type == TokenType.R_PAREN: - paren -= 1 - - end = self._prev - self._advance() - - if paren > 0: - self.raise_error("Expecting )", self._curr) - - pattern = exp.var(self._find_sql(start, end)) - else: - pattern = None - - define = ( - self._parse_csv(self._parse_name_as_expression) - if self._match_text_seq("DEFINE") - else None - ) - - self._match_r_paren() - - return self.expression( - exp.MatchRecognize, - partition_by=partition, - order=order, - measures=measures, - rows=rows, - after=after, - pattern=pattern, - define=define, - alias=self._parse_table_alias(), - ) - - def _parse_lateral(self) -> t.Optional[exp.Lateral]: - cross_apply = self._match_pair(TokenType.CROSS, TokenType.APPLY) - if not cross_apply and self._match_pair(TokenType.OUTER, TokenType.APPLY): - cross_apply = False - - if cross_apply is not None: - this = self._parse_select(table=True) - view = None - outer = None - elif self._match(TokenType.LATERAL): - this = self._parse_select(table=True) - view = self._match(TokenType.VIEW) - outer = self._match(TokenType.OUTER) - else: - return None - - if not this: - this = ( - self._parse_unnest() - or self._parse_function() - or self._parse_id_var(any_token=False) - ) - - while self._match(TokenType.DOT): - this = exp.Dot( - this=this, - expression=self._parse_function() - or self._parse_id_var(any_token=False), - ) - - ordinality: t.Optional[bool] = None - - if view: - table = self._parse_id_var(any_token=False) - columns = ( - self._parse_csv(self._parse_id_var) - if self._match(TokenType.ALIAS) - else [] - ) - table_alias: t.Optional[exp.TableAlias] = self.expression( - exp.TableAlias, this=table, columns=columns - ) - elif isinstance(this, (exp.Subquery, exp.Unnest)) and this.alias: - # We move the alias from the lateral's child node to the lateral itself - table_alias = this.args["alias"].pop() - else: - ordinality = self._match_pair(TokenType.WITH, TokenType.ORDINALITY) - table_alias = self._parse_table_alias() - - return self.expression( - exp.Lateral, - this=this, - view=view, - outer=outer, - alias=table_alias, - cross_apply=cross_apply, - ordinality=ordinality, - ) - - def _parse_stream(self) -> t.Optional[exp.Stream]: - index = self._index - if self._match_text_seq("STREAM"): - this = self._try_parse(self._parse_table) - if this: - return self.expression(exp.Stream, this=this) - - self._retreat(index) - return None - - def _parse_join_parts( - self, - ) -> t.Tuple[t.Optional[Token], t.Optional[Token], t.Optional[Token]]: - return ( - self._match_set(self.JOIN_METHODS) and self._prev, - self._match_set(self.JOIN_SIDES) and self._prev, - self._match_set(self.JOIN_KINDS) and self._prev, - ) - - def _parse_using_identifiers(self) -> t.List[exp.Expression]: - def _parse_column_as_identifier() -> t.Optional[exp.Expression]: - this = self._parse_column() - if isinstance(this, exp.Column): - return this.this - return this - - return self._parse_wrapped_csv(_parse_column_as_identifier, optional=True) - - def _parse_join( - self, skip_join_token: bool = False, parse_bracket: bool = False - ) -> t.Optional[exp.Join]: - if self._match(TokenType.COMMA): - table = self._try_parse(self._parse_table) - cross_join = self.expression(exp.Join, this=table) if table else None - - if cross_join and self.JOINS_HAVE_EQUAL_PRECEDENCE: - cross_join.set("kind", "CROSS") - - return cross_join - - index = self._index - method, side, kind = self._parse_join_parts() - hint = self._prev.text if self._match_texts(self.JOIN_HINTS) else None - join = self._match(TokenType.JOIN) or ( - kind and kind.token_type == TokenType.STRAIGHT_JOIN - ) - join_comments = self._prev_comments - - if not skip_join_token and not join: - self._retreat(index) - kind = None - method = None - side = None - - outer_apply = self._match_pair(TokenType.OUTER, TokenType.APPLY, False) - cross_apply = self._match_pair(TokenType.CROSS, TokenType.APPLY, False) - - if not skip_join_token and not join and not outer_apply and not cross_apply: - return None - - kwargs: t.Dict[str, t.Any] = { - "this": self._parse_table(parse_bracket=parse_bracket) - } - if kind and kind.token_type == TokenType.ARRAY and self._match(TokenType.COMMA): - kwargs["expressions"] = self._parse_csv( - lambda: self._parse_table(parse_bracket=parse_bracket) - ) - - if method: - kwargs["method"] = method.text.upper() - if side: - kwargs["side"] = side.text.upper() - if kind: - kwargs["kind"] = kind.text.upper() - if hint: - kwargs["hint"] = hint - - if self._match(TokenType.MATCH_CONDITION): - kwargs["match_condition"] = self._parse_wrapped(self._parse_comparison) - - if self._match(TokenType.ON): - kwargs["on"] = self._parse_disjunction() - elif self._match(TokenType.USING): - kwargs["using"] = self._parse_using_identifiers() - elif ( - not method - and not (outer_apply or cross_apply) - and not isinstance(kwargs["this"], exp.Unnest) - and not (kind and kind.token_type in (TokenType.CROSS, TokenType.ARRAY)) - ): - index = self._index - joins: t.Optional[list] = list(self._parse_joins()) - - if joins and self._match(TokenType.ON): - kwargs["on"] = self._parse_disjunction() - elif joins and self._match(TokenType.USING): - kwargs["using"] = self._parse_using_identifiers() - else: - joins = None - self._retreat(index) - - kwargs["this"].set("joins", joins if joins else None) - - kwargs["pivots"] = self._parse_pivots() - - comments = [ - c for token in (method, side, kind) if token for c in token.comments - ] - comments = (join_comments or []) + comments - - if ( - self.ADD_JOIN_ON_TRUE - and not kwargs.get("on") - and not kwargs.get("using") - and not kwargs.get("method") - and kwargs.get("kind") in (None, "INNER", "OUTER") - ): - kwargs["on"] = exp.true() - - return self.expression(exp.Join, comments=comments, **kwargs) - - def _parse_opclass(self) -> t.Optional[exp.Expression]: - this = self._parse_disjunction() - - if self._match_texts(self.OPCLASS_FOLLOW_KEYWORDS, advance=False): - return this - - if not self._match_set(self.OPTYPE_FOLLOW_TOKENS, advance=False): - return self.expression( - exp.Opclass, this=this, expression=self._parse_table_parts() - ) - - return this - - def _parse_index_params(self) -> exp.IndexParameters: - using = ( - self._parse_var(any_token=True) if self._match(TokenType.USING) else None - ) - - if self._match(TokenType.L_PAREN, advance=False): - columns = self._parse_wrapped_csv(self._parse_with_operator) - else: - columns = None - - include = ( - self._parse_wrapped_id_vars() if self._match_text_seq("INCLUDE") else None - ) - partition_by = self._parse_partition_by() - with_storage = self._match(TokenType.WITH) and self._parse_wrapped_properties() - tablespace = ( - self._parse_var(any_token=True) - if self._match_text_seq("USING", "INDEX", "TABLESPACE") - else None - ) - where = self._parse_where() - - on = self._parse_field() if self._match(TokenType.ON) else None - - return self.expression( - exp.IndexParameters, - using=using, - columns=columns, - include=include, - partition_by=partition_by, - where=where, - with_storage=with_storage, - tablespace=tablespace, - on=on, - ) - - def _parse_index( - self, index: t.Optional[exp.Expression] = None, anonymous: bool = False - ) -> t.Optional[exp.Index]: - if index or anonymous: - unique = None - primary = None - amp = None - - self._match(TokenType.ON) - self._match(TokenType.TABLE) # hive - table = self._parse_table_parts(schema=True) - else: - unique = self._match(TokenType.UNIQUE) - primary = self._match_text_seq("PRIMARY") - amp = self._match_text_seq("AMP") - - if not self._match(TokenType.INDEX): - return None - - index = self._parse_id_var() - table = None - - params = self._parse_index_params() - - return self.expression( - exp.Index, - this=index, - table=table, - unique=unique, - primary=primary, - amp=amp, - params=params, - ) - - def _parse_table_hints(self) -> t.Optional[t.List[exp.Expression]]: - hints: t.List[exp.Expression] = [] - if self._match_pair(TokenType.WITH, TokenType.L_PAREN): - # https://learn.microsoft.com/en-us/sql/t-sql/queries/hints-transact-sql-table?view=sql-server-ver16 - hints.append( - self.expression( - exp.WithTableHint, - expressions=self._parse_csv( - lambda: ( - self._parse_function() or self._parse_var(any_token=True) - ) - ), - ) - ) - self._match_r_paren() - else: - # https://dev.mysql.com/doc/refman/8.0/en/index-hints.html - while self._match_set(self.TABLE_INDEX_HINT_TOKENS): - hint = exp.IndexTableHint(this=self._prev.text.upper()) - - self._match_set((TokenType.INDEX, TokenType.KEY)) - if self._match(TokenType.FOR): - hint.set("target", self._advance_any() and self._prev.text.upper()) - - hint.set("expressions", self._parse_wrapped_id_vars()) - hints.append(hint) - - return hints or None - - def _parse_table_part(self, schema: bool = False) -> t.Optional[exp.Expression]: - return ( - (not schema and self._parse_function(optional_parens=False)) - or self._parse_id_var(any_token=False) - or self._parse_string_as_identifier() - or self._parse_placeholder() - ) - - def _parse_table_parts( - self, - schema: bool = False, - is_db_reference: bool = False, - wildcard: bool = False, - ) -> exp.Table: - catalog = None - db = None - table: t.Optional[exp.Expression | str] = self._parse_table_part(schema=schema) - - while self._match(TokenType.DOT): - if catalog: - # This allows nesting the table in arbitrarily many dot expressions if needed - table = self.expression( - exp.Dot, - this=table, - expression=self._parse_table_part(schema=schema), - ) - else: - catalog = db - db = table - # "" used for tsql FROM a..b case - table = self._parse_table_part(schema=schema) or "" - - if ( - wildcard - and self._is_connected() - and (isinstance(table, exp.Identifier) or not table) - and self._match(TokenType.STAR) - ): - if isinstance(table, exp.Identifier): - table.args["this"] += "*" - else: - table = exp.Identifier(this="*") - - # We bubble up comments from the Identifier to the Table - comments = table.pop_comments() if isinstance(table, exp.Expression) else None - - if is_db_reference: - catalog = db - db = table - table = None - - if not table and not is_db_reference: - self.raise_error(f"Expected table name but got {self._curr}") - if not db and is_db_reference: - self.raise_error(f"Expected database name but got {self._curr}") - - table = self.expression( - exp.Table, - comments=comments, - this=table, - db=db, - catalog=catalog, - ) - - changes = self._parse_changes() - if changes: - table.set("changes", changes) - - at_before = self._parse_historical_data() - if at_before: - table.set("when", at_before) - - pivots = self._parse_pivots() - if pivots: - table.set("pivots", pivots) - - return table - - def _parse_table( - self, - schema: bool = False, - joins: bool = False, - alias_tokens: t.Optional[t.Collection[TokenType]] = None, - parse_bracket: bool = False, - is_db_reference: bool = False, - parse_partition: bool = False, - consume_pipe: bool = False, - ) -> t.Optional[exp.Expression]: - stream = self._parse_stream() - if stream: - return stream - - lateral = self._parse_lateral() - if lateral: - return lateral - - unnest = self._parse_unnest() - if unnest: - return unnest - - values = self._parse_derived_table_values() - if values: - return values - - subquery = self._parse_select(table=True, consume_pipe=consume_pipe) - if subquery: - if not subquery.args.get("pivots"): - subquery.set("pivots", self._parse_pivots()) - return subquery - - bracket = parse_bracket and self._parse_bracket(None) - bracket = self.expression(exp.Table, this=bracket) if bracket else None - - rows_from = self._match_text_seq("ROWS", "FROM") and self._parse_wrapped_csv( - self._parse_table - ) - rows_from = ( - self.expression(exp.Table, rows_from=rows_from) if rows_from else None - ) - - only = self._match(TokenType.ONLY) - - this = t.cast( - exp.Expression, - bracket - or rows_from - or self._parse_bracket( - self._parse_table_parts(schema=schema, is_db_reference=is_db_reference) - ), - ) - - if only: - this.set("only", only) - - # Postgres supports a wildcard (table) suffix operator, which is a no-op in this context - self._match_text_seq("*") - - parse_partition = parse_partition or self.SUPPORTS_PARTITION_SELECTION - if parse_partition and self._match(TokenType.PARTITION, advance=False): - this.set("partition", self._parse_partition()) - - if schema: - return self._parse_schema(this=this) - - # see: https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/query-syntax#from_clause - # from_item, then alias, then time travel, then sample. - alias = self._parse_table_alias( - alias_tokens=alias_tokens or self.TABLE_ALIAS_TOKENS - ) - if alias: - this.set("alias", alias) - - version = self._parse_version() - - if version: - this.set("version", version) - - if self.dialect.ALIAS_POST_TABLESAMPLE: - this.set("sample", self._parse_table_sample()) - - if self._match(TokenType.INDEXED_BY): - this.set("indexed", self._parse_table_parts()) - elif self._match_text_seq("NOT", "INDEXED"): - this.set("indexed", False) - - if isinstance(this, exp.Table) and self._match_text_seq("AT"): - return self.expression( - exp.AtIndex, - this=this.to_column(copy=False), - expression=self._parse_id_var(), - ) - - this.set("hints", self._parse_table_hints()) - - if not this.args.get("pivots"): - this.set("pivots", self._parse_pivots()) - - if not self.dialect.ALIAS_POST_TABLESAMPLE: - this.set("sample", self._parse_table_sample()) - - if joins: - for join in self._parse_joins(): - this.append("joins", join) - - if self._match_pair(TokenType.WITH, TokenType.ORDINALITY): - this.set("ordinality", True) - this.set("alias", self._parse_table_alias()) - - return this - - def _parse_version(self) -> t.Optional[exp.Version]: - if self._match(TokenType.TIMESTAMP_SNAPSHOT): - this = "TIMESTAMP" - elif self._match(TokenType.VERSION_SNAPSHOT): - this = "VERSION" - else: - return None - - if self._match_set((TokenType.FROM, TokenType.BETWEEN)): - kind = self._prev.text.upper() - start = self._parse_bitwise() - self._match_texts(("TO", "AND")) - end = self._parse_bitwise() - expression: t.Optional[exp.Expression] = self.expression( - exp.Tuple, expressions=[start, end] - ) - elif self._match_text_seq("CONTAINED", "IN"): - kind = "CONTAINED IN" - expression = self.expression( - exp.Tuple, expressions=self._parse_wrapped_csv(self._parse_bitwise) - ) - elif self._match(TokenType.ALL): - kind = "ALL" - expression = None - else: - self._match_text_seq("AS", "OF") - kind = "AS OF" - expression = self._parse_type() - - return self.expression(exp.Version, this=this, expression=expression, kind=kind) - - def _parse_historical_data(self) -> t.Optional[exp.HistoricalData]: - # https://docs.snowflake.com/en/sql-reference/constructs/at-before - index = self._index - historical_data = None - if self._match_texts(self.HISTORICAL_DATA_PREFIX): - this = self._prev.text.upper() - kind = ( - self._match(TokenType.L_PAREN) - and self._match_texts(self.HISTORICAL_DATA_KIND) - and self._prev.text.upper() - ) - expression = self._match(TokenType.FARROW) and self._parse_bitwise() - - if expression: - self._match_r_paren() - historical_data = self.expression( - exp.HistoricalData, this=this, kind=kind, expression=expression - ) - else: - self._retreat(index) - - return historical_data - - def _parse_changes(self) -> t.Optional[exp.Changes]: - if not self._match_text_seq("CHANGES", "(", "INFORMATION", "=>"): - return None - - information = self._parse_var(any_token=True) - self._match_r_paren() - - return self.expression( - exp.Changes, - information=information, - at_before=self._parse_historical_data(), - end=self._parse_historical_data(), - ) - - def _parse_unnest(self, with_alias: bool = True) -> t.Optional[exp.Unnest]: - if not self._match_pair(TokenType.UNNEST, TokenType.L_PAREN, advance=False): - return None - - self._advance() - - expressions = self._parse_wrapped_csv(self._parse_equality) - offset = self._match_pair(TokenType.WITH, TokenType.ORDINALITY) - - alias = self._parse_table_alias() if with_alias else None - - if alias: - if self.dialect.UNNEST_COLUMN_ONLY: - if alias.args.get("columns"): - self.raise_error("Unexpected extra column alias in unnest.") - - alias.set("columns", [alias.this]) - alias.set("this", None) - - columns = alias.args.get("columns") or [] - if offset and len(expressions) < len(columns): - offset = columns.pop() - - if not offset and self._match_pair(TokenType.WITH, TokenType.OFFSET): - self._match(TokenType.ALIAS) - offset = self._parse_id_var( - any_token=False, tokens=self.UNNEST_OFFSET_ALIAS_TOKENS - ) or exp.to_identifier("offset") - - return self.expression( - exp.Unnest, expressions=expressions, alias=alias, offset=offset - ) - - def _parse_derived_table_values(self) -> t.Optional[exp.Values]: - is_derived = self._match_pair(TokenType.L_PAREN, TokenType.VALUES) - if not is_derived and not ( - # ClickHouse's `FORMAT Values` is equivalent to `VALUES` - self._match_text_seq("VALUES") or self._match_text_seq("FORMAT", "VALUES") - ): - return None - - expressions = self._parse_csv(self._parse_value) - alias = self._parse_table_alias() - - if is_derived: - self._match_r_paren() - - return self.expression( - exp.Values, - expressions=expressions, - alias=alias or self._parse_table_alias(), - ) - - def _parse_table_sample( - self, as_modifier: bool = False - ) -> t.Optional[exp.TableSample]: - if not self._match(TokenType.TABLE_SAMPLE) and not ( - as_modifier and self._match_text_seq("USING", "SAMPLE") - ): - return None - - bucket_numerator = None - bucket_denominator = None - bucket_field = None - percent = None - size = None - seed = None - - method = self._parse_var(tokens=(TokenType.ROW,), upper=True) - matched_l_paren = self._match(TokenType.L_PAREN) - - if self.TABLESAMPLE_CSV: - num = None - expressions = self._parse_csv(self._parse_primary) - else: - expressions = None - num = ( - self._parse_factor() - if self._match(TokenType.NUMBER, advance=False) - else self._parse_primary() or self._parse_placeholder() - ) - - if self._match_text_seq("BUCKET"): - bucket_numerator = self._parse_number() - self._match_text_seq("OUT", "OF") - bucket_denominator = bucket_denominator = self._parse_number() - self._match(TokenType.ON) - bucket_field = self._parse_field() - elif self._match_set((TokenType.PERCENT, TokenType.MOD)): - percent = num - elif ( - self._match(TokenType.ROWS) or not self.dialect.TABLESAMPLE_SIZE_IS_PERCENT - ): - size = num - else: - percent = num - - if matched_l_paren: - self._match_r_paren() - - if self._match(TokenType.L_PAREN): - method = self._parse_var(upper=True) - seed = self._match(TokenType.COMMA) and self._parse_number() - self._match_r_paren() - elif self._match_texts(("SEED", "REPEATABLE")): - seed = self._parse_wrapped(self._parse_number) - - if not method and self.DEFAULT_SAMPLING_METHOD: - method = exp.var(self.DEFAULT_SAMPLING_METHOD) - - return self.expression( - exp.TableSample, - expressions=expressions, - method=method, - bucket_numerator=bucket_numerator, - bucket_denominator=bucket_denominator, - bucket_field=bucket_field, - percent=percent, - size=size, - seed=seed, - ) - - def _parse_pivots(self) -> t.Optional[t.List[exp.Pivot]]: - return list(iter(self._parse_pivot, None)) or None - - def _parse_joins(self) -> t.Iterator[exp.Join]: - return iter(self._parse_join, None) - - def _parse_unpivot_columns(self) -> t.Optional[exp.UnpivotColumns]: - if not self._match(TokenType.INTO): - return None - - return self.expression( - exp.UnpivotColumns, - this=self._match_text_seq("NAME") and self._parse_column(), - expressions=self._match_text_seq("VALUE") - and self._parse_csv(self._parse_column), - ) - - # https://duckdb.org/docs/sql/statements/pivot - def _parse_simplified_pivot(self, is_unpivot: t.Optional[bool] = None) -> exp.Pivot: - def _parse_on() -> t.Optional[exp.Expression]: - this = self._parse_bitwise() - - if self._match(TokenType.IN): - # PIVOT ... ON col IN (row_val1, row_val2) - return self._parse_in(this) - if self._match(TokenType.ALIAS, advance=False): - # UNPIVOT ... ON (col1, col2, col3) AS row_val - return self._parse_alias(this) - - return this - - this = self._parse_table() - expressions = self._match(TokenType.ON) and self._parse_csv(_parse_on) - into = self._parse_unpivot_columns() - using = self._match(TokenType.USING) and self._parse_csv( - lambda: self._parse_alias(self._parse_column()) - ) - group = self._parse_group() - - return self.expression( - exp.Pivot, - this=this, - expressions=expressions, - using=using, - group=group, - unpivot=is_unpivot, - into=into, - ) - - def _parse_pivot_in(self) -> exp.In: - def _parse_aliased_expression() -> t.Optional[exp.Expression]: - this = self._parse_select_or_expression() - - self._match(TokenType.ALIAS) - alias = self._parse_bitwise() - if alias: - if isinstance(alias, exp.Column) and not alias.db: - alias = alias.this - return self.expression(exp.PivotAlias, this=this, alias=alias) - - return this - - value = self._parse_column() - - if not self._match(TokenType.IN): - self.raise_error("Expecting IN") - - if self._match(TokenType.L_PAREN): - if self._match(TokenType.ANY): - exprs: t.List[exp.Expression] = ensure_list( - exp.PivotAny(this=self._parse_order()) - ) - else: - exprs = self._parse_csv(_parse_aliased_expression) - self._match_r_paren() - return self.expression(exp.In, this=value, expressions=exprs) - - return self.expression(exp.In, this=value, field=self._parse_id_var()) - - def _parse_pivot_aggregation(self) -> t.Optional[exp.Expression]: - func = self._parse_function() - if not func: - if self._prev and self._prev.token_type == TokenType.COMMA: - return None - self.raise_error("Expecting an aggregation function in PIVOT") - - return self._parse_alias(func) - - def _parse_pivot(self) -> t.Optional[exp.Pivot]: - index = self._index - include_nulls = None - - if self._match(TokenType.PIVOT): - unpivot = False - elif self._match(TokenType.UNPIVOT): - unpivot = True - - # https://docs.databricks.com/en/sql/language-manual/sql-ref-syntax-qry-select-unpivot.html#syntax - if self._match_text_seq("INCLUDE", "NULLS"): - include_nulls = True - elif self._match_text_seq("EXCLUDE", "NULLS"): - include_nulls = False - else: - return None - - expressions = [] - - if not self._match(TokenType.L_PAREN): - self._retreat(index) - return None - - if unpivot: - expressions = self._parse_csv(self._parse_column) - else: - expressions = self._parse_csv(self._parse_pivot_aggregation) - - if not expressions: - self.raise_error("Failed to parse PIVOT's aggregation list") - - if not self._match(TokenType.FOR): - self.raise_error("Expecting FOR") - - fields = [] - while True: - field = self._try_parse(self._parse_pivot_in) - if not field: - break - fields.append(field) - - default_on_null = self._match_text_seq( - "DEFAULT", "ON", "NULL" - ) and self._parse_wrapped(self._parse_bitwise) - - group = self._parse_group() - - self._match_r_paren() - - pivot = self.expression( - exp.Pivot, - expressions=expressions, - fields=fields, - unpivot=unpivot, - include_nulls=include_nulls, - default_on_null=default_on_null, - group=group, - ) - - if not self._match_set((TokenType.PIVOT, TokenType.UNPIVOT), advance=False): - pivot.set("alias", self._parse_table_alias()) - - if not unpivot: - names = self._pivot_column_names( - t.cast(t.List[exp.Expression], expressions) - ) - - columns: t.List[exp.Expression] = [] - all_fields = [] - for pivot_field in pivot.fields: - pivot_field_expressions = pivot_field.expressions - - # The `PivotAny` expression corresponds to `ANY ORDER BY `; we can't infer in this case. - if isinstance(seq_get(pivot_field_expressions, 0), exp.PivotAny): - continue - - all_fields.append( - [ - fld.sql() if self.IDENTIFY_PIVOT_STRINGS else fld.alias_or_name - for fld in pivot_field_expressions - ] - ) - - if all_fields: - if names: - all_fields.append(names) - - # Generate all possible combinations of the pivot columns - # e.g PIVOT(sum(...) as total FOR year IN (2000, 2010) FOR country IN ('NL', 'US')) - # generates the product between [[2000, 2010], ['NL', 'US'], ['total']] - for fld_parts_tuple in itertools.product(*all_fields): - fld_parts = list(fld_parts_tuple) - - if names and self.PREFIXED_PIVOT_COLUMNS: - # Move the "name" to the front of the list - fld_parts.insert(0, fld_parts.pop(-1)) - - columns.append(exp.to_identifier("_".join(fld_parts))) - - pivot.set("columns", columns) - - return pivot - - def _pivot_column_names(self, aggregations: t.List[exp.Expression]) -> t.List[str]: - return [agg.alias for agg in aggregations if agg.alias] - - def _parse_prewhere( - self, skip_where_token: bool = False - ) -> t.Optional[exp.PreWhere]: - if not skip_where_token and not self._match(TokenType.PREWHERE): - return None - - return self.expression( - exp.PreWhere, comments=self._prev_comments, this=self._parse_disjunction() - ) - - def _parse_where(self, skip_where_token: bool = False) -> t.Optional[exp.Where]: - if not skip_where_token and not self._match(TokenType.WHERE): - return None - - return self.expression( - exp.Where, comments=self._prev_comments, this=self._parse_disjunction() - ) - - def _parse_group(self, skip_group_by_token: bool = False) -> t.Optional[exp.Group]: - if not skip_group_by_token and not self._match(TokenType.GROUP_BY): - return None - comments = self._prev_comments - - elements: t.Dict[str, t.Any] = defaultdict(list) - - if self._match(TokenType.ALL): - elements["all"] = True - elif self._match(TokenType.DISTINCT): - elements["all"] = False - - if self._match_set(self.QUERY_MODIFIER_TOKENS, advance=False): - return self.expression(exp.Group, comments=comments, **elements) # type: ignore - - while True: - index = self._index - - elements["expressions"].extend( - self._parse_csv( - lambda: ( - None - if self._match_set( - (TokenType.CUBE, TokenType.ROLLUP), advance=False - ) - else self._parse_disjunction() - ) - ) - ) - - before_with_index = self._index - with_prefix = self._match(TokenType.WITH) - - if cube_or_rollup := self._parse_cube_or_rollup(with_prefix=with_prefix): - key = "rollup" if isinstance(cube_or_rollup, exp.Rollup) else "cube" - elements[key].append(cube_or_rollup) - elif grouping_sets := self._parse_grouping_sets(): - elements["grouping_sets"].append(grouping_sets) - elif self._match_text_seq("TOTALS"): - elements["totals"] = True # type: ignore - - if before_with_index <= self._index <= before_with_index + 1: - self._retreat(before_with_index) - break - - if index == self._index: - break - - return self.expression(exp.Group, comments=comments, **elements) # type: ignore - - def _parse_cube_or_rollup( - self, with_prefix: bool = False - ) -> t.Optional[exp.Cube | exp.Rollup]: - if self._match(TokenType.CUBE): - kind: t.Type[exp.Cube | exp.Rollup] = exp.Cube - elif self._match(TokenType.ROLLUP): - kind = exp.Rollup - else: - return None - - return self.expression( - kind, - expressions=[] - if with_prefix - else self._parse_wrapped_csv(self._parse_bitwise), - ) - - def _parse_grouping_sets(self) -> t.Optional[exp.GroupingSets]: - if self._match(TokenType.GROUPING_SETS): - return self.expression( - exp.GroupingSets, - expressions=self._parse_wrapped_csv(self._parse_grouping_set), - ) - return None - - def _parse_grouping_set(self) -> t.Optional[exp.Expression]: - return ( - self._parse_grouping_sets() - or self._parse_cube_or_rollup() - or self._parse_bitwise() - ) - - def _parse_having(self, skip_having_token: bool = False) -> t.Optional[exp.Having]: - if not skip_having_token and not self._match(TokenType.HAVING): - return None - return self.expression( - exp.Having, comments=self._prev_comments, this=self._parse_disjunction() - ) - - def _parse_qualify(self) -> t.Optional[exp.Qualify]: - if not self._match(TokenType.QUALIFY): - return None - return self.expression(exp.Qualify, this=self._parse_disjunction()) - - def _parse_connect_with_prior(self) -> t.Optional[exp.Expression]: - self.NO_PAREN_FUNCTION_PARSERS["PRIOR"] = lambda self: self.expression( - exp.Prior, this=self._parse_bitwise() - ) - connect = self._parse_disjunction() - self.NO_PAREN_FUNCTION_PARSERS.pop("PRIOR") - return connect - - def _parse_connect(self, skip_start_token: bool = False) -> t.Optional[exp.Connect]: - if skip_start_token: - start = None - elif self._match(TokenType.START_WITH): - start = self._parse_disjunction() - else: - return None - - self._match(TokenType.CONNECT_BY) - nocycle = self._match_text_seq("NOCYCLE") - connect = self._parse_connect_with_prior() - - if not start and self._match(TokenType.START_WITH): - start = self._parse_disjunction() - - return self.expression( - exp.Connect, start=start, connect=connect, nocycle=nocycle - ) - - def _parse_name_as_expression(self) -> t.Optional[exp.Expression]: - this = self._parse_id_var(any_token=True) - if self._match(TokenType.ALIAS): - this = self.expression( - exp.Alias, alias=this, this=self._parse_disjunction() - ) - return this - - def _parse_interpolate(self) -> t.Optional[t.List[exp.Expression]]: - if self._match_text_seq("INTERPOLATE"): - return self._parse_wrapped_csv(self._parse_name_as_expression) - return None - - def _parse_order( - self, this: t.Optional[exp.Expression] = None, skip_order_token: bool = False - ) -> t.Optional[exp.Expression]: - siblings = None - if not skip_order_token and not self._match(TokenType.ORDER_BY): - if not self._match(TokenType.ORDER_SIBLINGS_BY): - return this - - siblings = True - - return self.expression( - exp.Order, - comments=self._prev_comments, - this=this, - expressions=self._parse_csv(self._parse_ordered), - siblings=siblings, - ) - - def _parse_sort(self, exp_class: t.Type[E], token: TokenType) -> t.Optional[E]: - if not self._match(token): - return None - return self.expression( - exp_class, expressions=self._parse_csv(self._parse_ordered) - ) - - def _parse_ordered( - self, parse_method: t.Optional[t.Callable] = None - ) -> t.Optional[exp.Ordered]: - this = parse_method() if parse_method else self._parse_disjunction() - if not this: - return None - - if this.name.upper() == "ALL" and self.dialect.SUPPORTS_ORDER_BY_ALL: - this = exp.var("ALL") - - asc = self._match(TokenType.ASC) - desc = self._match(TokenType.DESC) or (asc and False) - - is_nulls_first = self._match_text_seq("NULLS", "FIRST") - is_nulls_last = self._match_text_seq("NULLS", "LAST") - - nulls_first = is_nulls_first or False - explicitly_null_ordered = is_nulls_first or is_nulls_last - - if ( - not explicitly_null_ordered - and ( - (not desc and self.dialect.NULL_ORDERING == "nulls_are_small") - or (desc and self.dialect.NULL_ORDERING != "nulls_are_small") - ) - and self.dialect.NULL_ORDERING != "nulls_are_last" - ): - nulls_first = True - - if self._match_text_seq("WITH", "FILL"): - with_fill = self.expression( - exp.WithFill, - from_=self._match(TokenType.FROM) and self._parse_bitwise(), - to=self._match_text_seq("TO") and self._parse_bitwise(), - step=self._match_text_seq("STEP") and self._parse_bitwise(), - interpolate=self._parse_interpolate(), - ) - else: - with_fill = None - - return self.expression( - exp.Ordered, - this=this, - desc=desc, - nulls_first=nulls_first, - with_fill=with_fill, - ) - - def _parse_limit_options(self) -> t.Optional[exp.LimitOptions]: - percent = self._match_set((TokenType.PERCENT, TokenType.MOD)) - rows = self._match_set((TokenType.ROW, TokenType.ROWS)) - self._match_text_seq("ONLY") - with_ties = self._match_text_seq("WITH", "TIES") - - if not (percent or rows or with_ties): - return None - - return self.expression( - exp.LimitOptions, percent=percent, rows=rows, with_ties=with_ties - ) - - def _parse_limit( - self, - this: t.Optional[exp.Expression] = None, - top: bool = False, - skip_limit_token: bool = False, - ) -> t.Optional[exp.Expression]: - if skip_limit_token or self._match(TokenType.TOP if top else TokenType.LIMIT): - comments = self._prev_comments - if top: - limit_paren = self._match(TokenType.L_PAREN) - expression = self._parse_term() if limit_paren else self._parse_number() - - if limit_paren: - self._match_r_paren() - - else: - # Parsing LIMIT x% (i.e x PERCENT) as a term leads to an error, since - # we try to build an exp.Mod expr. For that matter, we backtrack and instead - # consume the factor plus parse the percentage separately - index = self._index - expression = self._try_parse(self._parse_term) - if isinstance(expression, exp.Mod): - self._retreat(index) - expression = self._parse_factor() - elif not expression: - expression = self._parse_factor() - limit_options = self._parse_limit_options() - - if self._match(TokenType.COMMA): - offset = expression - expression = self._parse_term() - else: - offset = None - - limit_exp = self.expression( - exp.Limit, - this=this, - expression=expression, - offset=offset, - comments=comments, - limit_options=limit_options, - expressions=self._parse_limit_by(), - ) - - return limit_exp - - if self._match(TokenType.FETCH): - direction = self._match_set((TokenType.FIRST, TokenType.NEXT)) - direction = self._prev.text.upper() if direction else "FIRST" - - count = self._parse_field(tokens=self.FETCH_TOKENS) - - return self.expression( - exp.Fetch, - direction=direction, - count=count, - limit_options=self._parse_limit_options(), - ) - - return this - - def _parse_offset( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - if not self._match(TokenType.OFFSET): - return this - - count = self._parse_term() - self._match_set((TokenType.ROW, TokenType.ROWS)) - - return self.expression( - exp.Offset, this=this, expression=count, expressions=self._parse_limit_by() - ) - - def _can_parse_limit_or_offset(self) -> bool: - if not self._match_set(self.AMBIGUOUS_ALIAS_TOKENS, advance=False): - return False - - index = self._index - result = bool( - self._try_parse(self._parse_limit, retreat=True) - or self._try_parse(self._parse_offset, retreat=True) - ) - self._retreat(index) - return result - - def _parse_limit_by(self) -> t.Optional[t.List[exp.Expression]]: - return self._match_text_seq("BY") and self._parse_csv(self._parse_bitwise) - - def _parse_locks(self) -> t.List[exp.Lock]: - locks = [] - while True: - update, key = None, None - if self._match_text_seq("FOR", "UPDATE"): - update = True - elif self._match_text_seq("FOR", "SHARE") or self._match_text_seq( - "LOCK", "IN", "SHARE", "MODE" - ): - update = False - elif self._match_text_seq("FOR", "KEY", "SHARE"): - update, key = False, True - elif self._match_text_seq("FOR", "NO", "KEY", "UPDATE"): - update, key = True, True - else: - break - - expressions = None - if self._match_text_seq("OF"): - expressions = self._parse_csv(lambda: self._parse_table(schema=True)) - - wait: t.Optional[bool | exp.Expression] = None - if self._match_text_seq("NOWAIT"): - wait = True - elif self._match_text_seq("WAIT"): - wait = self._parse_primary() - elif self._match_text_seq("SKIP", "LOCKED"): - wait = False - - locks.append( - self.expression( - exp.Lock, update=update, expressions=expressions, wait=wait, key=key - ) - ) - - return locks - - def parse_set_operation( - self, this: t.Optional[exp.Expression], consume_pipe: bool = False - ) -> t.Optional[exp.Expression]: - start = self._index - _, side_token, kind_token = self._parse_join_parts() - - side = side_token.text if side_token else None - kind = kind_token.text if kind_token else None - - if not self._match_set(self.SET_OPERATIONS): - self._retreat(start) - return None - - token_type = self._prev.token_type - - if token_type == TokenType.UNION: - operation: t.Type[exp.SetOperation] = exp.Union - elif token_type == TokenType.EXCEPT: - operation = exp.Except - else: - operation = exp.Intersect - - comments = self._prev.comments - - if self._match(TokenType.DISTINCT): - distinct: t.Optional[bool] = True - elif self._match(TokenType.ALL): - distinct = False - else: - distinct = self.dialect.SET_OP_DISTINCT_BY_DEFAULT[operation] - if distinct is None: - self.raise_error(f"Expected DISTINCT or ALL for {operation.__name__}") - - by_name = self._match_text_seq("BY", "NAME") or self._match_text_seq( - "STRICT", "CORRESPONDING" - ) - if self._match_text_seq("CORRESPONDING"): - by_name = True - if not side and not kind: - kind = "INNER" - - on_column_list = None - if by_name and self._match_texts(("ON", "BY")): - on_column_list = self._parse_wrapped_csv(self._parse_column) - - expression = self._parse_select( - nested=True, parse_set_operation=False, consume_pipe=consume_pipe - ) - - return self.expression( - operation, - comments=comments, - this=this, - distinct=distinct, - by_name=by_name, - expression=expression, - side=side, - kind=kind, - on=on_column_list, - ) - - def _parse_set_operations( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - while this: - setop = self.parse_set_operation(this) - if not setop: - break - this = setop - - if isinstance(this, exp.SetOperation) and self.MODIFIERS_ATTACHED_TO_SET_OP: - expression = this.expression - - if expression: - for arg in self.SET_OP_MODIFIERS: - expr = expression.args.get(arg) - if expr: - this.set(arg, expr.pop()) - - return this - - def _parse_expression(self) -> t.Optional[exp.Expression]: - return self._parse_alias(self._parse_assignment()) - - def _parse_assignment(self) -> t.Optional[exp.Expression]: - this = self._parse_disjunction() - if not this and self._next and self._next.token_type in self.ASSIGNMENT: - # This allows us to parse := - this = exp.column( - t.cast(str, self._advance_any(ignore_reserved=True) and self._prev.text) - ) - - while self._match_set(self.ASSIGNMENT): - if isinstance(this, exp.Column) and len(this.parts) == 1: - this = this.this - - this = self.expression( - self.ASSIGNMENT[self._prev.token_type], - this=this, - comments=self._prev_comments, - expression=self._parse_assignment(), - ) - - return this - - def _parse_disjunction(self) -> t.Optional[exp.Expression]: - return self._parse_tokens(self._parse_conjunction, self.DISJUNCTION) - - def _parse_conjunction(self) -> t.Optional[exp.Expression]: - return self._parse_tokens(self._parse_equality, self.CONJUNCTION) - - def _parse_equality(self) -> t.Optional[exp.Expression]: - return self._parse_tokens(self._parse_comparison, self.EQUALITY) - - def _parse_comparison(self) -> t.Optional[exp.Expression]: - return self._parse_tokens(self._parse_range, self.COMPARISON) - - def _parse_range( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - this = this or self._parse_bitwise() - negate = self._match(TokenType.NOT) - - if self._match_set(self.RANGE_PARSERS): - expression = self.RANGE_PARSERS[self._prev.token_type](self, this) - if not expression: - return this - - this = expression - elif self._match(TokenType.ISNULL) or (negate and self._match(TokenType.NULL)): - this = self.expression(exp.Is, this=this, expression=exp.Null()) - - # Postgres supports ISNULL and NOTNULL for conditions. - # https://blog.andreiavram.ro/postgresql-null-composite-type/ - if self._match(TokenType.NOTNULL): - this = self.expression(exp.Is, this=this, expression=exp.Null()) - this = self.expression(exp.Not, this=this) - - if negate: - this = self._negate_range(this) - - if self._match(TokenType.IS): - this = self._parse_is(this) - - return this - - def _negate_range( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - if not this: - return this - - return self.expression(exp.Not, this=this) - - def _parse_is(self, this: t.Optional[exp.Expression]) -> t.Optional[exp.Expression]: - index = self._index - 1 - negate = self._match(TokenType.NOT) - - if self._match_text_seq("DISTINCT", "FROM"): - klass = exp.NullSafeEQ if negate else exp.NullSafeNEQ - return self.expression(klass, this=this, expression=self._parse_bitwise()) - - if self._match(TokenType.JSON): - kind = ( - self._match_texts(self.IS_JSON_PREDICATE_KIND) - and self._prev.text.upper() - ) - - if self._match_text_seq("WITH"): - _with = True - elif self._match_text_seq("WITHOUT"): - _with = False - else: - _with = None - - unique = self._match(TokenType.UNIQUE) - self._match_text_seq("KEYS") - expression: t.Optional[exp.Expression] = self.expression( - exp.JSON, - this=kind, - with_=_with, - unique=unique, - ) - else: - expression = self._parse_null() or self._parse_bitwise() - if not expression: - self._retreat(index) - return None - - this = self.expression(exp.Is, this=this, expression=expression) - this = self.expression(exp.Not, this=this) if negate else this - return self._parse_column_ops(this) - - def _parse_in( - self, this: t.Optional[exp.Expression], alias: bool = False - ) -> exp.In: - unnest = self._parse_unnest(with_alias=False) - if unnest: - this = self.expression(exp.In, this=this, unnest=unnest) - elif self._match_set((TokenType.L_PAREN, TokenType.L_BRACKET)): - matched_l_paren = self._prev.token_type == TokenType.L_PAREN - expressions = self._parse_csv( - lambda: self._parse_select_or_expression(alias=alias) - ) - - if len(expressions) == 1 and isinstance(query := expressions[0], exp.Query): - this = self.expression( - exp.In, - this=this, - query=self._parse_query_modifiers(query).subquery(copy=False), - ) - else: - this = self.expression(exp.In, this=this, expressions=expressions) - - if matched_l_paren: - self._match_r_paren(this) - elif not self._match(TokenType.R_BRACKET, expression=this): - self.raise_error("Expecting ]") - else: - this = self.expression(exp.In, this=this, field=self._parse_column()) - - return this - - def _parse_between(self, this: t.Optional[exp.Expression]) -> exp.Between: - symmetric = None - if self._match_text_seq("SYMMETRIC"): - symmetric = True - elif self._match_text_seq("ASYMMETRIC"): - symmetric = False - - low = self._parse_bitwise() - self._match(TokenType.AND) - high = self._parse_bitwise() - - return self.expression( - exp.Between, - this=this, - low=low, - high=high, - symmetric=symmetric, - ) - - def _parse_escape( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if not self._match(TokenType.ESCAPE): - return this - return self.expression( - exp.Escape, this=this, expression=self._parse_string() or self._parse_null() - ) - - def _parse_interval( - self, match_interval: bool = True - ) -> t.Optional[exp.Add | exp.Interval]: - index = self._index - - if not self._match(TokenType.INTERVAL) and match_interval: - return None - - if self._match(TokenType.STRING, advance=False): - this = self._parse_primary() - else: - this = self._parse_term() - - if not this or ( - isinstance(this, exp.Column) - and not this.table - and not this.this.quoted - and self._curr - and self._curr.text.upper() not in self.dialect.VALID_INTERVAL_UNITS - ): - self._retreat(index) - return None - - # handle day-time format interval span with omitted units: - # INTERVAL ' hh[:][mm[:ss[.ff]]]' - interval_span_units_omitted = None - if ( - this - and this.is_string - and self.SUPPORTS_OMITTED_INTERVAL_SPAN_UNIT - and exp.INTERVAL_DAY_TIME_RE.match(this.name) - ): - index = self._index - - # Var "TO" Var - first_unit = self._parse_var(any_token=True, upper=True) - second_unit = None - if first_unit and self._match_text_seq("TO"): - second_unit = self._parse_var(any_token=True, upper=True) - - interval_span_units_omitted = not (first_unit and second_unit) - - self._retreat(index) - - unit = ( - None - if interval_span_units_omitted - else ( - self._parse_function() - or ( - not self._match(TokenType.ALIAS, advance=False) - and self._parse_var(any_token=True, upper=True) - ) - ) - ) - - # Most dialects support, e.g., the form INTERVAL '5' day, thus we try to parse - # each INTERVAL expression into this canonical form so it's easy to transpile - if this and this.is_number: - this = exp.Literal.string(this.to_py()) - elif this and this.is_string: - parts = exp.INTERVAL_STRING_RE.findall(this.name) - if parts and unit: - # Unconsume the eagerly-parsed unit, since the real unit was part of the string - unit = None - self._retreat(self._index - 1) - - if len(parts) == 1: - this = exp.Literal.string(parts[0][0]) - unit = self.expression(exp.Var, this=parts[0][1].upper()) - - if self.INTERVAL_SPANS and self._match_text_seq("TO"): - unit = self.expression( - exp.IntervalSpan, - this=unit, - expression=self._parse_var(any_token=True, upper=True), - ) - - interval = self.expression(exp.Interval, this=this, unit=unit) - - index = self._index - self._match(TokenType.PLUS) - - # Convert INTERVAL 'val_1' unit_1 [+] ... [+] 'val_n' unit_n into a sum of intervals - if self._match_set((TokenType.STRING, TokenType.NUMBER), advance=False): - return self.expression( - exp.Add, - this=interval, - expression=self._parse_interval(match_interval=False), - ) - - self._retreat(index) - return interval - - def _parse_bitwise(self) -> t.Optional[exp.Expression]: - this = self._parse_term() - - while True: - if self._match_set(self.BITWISE): - this = self.expression( - self.BITWISE[self._prev.token_type], - this=this, - expression=self._parse_term(), - ) - elif self.dialect.DPIPE_IS_STRING_CONCAT and self._match(TokenType.DPIPE): - this = self.expression( - exp.DPipe, - this=this, - expression=self._parse_term(), - safe=not self.dialect.STRICT_STRING_CONCAT, - ) - elif self._match(TokenType.DQMARK): - this = self.expression( - exp.Coalesce, this=this, expressions=ensure_list(self._parse_term()) - ) - elif self._match_pair(TokenType.LT, TokenType.LT): - this = self.expression( - exp.BitwiseLeftShift, this=this, expression=self._parse_term() - ) - elif self._match_pair(TokenType.GT, TokenType.GT): - this = self.expression( - exp.BitwiseRightShift, this=this, expression=self._parse_term() - ) - else: - break - - return this - - def _parse_term(self) -> t.Optional[exp.Expression]: - this = self._parse_factor() - - while self._match_set(self.TERM): - klass = self.TERM[self._prev.token_type] - comments = self._prev_comments - expression = self._parse_factor() - - this = self.expression( - klass, this=this, comments=comments, expression=expression - ) - - if isinstance(this, exp.Collate): - expr = this.expression - - # Preserve collations such as pg_catalog."default" (Postgres) as columns, otherwise - # fallback to Identifier / Var - if isinstance(expr, exp.Column) and len(expr.parts) == 1: - ident = expr.this - if isinstance(ident, exp.Identifier): - this.set( - "expression", ident if ident.quoted else exp.var(ident.name) - ) - - return this - - def _parse_factor(self) -> t.Optional[exp.Expression]: - parse_method = self._parse_exponent if self.EXPONENT else self._parse_unary - this = self._parse_at_time_zone(parse_method()) - - while self._match_set(self.FACTOR): - klass = self.FACTOR[self._prev.token_type] - comments = self._prev_comments - expression = parse_method() - - if not expression and klass is exp.IntDiv and self._prev.text.isalpha(): - self._retreat(self._index - 1) - return this - - this = self.expression( - klass, this=this, comments=comments, expression=expression - ) - - if isinstance(this, exp.Div): - this.set("typed", self.dialect.TYPED_DIVISION) - this.set("safe", self.dialect.SAFE_DIVISION) - - return this - - def _parse_exponent(self) -> t.Optional[exp.Expression]: - return self._parse_tokens(self._parse_unary, self.EXPONENT) - - def _parse_unary(self) -> t.Optional[exp.Expression]: - if self._match_set(self.UNARY_PARSERS): - return self.UNARY_PARSERS[self._prev.token_type](self) - return self._parse_type() - - def _parse_type( - self, parse_interval: bool = True, fallback_to_identifier: bool = False - ) -> t.Optional[exp.Expression]: - interval = parse_interval and self._parse_interval() - if interval: - return self._parse_column_ops(interval) - - index = self._index - data_type = self._parse_types(check_func=True, allow_identifiers=False) - - # parse_types() returns a Cast if we parsed BQ's inline constructor () e.g. - # STRUCT(1, 'foo'), which is canonicalized to CAST( AS ) - if isinstance(data_type, exp.Cast): - # This constructor can contain ops directly after it, for instance struct unnesting: - # STRUCT(1, 'foo').* --> CAST(STRUCT(1, 'foo') AS STRUCT 1: - self._retreat(index2) - return self._parse_column_ops(data_type) - - self._retreat(index) - - if fallback_to_identifier: - return self._parse_id_var() - - this = self._parse_column() - return this and self._parse_column_ops(this) - - def _parse_type_size(self) -> t.Optional[exp.DataTypeParam]: - this = self._parse_type() - if not this: - return None - - if isinstance(this, exp.Column) and not this.table: - this = exp.var(this.name.upper()) - - return self.expression( - exp.DataTypeParam, this=this, expression=self._parse_var(any_token=True) - ) - - def _parse_user_defined_type( - self, identifier: exp.Identifier - ) -> t.Optional[exp.Expression]: - type_name = identifier.name - - while self._match(TokenType.DOT): - type_name = f"{type_name}.{self._advance_any() and self._prev.text}" - - return exp.DataType.build(type_name, dialect=self.dialect, udt=True) - - def _parse_types( - self, - check_func: bool = False, - schema: bool = False, - allow_identifiers: bool = True, - ) -> t.Optional[exp.Expression]: - index = self._index - - this: t.Optional[exp.Expression] = None - prefix = self._match_text_seq("SYSUDTLIB", ".") - - if self._match_set(self.TYPE_TOKENS): - type_token = self._prev.token_type - else: - type_token = None - identifier = allow_identifiers and self._parse_id_var( - any_token=False, tokens=(TokenType.VAR,) - ) - if isinstance(identifier, exp.Identifier): - try: - tokens = self.dialect.tokenize(identifier.name) - except TokenError: - tokens = None - - if ( - tokens - and len(tokens) == 1 - and tokens[0].token_type in self.TYPE_TOKENS - ): - type_token = tokens[0].token_type - elif self.dialect.SUPPORTS_USER_DEFINED_TYPES: - this = self._parse_user_defined_type(identifier) - else: - self._retreat(self._index - 1) - return None - else: - return None - - if type_token == TokenType.PSEUDO_TYPE: - return self.expression(exp.PseudoType, this=self._prev.text.upper()) - - if type_token == TokenType.OBJECT_IDENTIFIER: - return self.expression(exp.ObjectIdentifier, this=self._prev.text.upper()) - - # https://materialize.com/docs/sql/types/map/ - if type_token == TokenType.MAP and self._match(TokenType.L_BRACKET): - key_type = self._parse_types( - check_func=check_func, - schema=schema, - allow_identifiers=allow_identifiers, - ) - if not self._match(TokenType.FARROW): - self._retreat(index) - return None - - value_type = self._parse_types( - check_func=check_func, - schema=schema, - allow_identifiers=allow_identifiers, - ) - if not self._match(TokenType.R_BRACKET): - self._retreat(index) - return None - - return exp.DataType( - this=exp.DataType.Type.MAP, - expressions=[key_type, value_type], - nested=True, - prefix=prefix, - ) - - nested = type_token in self.NESTED_TYPE_TOKENS - is_struct = type_token in self.STRUCT_TYPE_TOKENS - is_aggregate = type_token in self.AGGREGATE_TYPE_TOKENS - expressions = None - maybe_func = False - - if self._match(TokenType.L_PAREN): - if is_struct: - expressions = self._parse_csv( - lambda: self._parse_struct_types(type_required=True) - ) - elif nested: - expressions = self._parse_csv( - lambda: self._parse_types( - check_func=check_func, - schema=schema, - allow_identifiers=allow_identifiers, - ) - ) - if type_token == TokenType.NULLABLE and len(expressions) == 1: - this = expressions[0] - this.set("nullable", True) - self._match_r_paren() - return this - elif type_token in self.ENUM_TYPE_TOKENS: - expressions = self._parse_csv(self._parse_equality) - elif is_aggregate: - func_or_ident = self._parse_function( - anonymous=True - ) or self._parse_id_var( - any_token=False, tokens=(TokenType.VAR, TokenType.ANY) - ) - if not func_or_ident: - return None - expressions = [func_or_ident] - if self._match(TokenType.COMMA): - expressions.extend( - self._parse_csv( - lambda: self._parse_types( - check_func=check_func, - schema=schema, - allow_identifiers=allow_identifiers, - ) - ) - ) - else: - expressions = self._parse_csv(self._parse_type_size) - - # https://docs.snowflake.com/en/sql-reference/data-types-vector - if type_token == TokenType.VECTOR and len(expressions) == 2: - expressions = self._parse_vector_expressions(expressions) - - if not self._match(TokenType.R_PAREN): - self._retreat(index) - return None - - maybe_func = True - - values: t.Optional[t.List[exp.Expression]] = None - - if nested and self._match(TokenType.LT): - if is_struct: - expressions = self._parse_csv( - lambda: self._parse_struct_types(type_required=True) - ) - else: - expressions = self._parse_csv( - lambda: self._parse_types( - check_func=check_func, - schema=schema, - allow_identifiers=allow_identifiers, - ) - ) - - if not self._match(TokenType.GT): - self.raise_error("Expecting >") - - if self._match_set((TokenType.L_BRACKET, TokenType.L_PAREN)): - values = self._parse_csv(self._parse_disjunction) - if not values and is_struct: - values = None - self._retreat(self._index - 1) - else: - self._match_set((TokenType.R_BRACKET, TokenType.R_PAREN)) - - if type_token in self.TIMESTAMPS: - if self._match_text_seq("WITH", "TIME", "ZONE"): - maybe_func = False - tz_type = ( - exp.DataType.Type.TIMETZ - if type_token in self.TIMES - else exp.DataType.Type.TIMESTAMPTZ - ) - this = exp.DataType(this=tz_type, expressions=expressions) - elif self._match_text_seq("WITH", "LOCAL", "TIME", "ZONE"): - maybe_func = False - this = exp.DataType( - this=exp.DataType.Type.TIMESTAMPLTZ, expressions=expressions - ) - elif self._match_text_seq("WITHOUT", "TIME", "ZONE"): - maybe_func = False - elif type_token == TokenType.INTERVAL: - unit = self._parse_var(upper=True) - if unit: - if self._match_text_seq("TO"): - unit = exp.IntervalSpan( - this=unit, expression=self._parse_var(upper=True) - ) - - this = self.expression( - exp.DataType, this=self.expression(exp.Interval, unit=unit) - ) - else: - this = self.expression(exp.DataType, this=exp.DataType.Type.INTERVAL) - elif type_token == TokenType.VOID: - this = exp.DataType(this=exp.DataType.Type.NULL) - - if maybe_func and check_func: - index2 = self._index - peek = self._parse_string() - - if not peek: - self._retreat(index) - return None - - self._retreat(index2) - - if not this: - if self._match_text_seq("UNSIGNED"): - unsigned_type_token = self.SIGNED_TO_UNSIGNED_TYPE_TOKEN.get(type_token) - if not unsigned_type_token: - self.raise_error(f"Cannot convert {type_token.value} to unsigned.") - - type_token = unsigned_type_token or type_token - - # NULLABLE without parentheses can be a column (Presto/Trino) - if type_token == TokenType.NULLABLE and not expressions: - self._retreat(index) - return None - - this = exp.DataType( - this=exp.DataType.Type[type_token.value], - expressions=expressions, - nested=nested, - prefix=prefix, - ) - - # Empty arrays/structs are allowed - if values is not None: - cls = exp.Struct if is_struct else exp.Array - this = exp.cast(cls(expressions=values), this, copy=False) - - elif expressions: - this.set("expressions", expressions) - - # https://materialize.com/docs/sql/types/list/#type-name - while self._match(TokenType.LIST): - this = exp.DataType( - this=exp.DataType.Type.LIST, expressions=[this], nested=True - ) - - index = self._index - - # Postgres supports the INT ARRAY[3] syntax as a synonym for INT[3] - matched_array = self._match(TokenType.ARRAY) - - while self._curr: - datatype_token = self._prev.token_type - matched_l_bracket = self._match(TokenType.L_BRACKET) - - if (not matched_l_bracket and not matched_array) or ( - datatype_token == TokenType.ARRAY and self._match(TokenType.R_BRACKET) - ): - # Postgres allows casting empty arrays such as ARRAY[]::INT[], - # not to be confused with the fixed size array parsing - break - - matched_array = False - values = self._parse_csv(self._parse_disjunction) or None - if ( - values - and not schema - and ( - not self.dialect.SUPPORTS_FIXED_SIZE_ARRAYS - or datatype_token == TokenType.ARRAY - or not self._match(TokenType.R_BRACKET, advance=False) - ) - ): - # Retreating here means that we should not parse the following values as part of the data type, e.g. in DuckDB - # ARRAY[1] should retreat and instead be parsed into exp.Array in contrast to INT[x][y] which denotes a fixed-size array data type - self._retreat(index) - break - - this = exp.DataType( - this=exp.DataType.Type.ARRAY, - expressions=[this], - values=values, - nested=True, - ) - self._match(TokenType.R_BRACKET) - - if self.TYPE_CONVERTERS and isinstance(this.this, exp.DataType.Type): - converter = self.TYPE_CONVERTERS.get(this.this) - if converter: - this = converter(t.cast(exp.DataType, this)) - - return this - - def _parse_vector_expressions( - self, expressions: t.List[exp.Expression] - ) -> t.List[exp.Expression]: - return [ - exp.DataType.build(expressions[0].name, dialect=self.dialect), - *expressions[1:], - ] - - def _parse_struct_types( - self, type_required: bool = False - ) -> t.Optional[exp.Expression]: - index = self._index - - if ( - self._curr - and self._next - and self._curr.token_type in self.TYPE_TOKENS - and self._next.token_type in self.TYPE_TOKENS - ): - # Takes care of special cases like `STRUCT>` where the identifier is also a - # type token. Without this, the list will be parsed as a type and we'll eventually crash - this = self._parse_id_var() - else: - this = ( - self._parse_type(parse_interval=False, fallback_to_identifier=True) - or self._parse_id_var() - ) - - self._match(TokenType.COLON) - - if ( - type_required - and not isinstance(this, exp.DataType) - and not self._match_set(self.TYPE_TOKENS, advance=False) - ): - self._retreat(index) - return self._parse_types() - - return self._parse_column_def(this) - - def _parse_at_time_zone( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if not self._match_text_seq("AT", "TIME", "ZONE"): - return this - return self._parse_at_time_zone( - self.expression(exp.AtTimeZone, this=this, zone=self._parse_unary()) - ) - - def _parse_column(self) -> t.Optional[exp.Expression]: - this = self._parse_column_reference() - column = self._parse_column_ops(this) if this else self._parse_bracket(this) - - if self.dialect.SUPPORTS_COLUMN_JOIN_MARKS and column: - column.set("join_mark", self._match(TokenType.JOIN_MARKER)) - - return column - - def _parse_column_reference(self) -> t.Optional[exp.Expression]: - this = self._parse_field() - if ( - not this - and self._match(TokenType.VALUES, advance=False) - and self.VALUES_FOLLOWED_BY_PAREN - and (not self._next or self._next.token_type != TokenType.L_PAREN) - ): - this = self._parse_id_var() - - if isinstance(this, exp.Identifier): - # We bubble up comments from the Identifier to the Column - this = self.expression(exp.Column, comments=this.pop_comments(), this=this) - - return this - - def _parse_colon_as_variant_extract( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - casts = [] - json_path = [] - escape = None - - while self._match(TokenType.COLON): - start_index = self._index - - # Snowflake allows reserved keywords as json keys but advance_any() excludes TokenType.SELECT from any_tokens=True - path = self._parse_column_ops( - self._parse_field(any_token=True, tokens=(TokenType.SELECT,)) - ) - - # The cast :: operator has a lower precedence than the extraction operator :, so - # we rearrange the AST appropriately to avoid casting the JSON path - while isinstance(path, exp.Cast): - casts.append(path.to) - path = path.this - - if casts: - dcolon_offset = next( - i - for i, t in enumerate(self._tokens[start_index:]) - if t.token_type == TokenType.DCOLON - ) - end_token = self._tokens[start_index + dcolon_offset - 1] - else: - end_token = self._prev - - if path: - # Escape single quotes from Snowflake's colon extraction (e.g. col:"a'b") as - # it'll roundtrip to a string literal in GET_PATH - if isinstance(path, exp.Identifier) and path.quoted: - escape = True - - json_path.append(self._find_sql(self._tokens[start_index], end_token)) - - # The VARIANT extract in Snowflake/Databricks is parsed as a JSONExtract; Snowflake uses the json_path in GET_PATH() while - # Databricks transforms it back to the colon/dot notation - if json_path: - json_path_expr = self.dialect.to_json_path( - exp.Literal.string(".".join(json_path)) - ) - - if json_path_expr: - json_path_expr.set("escape", escape) - - this = self.expression( - exp.JSONExtract, - this=this, - expression=json_path_expr, - variant_extract=True, - requires_json=self.JSON_EXTRACT_REQUIRES_JSON_EXPRESSION, - ) - - while casts: - this = self.expression(exp.Cast, this=this, to=casts.pop()) - - return this - - def _parse_dcolon(self) -> t.Optional[exp.Expression]: - return self._parse_types() - - def _parse_column_ops( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - this = self._parse_bracket(this) - - while self._match_set(self.COLUMN_OPERATORS): - op_token = self._prev.token_type - op = self.COLUMN_OPERATORS.get(op_token) - - if op_token in self.CAST_COLUMN_OPERATORS: - field = self._parse_dcolon() - if not field: - self.raise_error("Expected type") - elif op and self._curr: - field = self._parse_column_reference() or self._parse_bitwise() - if isinstance(field, exp.Column) and self._match( - TokenType.DOT, advance=False - ): - field = self._parse_column_ops(field) - else: - field = self._parse_field(any_token=True, anonymous_func=True) - - # Function calls can be qualified, e.g., x.y.FOO() - # This converts the final AST to a series of Dots leading to the function call - # https://cloud.google.com/bigquery/docs/reference/standard-sql/functions-reference#function_call_rules - if isinstance(field, (exp.Func, exp.Window)) and this: - this = this.transform( - lambda n: ( - n.to_dot(include_dots=False) if isinstance(n, exp.Column) else n - ) - ) - - if op: - this = op(self, this, field) - elif isinstance(this, exp.Column) and not this.args.get("catalog"): - this = self.expression( - exp.Column, - comments=this.comments, - this=field, - table=this.this, - db=this.args.get("table"), - catalog=this.args.get("db"), - ) - elif isinstance(field, exp.Window): - # Move the exp.Dot's to the window's function - window_func = self.expression(exp.Dot, this=this, expression=field.this) - field.set("this", window_func) - this = field - else: - this = self.expression(exp.Dot, this=this, expression=field) - - if field and field.comments: - t.cast(exp.Expression, this).add_comments(field.pop_comments()) - - this = self._parse_bracket(this) - - return ( - self._parse_colon_as_variant_extract(this) - if self.COLON_IS_VARIANT_EXTRACT - else this - ) - - def _parse_paren(self) -> t.Optional[exp.Expression]: - if not self._match(TokenType.L_PAREN): - return None - - comments = self._prev_comments - query = self._parse_select() - - if query: - expressions = [query] - else: - expressions = self._parse_expressions() - - this = seq_get(expressions, 0) - - if not this and self._match(TokenType.R_PAREN, advance=False): - this = self.expression(exp.Tuple) - elif isinstance(this, exp.UNWRAPPED_QUERIES): - this = self._parse_subquery(this=this, parse_alias=False) - elif isinstance(this, (exp.Subquery, exp.Values)): - this = self._parse_subquery( - this=self._parse_query_modifiers(self._parse_set_operations(this)), - parse_alias=False, - ) - elif len(expressions) > 1 or self._prev.token_type == TokenType.COMMA: - this = self.expression(exp.Tuple, expressions=expressions) - else: - this = self.expression(exp.Paren, this=this) - - if this: - this.add_comments(comments) - - self._match_r_paren(expression=this) - - if isinstance(this, exp.Paren) and isinstance(this.this, exp.AggFunc): - return self._parse_window(this) - - return this - - def _parse_primary(self) -> t.Optional[exp.Expression]: - if self._match_set(self.PRIMARY_PARSERS): - token_type = self._prev.token_type - primary = self.PRIMARY_PARSERS[token_type](self, self._prev) - - if token_type == TokenType.STRING: - expressions = [primary] - while self._match(TokenType.STRING): - expressions.append(exp.Literal.string(self._prev.text)) - - if len(expressions) > 1: - return self.expression( - exp.Concat, - expressions=expressions, - coalesce=self.dialect.CONCAT_COALESCE, - ) - - return primary - - if self._match_pair(TokenType.DOT, TokenType.NUMBER): - return exp.Literal.number(f"0.{self._prev.text}") - - return self._parse_paren() - - def _parse_field( - self, - any_token: bool = False, - tokens: t.Optional[t.Collection[TokenType]] = None, - anonymous_func: bool = False, - ) -> t.Optional[exp.Expression]: - if anonymous_func: - field = ( - self._parse_function(anonymous=anonymous_func, any_token=any_token) - or self._parse_primary() - ) - else: - field = self._parse_primary() or self._parse_function( - anonymous=anonymous_func, any_token=any_token - ) - return field or self._parse_id_var(any_token=any_token, tokens=tokens) - - def _parse_function( - self, - functions: t.Optional[t.Dict[str, t.Callable]] = None, - anonymous: bool = False, - optional_parens: bool = True, - any_token: bool = False, - ) -> t.Optional[exp.Expression]: - # This allows us to also parse {fn } syntax (Snowflake, MySQL support this) - # See: https://community.snowflake.com/s/article/SQL-Escape-Sequences - fn_syntax = False - if ( - self._match(TokenType.L_BRACE, advance=False) - and self._next - and self._next.text.upper() == "FN" - ): - self._advance(2) - fn_syntax = True - - func = self._parse_function_call( - functions=functions, - anonymous=anonymous, - optional_parens=optional_parens, - any_token=any_token, - ) - - if fn_syntax: - self._match(TokenType.R_BRACE) - - return func - - def _parse_function_args(self, alias: bool = False) -> t.List[exp.Expression]: - return self._parse_csv(lambda: self._parse_lambda(alias=alias)) - - def _parse_function_call( - self, - functions: t.Optional[t.Dict[str, t.Callable]] = None, - anonymous: bool = False, - optional_parens: bool = True, - any_token: bool = False, - ) -> t.Optional[exp.Expression]: - if not self._curr: - return None - - comments = self._curr.comments - prev = self._prev - token = self._curr - token_type = self._curr.token_type - this = self._curr.text - upper = this.upper() - - parser = self.NO_PAREN_FUNCTION_PARSERS.get(upper) - if ( - optional_parens - and parser - and token_type not in self.INVALID_FUNC_NAME_TOKENS - ): - self._advance() - return self._parse_window(parser(self)) - - if not self._next or self._next.token_type != TokenType.L_PAREN: - if optional_parens and token_type in self.NO_PAREN_FUNCTIONS: - self._advance() - return self.expression(self.NO_PAREN_FUNCTIONS[token_type]) - - return None - - if any_token: - if token_type in self.RESERVED_TOKENS: - return None - elif token_type not in self.FUNC_TOKENS: - return None - - self._advance(2) - - parser = self.FUNCTION_PARSERS.get(upper) - if parser and not anonymous: - this = parser(self) - else: - subquery_predicate = self.SUBQUERY_PREDICATES.get(token_type) - - if subquery_predicate: - expr = None - if self._curr.token_type in (TokenType.SELECT, TokenType.WITH): - expr = self._parse_select() - self._match_r_paren() - elif prev and prev.token_type in (TokenType.LIKE, TokenType.ILIKE): - # Backtrack one token since we've consumed the L_PAREN here. Instead, we'd like - # to parse "LIKE [ANY | ALL] (...)" as a whole into an exp.Tuple or exp.Paren - self._advance(-1) - expr = self._parse_bitwise() - - if expr: - return self.expression( - subquery_predicate, comments=comments, this=expr - ) - - if functions is None: - functions = self.FUNCTIONS - - function = functions.get(upper) - known_function = function and not anonymous - - alias = not known_function or upper in self.FUNCTIONS_WITH_ALIASED_ARGS - args = self._parse_function_args(alias) - - post_func_comments = self._curr and self._curr.comments - if known_function and post_func_comments: - # If the user-inputted comment "/* sqlglot.anonymous */" is following the function - # call we'll construct it as exp.Anonymous, even if it's "known" - if any( - comment.lstrip().startswith(exp.SQLGLOT_ANONYMOUS) - for comment in post_func_comments - ): - known_function = False - - if alias and known_function: - args = self._kv_to_prop_eq(args) - - if known_function: - func_builder = t.cast(t.Callable, function) - - if "dialect" in func_builder.__code__.co_varnames: - func = func_builder(args, dialect=self.dialect) - else: - func = func_builder(args) - - func = self.validate_expression(func, args) - if self.dialect.PRESERVE_ORIGINAL_NAMES: - func.meta["name"] = this - - this = func - else: - if token_type == TokenType.IDENTIFIER: - this = exp.Identifier(this=this, quoted=True).update_positions( - token - ) - - this = self.expression(exp.Anonymous, this=this, expressions=args) - - this = this.update_positions(token) - - if isinstance(this, exp.Expression): - this.add_comments(comments) - - self._match_r_paren(this) - return self._parse_window(this) - - def _to_prop_eq(self, expression: exp.Expression, index: int) -> exp.Expression: - return expression - - def _kv_to_prop_eq( - self, expressions: t.List[exp.Expression], parse_map: bool = False - ) -> t.List[exp.Expression]: - transformed = [] - - for index, e in enumerate(expressions): - if isinstance(e, self.KEY_VALUE_DEFINITIONS): - if isinstance(e, exp.Alias): - e = self.expression( - exp.PropertyEQ, this=e.args.get("alias"), expression=e.this - ) - - if not isinstance(e, exp.PropertyEQ): - e = self.expression( - exp.PropertyEQ, - this=e.this if parse_map else exp.to_identifier(e.this.name), - expression=e.expression, - ) - - if isinstance(e.this, exp.Column): - e.this.replace(e.this.this) - else: - e = self._to_prop_eq(e, index) - - transformed.append(e) - - return transformed - - def _parse_user_defined_function_expression(self) -> t.Optional[exp.Expression]: - return self._parse_statement() - - def _parse_function_parameter(self) -> t.Optional[exp.Expression]: - return self._parse_column_def(this=self._parse_id_var(), computed_column=False) - - def _parse_user_defined_function( - self, kind: t.Optional[TokenType] = None - ) -> t.Optional[exp.Expression]: - this = self._parse_table_parts(schema=True) - - if not self._match(TokenType.L_PAREN): - return this - - expressions = self._parse_csv(self._parse_function_parameter) - self._match_r_paren() - return self.expression( - exp.UserDefinedFunction, this=this, expressions=expressions, wrapped=True - ) - - def _parse_introducer(self, token: Token) -> exp.Introducer | exp.Identifier: - literal = self._parse_primary() - if literal: - return self.expression(exp.Introducer, token=token, expression=literal) - - return self._identifier_expression(token) - - def _parse_session_parameter(self) -> exp.SessionParameter: - kind = None - this = self._parse_id_var() or self._parse_primary() - - if this and self._match(TokenType.DOT): - kind = this.name - this = self._parse_var() or self._parse_primary() - - return self.expression(exp.SessionParameter, this=this, kind=kind) - - def _parse_lambda_arg(self) -> t.Optional[exp.Expression]: - return self._parse_id_var() - - def _parse_lambda(self, alias: bool = False) -> t.Optional[exp.Expression]: - index = self._index - - if self._match(TokenType.L_PAREN): - expressions = t.cast( - t.List[t.Optional[exp.Expression]], - self._parse_csv(self._parse_lambda_arg), - ) - - if not self._match(TokenType.R_PAREN): - self._retreat(index) - else: - expressions = [self._parse_lambda_arg()] - - if self._match_set(self.LAMBDAS): - return self.LAMBDAS[self._prev.token_type](self, expressions) - - self._retreat(index) - - this: t.Optional[exp.Expression] - - if self._match(TokenType.DISTINCT): - this = self.expression( - exp.Distinct, expressions=self._parse_csv(self._parse_disjunction) - ) - else: - this = self._parse_select_or_expression(alias=alias) - - return self._parse_limit( - self._parse_order( - self._parse_having_max(self._parse_respect_or_ignore_nulls(this)) - ) - ) - - def _parse_schema( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - index = self._index - if not self._match(TokenType.L_PAREN): - return this - - # Disambiguate between schema and subquery/CTE, e.g. in INSERT INTO table (), - # expr can be of both types - if self._match_set(self.SELECT_START_TOKENS): - self._retreat(index) - return this - args = self._parse_csv( - lambda: self._parse_constraint() or self._parse_field_def() - ) - self._match_r_paren() - return self.expression(exp.Schema, this=this, expressions=args) - - def _parse_field_def(self) -> t.Optional[exp.Expression]: - return self._parse_column_def(self._parse_field(any_token=True)) - - def _parse_column_def( - self, this: t.Optional[exp.Expression], computed_column: bool = True - ) -> t.Optional[exp.Expression]: - # column defs are not really columns, they're identifiers - if isinstance(this, exp.Column): - this = this.this - - if not computed_column: - self._match(TokenType.ALIAS) - - kind = self._parse_types(schema=True) - - if self._match_text_seq("FOR", "ORDINALITY"): - return self.expression(exp.ColumnDef, this=this, ordinality=True) - - constraints: t.List[exp.Expression] = [] - - if (not kind and self._match(TokenType.ALIAS)) or self._match_texts( - ("ALIAS", "MATERIALIZED") - ): - persisted = self._prev.text.upper() == "MATERIALIZED" - constraint_kind = exp.ComputedColumnConstraint( - this=self._parse_disjunction(), - persisted=persisted or self._match_text_seq("PERSISTED"), - data_type=exp.Var(this="AUTO") - if self._match_text_seq("AUTO") - else self._parse_types(), - not_null=self._match_pair(TokenType.NOT, TokenType.NULL), - ) - constraints.append( - self.expression(exp.ColumnConstraint, kind=constraint_kind) - ) - elif ( - kind - and self._match(TokenType.ALIAS, advance=False) - and ( - not self.WRAPPED_TRANSFORM_COLUMN_CONSTRAINT - or (self._next and self._next.token_type == TokenType.L_PAREN) - ) - ): - self._advance() - constraints.append( - self.expression( - exp.ColumnConstraint, - kind=exp.ComputedColumnConstraint( - this=self._parse_disjunction(), - persisted=self._match_texts(("STORED", "VIRTUAL")) - and self._prev.text.upper() == "STORED", - ), - ) - ) - - while True: - constraint = self._parse_column_constraint() - if not constraint: - break - constraints.append(constraint) - - if not kind and not constraints: - return this - - return self.expression( - exp.ColumnDef, this=this, kind=kind, constraints=constraints - ) - - def _parse_auto_increment( - self, - ) -> exp.GeneratedAsIdentityColumnConstraint | exp.AutoIncrementColumnConstraint: - start = None - increment = None - order = None - - if self._match(TokenType.L_PAREN, advance=False): - args = self._parse_wrapped_csv(self._parse_bitwise) - start = seq_get(args, 0) - increment = seq_get(args, 1) - elif self._match_text_seq("START"): - start = self._parse_bitwise() - self._match_text_seq("INCREMENT") - increment = self._parse_bitwise() - if self._match_text_seq("ORDER"): - order = True - elif self._match_text_seq("NOORDER"): - order = False - - if start and increment: - return exp.GeneratedAsIdentityColumnConstraint( - start=start, increment=increment, this=False, order=order - ) - - return exp.AutoIncrementColumnConstraint() - - def _parse_auto_property(self) -> t.Optional[exp.AutoRefreshProperty]: - if not self._match_text_seq("REFRESH"): - self._retreat(self._index - 1) - return None - return self.expression( - exp.AutoRefreshProperty, this=self._parse_var(upper=True) - ) - - def _parse_compress(self) -> exp.CompressColumnConstraint: - if self._match(TokenType.L_PAREN, advance=False): - return self.expression( - exp.CompressColumnConstraint, - this=self._parse_wrapped_csv(self._parse_bitwise), - ) - - return self.expression(exp.CompressColumnConstraint, this=self._parse_bitwise()) - - def _parse_generated_as_identity( - self, - ) -> ( - exp.GeneratedAsIdentityColumnConstraint - | exp.ComputedColumnConstraint - | exp.GeneratedAsRowColumnConstraint - ): - if self._match_text_seq("BY", "DEFAULT"): - on_null = self._match_pair(TokenType.ON, TokenType.NULL) - this = self.expression( - exp.GeneratedAsIdentityColumnConstraint, this=False, on_null=on_null - ) - else: - self._match_text_seq("ALWAYS") - this = self.expression(exp.GeneratedAsIdentityColumnConstraint, this=True) - - self._match(TokenType.ALIAS) - - if self._match_text_seq("ROW"): - start = self._match_text_seq("START") - if not start: - self._match(TokenType.END) - hidden = self._match_text_seq("HIDDEN") - return self.expression( - exp.GeneratedAsRowColumnConstraint, start=start, hidden=hidden - ) - - identity = self._match_text_seq("IDENTITY") - - if self._match(TokenType.L_PAREN): - if self._match(TokenType.START_WITH): - this.set("start", self._parse_bitwise()) - if self._match_text_seq("INCREMENT", "BY"): - this.set("increment", self._parse_bitwise()) - if self._match_text_seq("MINVALUE"): - this.set("minvalue", self._parse_bitwise()) - if self._match_text_seq("MAXVALUE"): - this.set("maxvalue", self._parse_bitwise()) - - if self._match_text_seq("CYCLE"): - this.set("cycle", True) - elif self._match_text_seq("NO", "CYCLE"): - this.set("cycle", False) - - if not identity: - this.set("expression", self._parse_range()) - elif not this.args.get("start") and self._match( - TokenType.NUMBER, advance=False - ): - args = self._parse_csv(self._parse_bitwise) - this.set("start", seq_get(args, 0)) - this.set("increment", seq_get(args, 1)) - - self._match_r_paren() - - return this - - def _parse_inline(self) -> exp.InlineLengthColumnConstraint: - self._match_text_seq("LENGTH") - return self.expression( - exp.InlineLengthColumnConstraint, this=self._parse_bitwise() - ) - - def _parse_not_constraint(self) -> t.Optional[exp.Expression]: - if self._match_text_seq("NULL"): - return self.expression(exp.NotNullColumnConstraint) - if self._match_text_seq("CASESPECIFIC"): - return self.expression(exp.CaseSpecificColumnConstraint, not_=True) - if self._match_text_seq("FOR", "REPLICATION"): - return self.expression(exp.NotForReplicationColumnConstraint) - - # Unconsume the `NOT` token - self._retreat(self._index - 1) - return None - - def _parse_column_constraint(self) -> t.Optional[exp.Expression]: - this = self._match(TokenType.CONSTRAINT) and self._parse_id_var() - - procedure_option_follows = ( - self._match(TokenType.WITH, advance=False) - and self._next - and self._next.text.upper() in self.PROCEDURE_OPTIONS - ) - - if not procedure_option_follows and self._match_texts(self.CONSTRAINT_PARSERS): - return self.expression( - exp.ColumnConstraint, - this=this, - kind=self.CONSTRAINT_PARSERS[self._prev.text.upper()](self), - ) - - return this - - def _parse_constraint(self) -> t.Optional[exp.Expression]: - if not self._match(TokenType.CONSTRAINT): - return self._parse_unnamed_constraint( - constraints=self.SCHEMA_UNNAMED_CONSTRAINTS - ) - - return self.expression( - exp.Constraint, - this=self._parse_id_var(), - expressions=self._parse_unnamed_constraints(), - ) - - def _parse_unnamed_constraints(self) -> t.List[exp.Expression]: - constraints = [] - while True: - constraint = self._parse_unnamed_constraint() or self._parse_function() - if not constraint: - break - constraints.append(constraint) - - return constraints - - def _parse_unnamed_constraint( - self, constraints: t.Optional[t.Collection[str]] = None - ) -> t.Optional[exp.Expression]: - if self._match(TokenType.IDENTIFIER, advance=False) or not self._match_texts( - constraints or self.CONSTRAINT_PARSERS - ): - return None - - constraint = self._prev.text.upper() - if constraint not in self.CONSTRAINT_PARSERS: - self.raise_error(f"No parser found for schema constraint {constraint}.") - - return self.CONSTRAINT_PARSERS[constraint](self) - - def _parse_unique_key(self) -> t.Optional[exp.Expression]: - return self._parse_id_var(any_token=False) - - def _parse_unique(self) -> exp.UniqueColumnConstraint: - self._match_texts(("KEY", "INDEX")) - return self.expression( - exp.UniqueColumnConstraint, - nulls=self._match_text_seq("NULLS", "NOT", "DISTINCT"), - this=self._parse_schema(self._parse_unique_key()), - index_type=self._match(TokenType.USING) - and self._advance_any() - and self._prev.text, - on_conflict=self._parse_on_conflict(), - options=self._parse_key_constraint_options(), - ) - - def _parse_key_constraint_options(self) -> t.List[str]: - options = [] - while True: - if not self._curr: - break - - if self._match(TokenType.ON): - action = None - on = self._advance_any() and self._prev.text - - if self._match_text_seq("NO", "ACTION"): - action = "NO ACTION" - elif self._match_text_seq("CASCADE"): - action = "CASCADE" - elif self._match_text_seq("RESTRICT"): - action = "RESTRICT" - elif self._match_pair(TokenType.SET, TokenType.NULL): - action = "SET NULL" - elif self._match_pair(TokenType.SET, TokenType.DEFAULT): - action = "SET DEFAULT" - else: - self.raise_error("Invalid key constraint") - - options.append(f"ON {on} {action}") - else: - var = self._parse_var_from_options( - self.KEY_CONSTRAINT_OPTIONS, raise_unmatched=False - ) - if not var: - break - options.append(var.name) - - return options - - def _parse_references(self, match: bool = True) -> t.Optional[exp.Reference]: - if match and not self._match(TokenType.REFERENCES): - return None - - expressions = None - this = self._parse_table(schema=True) - options = self._parse_key_constraint_options() - return self.expression( - exp.Reference, this=this, expressions=expressions, options=options - ) - - def _parse_foreign_key(self) -> exp.ForeignKey: - expressions = ( - self._parse_wrapped_id_vars() - if not self._match(TokenType.REFERENCES, advance=False) - else None - ) - reference = self._parse_references() - on_options = {} - - while self._match(TokenType.ON): - if not self._match_set((TokenType.DELETE, TokenType.UPDATE)): - self.raise_error("Expected DELETE or UPDATE") - - kind = self._prev.text.lower() - - if self._match_text_seq("NO", "ACTION"): - action = "NO ACTION" - elif self._match(TokenType.SET): - self._match_set((TokenType.NULL, TokenType.DEFAULT)) - action = "SET " + self._prev.text.upper() - else: - self._advance() - action = self._prev.text.upper() - - on_options[kind] = action - - return self.expression( - exp.ForeignKey, - expressions=expressions, - reference=reference, - options=self._parse_key_constraint_options(), - **on_options, # type: ignore - ) - - def _parse_primary_key_part(self) -> t.Optional[exp.Expression]: - return self._parse_field() - - def _parse_period_for_system_time( - self, - ) -> t.Optional[exp.PeriodForSystemTimeConstraint]: - if not self._match(TokenType.TIMESTAMP_SNAPSHOT): - self._retreat(self._index - 1) - return None - - id_vars = self._parse_wrapped_id_vars() - return self.expression( - exp.PeriodForSystemTimeConstraint, - this=seq_get(id_vars, 0), - expression=seq_get(id_vars, 1), - ) - - def _parse_primary_key( - self, wrapped_optional: bool = False, in_props: bool = False - ) -> exp.PrimaryKeyColumnConstraint | exp.PrimaryKey: - desc = ( - self._match_set((TokenType.ASC, TokenType.DESC)) - and self._prev.token_type == TokenType.DESC - ) - - this = None - if ( - self._curr.text.upper() not in self.CONSTRAINT_PARSERS - and self._next - and self._next.token_type == TokenType.L_PAREN - ): - this = self._parse_id_var() - - if not in_props and not self._match(TokenType.L_PAREN, advance=False): - return self.expression( - exp.PrimaryKeyColumnConstraint, - desc=desc, - options=self._parse_key_constraint_options(), - ) - - expressions = self._parse_wrapped_csv( - self._parse_primary_key_part, optional=wrapped_optional - ) - - return self.expression( - exp.PrimaryKey, - this=this, - expressions=expressions, - include=self._parse_index_params(), - options=self._parse_key_constraint_options(), - ) - - def _parse_bracket_key_value( - self, is_map: bool = False - ) -> t.Optional[exp.Expression]: - return self._parse_slice( - self._parse_alias(self._parse_disjunction(), explicit=True) - ) - - def _parse_odbc_datetime_literal(self) -> exp.Expression: - """ - Parses a datetime column in ODBC format. We parse the column into the corresponding - types, for example `{d'yyyy-mm-dd'}` will be parsed as a `Date` column, exactly the - same as we did for `DATE('yyyy-mm-dd')`. - - Reference: - https://learn.microsoft.com/en-us/sql/odbc/reference/develop-app/date-time-and-timestamp-literals - """ - self._match(TokenType.VAR) - exp_class = self.ODBC_DATETIME_LITERALS[self._prev.text.lower()] - expression = self.expression(exp_class=exp_class, this=self._parse_string()) - if not self._match(TokenType.R_BRACE): - self.raise_error("Expected }") - return expression - - def _parse_bracket( - self, this: t.Optional[exp.Expression] = None - ) -> t.Optional[exp.Expression]: - if not self._match_set((TokenType.L_BRACKET, TokenType.L_BRACE)): - return this - - if self.MAP_KEYS_ARE_ARBITRARY_EXPRESSIONS: - map_token = seq_get(self._tokens, self._index - 2) - parse_map = map_token is not None and map_token.text.upper() == "MAP" - else: - parse_map = False - - bracket_kind = self._prev.token_type - if ( - bracket_kind == TokenType.L_BRACE - and self._curr - and self._curr.token_type == TokenType.VAR - and self._curr.text.lower() in self.ODBC_DATETIME_LITERALS - ): - return self._parse_odbc_datetime_literal() - - expressions = self._parse_csv( - lambda: self._parse_bracket_key_value( - is_map=bracket_kind == TokenType.L_BRACE - ) - ) - - if bracket_kind == TokenType.L_BRACKET and not self._match(TokenType.R_BRACKET): - self.raise_error("Expected ]") - elif bracket_kind == TokenType.L_BRACE and not self._match(TokenType.R_BRACE): - self.raise_error("Expected }") - - # https://duckdb.org/docs/sql/data_types/struct.html#creating-structs - if bracket_kind == TokenType.L_BRACE: - this = self.expression( - exp.Struct, - expressions=self._kv_to_prop_eq( - expressions=expressions, parse_map=parse_map - ), - ) - elif not this: - this = build_array_constructor( - exp.Array, - args=expressions, - bracket_kind=bracket_kind, - dialect=self.dialect, - ) - else: - constructor_type = self.ARRAY_CONSTRUCTORS.get(this.name.upper()) - if constructor_type: - return build_array_constructor( - constructor_type, - args=expressions, - bracket_kind=bracket_kind, - dialect=self.dialect, - ) - - expressions = apply_index_offset( - this, expressions, -self.dialect.INDEX_OFFSET, dialect=self.dialect - ) - this = self.expression( - exp.Bracket, - this=this, - expressions=expressions, - comments=this.pop_comments(), - ) - - self._add_comments(this) - return self._parse_bracket(this) - - def _parse_slice( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if not self._match(TokenType.COLON): - return this - - if self._match_pair(TokenType.DASH, TokenType.COLON, advance=False): - self._advance() - end: t.Optional[exp.Expression] = -exp.Literal.number("1") - else: - end = self._parse_unary() - step = self._parse_unary() if self._match(TokenType.COLON) else None - return self.expression(exp.Slice, this=this, expression=end, step=step) - - def _parse_case(self) -> t.Optional[exp.Expression]: - if self._match(TokenType.DOT, advance=False): - # Avoid raising on valid expressions like case.*, supported by, e.g., spark & snowflake - self._retreat(self._index - 1) - return None - - ifs = [] - default = None - - comments = self._prev_comments - expression = self._parse_disjunction() - - while self._match(TokenType.WHEN): - this = self._parse_disjunction() - self._match(TokenType.THEN) - then = self._parse_disjunction() - ifs.append(self.expression(exp.If, this=this, true=then)) - - if self._match(TokenType.ELSE): - default = self._parse_disjunction() - - if not self._match(TokenType.END): - if ( - isinstance(default, exp.Interval) - and default.this.sql().upper() == "END" - ): - default = exp.column("interval") - else: - self.raise_error("Expected END after CASE", self._prev) - - return self.expression( - exp.Case, comments=comments, this=expression, ifs=ifs, default=default - ) - - def _parse_if(self) -> t.Optional[exp.Expression]: - if self._match(TokenType.L_PAREN): - args = self._parse_csv( - lambda: self._parse_alias(self._parse_assignment(), explicit=True) - ) - this = self.validate_expression(exp.If.from_arg_list(args), args) - self._match_r_paren() - else: - index = self._index - 1 - - if self.NO_PAREN_IF_COMMANDS and index == 0: - return self._parse_as_command(self._prev) - - condition = self._parse_disjunction() - - if not condition: - self._retreat(index) - return None - - self._match(TokenType.THEN) - true = self._parse_disjunction() - false = self._parse_disjunction() if self._match(TokenType.ELSE) else None - self._match(TokenType.END) - this = self.expression(exp.If, this=condition, true=true, false=false) - - return this - - def _parse_next_value_for(self) -> t.Optional[exp.Expression]: - if not self._match_text_seq("VALUE", "FOR"): - self._retreat(self._index - 1) - return None - - return self.expression( - exp.NextValueFor, - this=self._parse_column(), - order=self._match(TokenType.OVER) - and self._parse_wrapped(self._parse_order), - ) - - def _parse_extract(self) -> exp.Extract | exp.Anonymous: - this = self._parse_function() or self._parse_var_or_string(upper=True) - - if self._match(TokenType.FROM): - return self.expression( - exp.Extract, this=this, expression=self._parse_bitwise() - ) - - if not self._match(TokenType.COMMA): - self.raise_error("Expected FROM or comma after EXTRACT", self._prev) - - return self.expression(exp.Extract, this=this, expression=self._parse_bitwise()) - - def _parse_gap_fill(self) -> exp.GapFill: - self._match(TokenType.TABLE) - this = self._parse_table() - - self._match(TokenType.COMMA) - args = [this, *self._parse_csv(self._parse_lambda)] - - gap_fill = exp.GapFill.from_arg_list(args) - return self.validate_expression(gap_fill, args) - - def _parse_cast( - self, strict: bool, safe: t.Optional[bool] = None - ) -> exp.Expression: - this = self._parse_disjunction() - - if not self._match(TokenType.ALIAS): - if self._match(TokenType.COMMA): - return self.expression( - exp.CastToStrType, this=this, to=self._parse_string() - ) - - self.raise_error("Expected AS after CAST") - - fmt = None - to = self._parse_types() - - default = self._match(TokenType.DEFAULT) - if default: - default = self._parse_bitwise() - self._match_text_seq("ON", "CONVERSION", "ERROR") - - if self._match_set((TokenType.FORMAT, TokenType.COMMA)): - fmt_string = self._parse_string() - fmt = self._parse_at_time_zone(fmt_string) - - if not to: - to = exp.DataType.build(exp.DataType.Type.UNKNOWN) - if to.this in exp.DataType.TEMPORAL_TYPES: - this = self.expression( - exp.StrToDate - if to.this == exp.DataType.Type.DATE - else exp.StrToTime, - this=this, - format=exp.Literal.string( - format_time( - fmt_string.this if fmt_string else "", - self.dialect.FORMAT_MAPPING or self.dialect.TIME_MAPPING, - self.dialect.FORMAT_TRIE or self.dialect.TIME_TRIE, - ) - ), - safe=safe, - ) - - if isinstance(fmt, exp.AtTimeZone) and isinstance(this, exp.StrToTime): - this.set("zone", fmt.args["zone"]) - return this - elif not to: - self.raise_error("Expected TYPE after CAST") - elif isinstance(to, exp.Identifier): - to = exp.DataType.build(to.name, dialect=self.dialect, udt=True) - elif to.this == exp.DataType.Type.CHAR: - if self._match(TokenType.CHARACTER_SET): - to = self.expression(exp.CharacterSet, this=self._parse_var_or_string()) - - return self.build_cast( - strict=strict, - this=this, - to=to, - format=fmt, - safe=safe, - action=self._parse_var_from_options( - self.CAST_ACTIONS, raise_unmatched=False - ), - default=default, - ) - - def _parse_string_agg(self) -> exp.GroupConcat: - if self._match(TokenType.DISTINCT): - args: t.List[t.Optional[exp.Expression]] = [ - self.expression(exp.Distinct, expressions=[self._parse_disjunction()]) - ] - if self._match(TokenType.COMMA): - args.extend(self._parse_csv(self._parse_disjunction)) - else: - args = self._parse_csv(self._parse_disjunction) # type: ignore - - if self._match_text_seq("ON", "OVERFLOW"): - # trino: LISTAGG(expression [, separator] [ON OVERFLOW overflow_behavior]) - if self._match_text_seq("ERROR"): - on_overflow: t.Optional[exp.Expression] = exp.var("ERROR") - else: - self._match_text_seq("TRUNCATE") - on_overflow = self.expression( - exp.OverflowTruncateBehavior, - this=self._parse_string(), - with_count=( - self._match_text_seq("WITH", "COUNT") - or not self._match_text_seq("WITHOUT", "COUNT") - ), - ) - else: - on_overflow = None - - index = self._index - if not self._match(TokenType.R_PAREN) and args: - # postgres: STRING_AGG([DISTINCT] expression, separator [ORDER BY expression1 {ASC | DESC} [, ...]]) - # bigquery: STRING_AGG([DISTINCT] expression [, separator] [ORDER BY key [{ASC | DESC}] [, ... ]] [LIMIT n]) - # The order is parsed through `this` as a canonicalization for WITHIN GROUPs - args[0] = self._parse_limit(this=self._parse_order(this=args[0])) - return self.expression( - exp.GroupConcat, this=args[0], separator=seq_get(args, 1) - ) - - # Checks if we can parse an order clause: WITHIN GROUP (ORDER BY [ASC | DESC]). - # This is done "manually", instead of letting _parse_window parse it into an exp.WithinGroup node, so that - # the STRING_AGG call is parsed like in MySQL / SQLite and can thus be transpiled more easily to them. - if not self._match_text_seq("WITHIN", "GROUP"): - self._retreat(index) - return self.validate_expression(exp.GroupConcat.from_arg_list(args), args) - - # The corresponding match_r_paren will be called in parse_function (caller) - self._match_l_paren() - - return self.expression( - exp.GroupConcat, - this=self._parse_order(this=seq_get(args, 0)), - separator=seq_get(args, 1), - on_overflow=on_overflow, - ) - - def _parse_convert( - self, strict: bool, safe: t.Optional[bool] = None - ) -> t.Optional[exp.Expression]: - this = self._parse_bitwise() - - if self._match(TokenType.USING): - to: t.Optional[exp.Expression] = self.expression( - exp.CharacterSet, this=self._parse_var() - ) - elif self._match(TokenType.COMMA): - to = self._parse_types() - else: - to = None - - return self.build_cast(strict=strict, this=this, to=to, safe=safe) - - def _parse_xml_table(self) -> exp.XMLTable: - namespaces = None - passing = None - columns = None - - if self._match_text_seq("XMLNAMESPACES", "("): - namespaces = self._parse_xml_namespace() - self._match_text_seq(")", ",") - - this = self._parse_string() - - if self._match_text_seq("PASSING"): - # The BY VALUE keywords are optional and are provided for semantic clarity - self._match_text_seq("BY", "VALUE") - passing = self._parse_csv(self._parse_column) - - by_ref = self._match_text_seq("RETURNING", "SEQUENCE", "BY", "REF") - - if self._match_text_seq("COLUMNS"): - columns = self._parse_csv(self._parse_field_def) - - return self.expression( - exp.XMLTable, - this=this, - namespaces=namespaces, - passing=passing, - columns=columns, - by_ref=by_ref, - ) - - def _parse_xml_namespace(self) -> t.List[exp.XMLNamespace]: - namespaces = [] - - while True: - if self._match(TokenType.DEFAULT): - uri = self._parse_string() - else: - uri = self._parse_alias(self._parse_string()) - namespaces.append(self.expression(exp.XMLNamespace, this=uri)) - if not self._match(TokenType.COMMA): - break - - return namespaces - - def _parse_decode(self) -> t.Optional[exp.Decode | exp.DecodeCase]: - args = self._parse_csv(self._parse_disjunction) - - if len(args) < 3: - return self.expression( - exp.Decode, this=seq_get(args, 0), charset=seq_get(args, 1) - ) - - return self.expression(exp.DecodeCase, expressions=args) - - def _parse_json_key_value(self) -> t.Optional[exp.JSONKeyValue]: - self._match_text_seq("KEY") - key = self._parse_column() - self._match_set(self.JSON_KEY_VALUE_SEPARATOR_TOKENS) - self._match_text_seq("VALUE") - value = self._parse_bitwise() - - if not key and not value: - return None - return self.expression(exp.JSONKeyValue, this=key, expression=value) - - def _parse_format_json( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if not this or not self._match_text_seq("FORMAT", "JSON"): - return this - - return self.expression(exp.FormatJson, this=this) - - def _parse_on_condition(self) -> t.Optional[exp.OnCondition]: - # MySQL uses "X ON EMPTY Y ON ERROR" (e.g. JSON_VALUE) while Oracle uses the opposite (e.g. JSON_EXISTS) - if self.dialect.ON_CONDITION_EMPTY_BEFORE_ERROR: - empty = self._parse_on_handling("EMPTY", *self.ON_CONDITION_TOKENS) - error = self._parse_on_handling("ERROR", *self.ON_CONDITION_TOKENS) - else: - error = self._parse_on_handling("ERROR", *self.ON_CONDITION_TOKENS) - empty = self._parse_on_handling("EMPTY", *self.ON_CONDITION_TOKENS) - - null = self._parse_on_handling("NULL", *self.ON_CONDITION_TOKENS) - - if not empty and not error and not null: - return None - - return self.expression( - exp.OnCondition, - empty=empty, - error=error, - null=null, - ) - - def _parse_on_handling( - self, on: str, *values: str - ) -> t.Optional[str] | t.Optional[exp.Expression]: - # Parses the "X ON Y" or "DEFAULT ON Y syntax, e.g. NULL ON NULL (Oracle, T-SQL, MySQL) - for value in values: - if self._match_text_seq(value, "ON", on): - return f"{value} ON {on}" - - index = self._index - if self._match(TokenType.DEFAULT): - default_value = self._parse_bitwise() - if self._match_text_seq("ON", on): - return default_value - - self._retreat(index) - - return None - - @t.overload - def _parse_json_object(self, agg: Lit[False]) -> exp.JSONObject: ... - - @t.overload - def _parse_json_object(self, agg: Lit[True]) -> exp.JSONObjectAgg: ... - - def _parse_json_object(self, agg=False): - star = self._parse_star() - expressions = ( - [star] - if star - else self._parse_csv( - lambda: self._parse_format_json(self._parse_json_key_value()) - ) - ) - null_handling = self._parse_on_handling("NULL", "NULL", "ABSENT") - - unique_keys = None - if self._match_text_seq("WITH", "UNIQUE"): - unique_keys = True - elif self._match_text_seq("WITHOUT", "UNIQUE"): - unique_keys = False - - self._match_text_seq("KEYS") - - return_type = self._match_text_seq("RETURNING") and self._parse_format_json( - self._parse_type() - ) - encoding = self._match_text_seq("ENCODING") and self._parse_var() - - return self.expression( - exp.JSONObjectAgg if agg else exp.JSONObject, - expressions=expressions, - null_handling=null_handling, - unique_keys=unique_keys, - return_type=return_type, - encoding=encoding, - ) - - # Note: this is currently incomplete; it only implements the "JSON_value_column" part - def _parse_json_column_def(self) -> exp.JSONColumnDef: - if not self._match_text_seq("NESTED"): - this = self._parse_id_var() - ordinality = self._match_pair(TokenType.FOR, TokenType.ORDINALITY) - kind = self._parse_types(allow_identifiers=False) - nested = None - else: - this = None - ordinality = None - kind = None - nested = True - - path = self._match_text_seq("PATH") and self._parse_string() - nested_schema = nested and self._parse_json_schema() - - return self.expression( - exp.JSONColumnDef, - this=this, - kind=kind, - path=path, - nested_schema=nested_schema, - ordinality=ordinality, - ) - - def _parse_json_schema(self) -> exp.JSONSchema: - self._match_text_seq("COLUMNS") - return self.expression( - exp.JSONSchema, - expressions=self._parse_wrapped_csv( - self._parse_json_column_def, optional=True - ), - ) - - def _parse_json_table(self) -> exp.JSONTable: - this = self._parse_format_json(self._parse_bitwise()) - path = self._match(TokenType.COMMA) and self._parse_string() - error_handling = self._parse_on_handling("ERROR", "ERROR", "NULL") - empty_handling = self._parse_on_handling("EMPTY", "ERROR", "NULL") - schema = self._parse_json_schema() - - return exp.JSONTable( - this=this, - schema=schema, - path=path, - error_handling=error_handling, - empty_handling=empty_handling, - ) - - def _parse_match_against(self) -> exp.MatchAgainst: - if self._match_text_seq("TABLE"): - # parse SingleStore MATCH(TABLE ...) syntax - # https://docs.singlestore.com/cloud/reference/sql-reference/full-text-search-functions/match/ - expressions = [] - table = self._parse_table() - if table: - expressions = [table] - else: - expressions = self._parse_csv(self._parse_column) - - self._match_text_seq(")", "AGAINST", "(") - - this = self._parse_string() - - if self._match_text_seq("IN", "NATURAL", "LANGUAGE", "MODE"): - modifier = "IN NATURAL LANGUAGE MODE" - if self._match_text_seq("WITH", "QUERY", "EXPANSION"): - modifier = f"{modifier} WITH QUERY EXPANSION" - elif self._match_text_seq("IN", "BOOLEAN", "MODE"): - modifier = "IN BOOLEAN MODE" - elif self._match_text_seq("WITH", "QUERY", "EXPANSION"): - modifier = "WITH QUERY EXPANSION" - else: - modifier = None - - return self.expression( - exp.MatchAgainst, this=this, expressions=expressions, modifier=modifier - ) - - # https://learn.microsoft.com/en-us/sql/t-sql/functions/openjson-transact-sql?view=sql-server-ver16 - def _parse_open_json(self) -> exp.OpenJSON: - this = self._parse_bitwise() - path = self._match(TokenType.COMMA) and self._parse_string() - - def _parse_open_json_column_def() -> exp.OpenJSONColumnDef: - this = self._parse_field(any_token=True) - kind = self._parse_types() - path = self._parse_string() - as_json = self._match_pair(TokenType.ALIAS, TokenType.JSON) - - return self.expression( - exp.OpenJSONColumnDef, this=this, kind=kind, path=path, as_json=as_json - ) - - expressions = None - if self._match_pair(TokenType.R_PAREN, TokenType.WITH): - self._match_l_paren() - expressions = self._parse_csv(_parse_open_json_column_def) - - return self.expression( - exp.OpenJSON, this=this, path=path, expressions=expressions - ) - - def _parse_position(self, haystack_first: bool = False) -> exp.StrPosition: - args = self._parse_csv(self._parse_bitwise) - - if self._match(TokenType.IN): - return self.expression( - exp.StrPosition, this=self._parse_bitwise(), substr=seq_get(args, 0) - ) - - if haystack_first: - haystack = seq_get(args, 0) - needle = seq_get(args, 1) - else: - haystack = seq_get(args, 1) - needle = seq_get(args, 0) - - return self.expression( - exp.StrPosition, this=haystack, substr=needle, position=seq_get(args, 2) - ) - - def _parse_join_hint(self, func_name: str) -> exp.JoinHint: - args = self._parse_csv(self._parse_table) - return exp.JoinHint(this=func_name.upper(), expressions=args) - - def _parse_substring(self) -> exp.Substring: - # Postgres supports the form: substring(string [from int] [for int]) - # (despite being undocumented, the reverse order also works) - # https://www.postgresql.org/docs/9.1/functions-string.html @ Table 9-6 - - args = t.cast( - t.List[t.Optional[exp.Expression]], self._parse_csv(self._parse_bitwise) - ) - - start, length = None, None - - while self._curr: - if self._match(TokenType.FROM): - start = self._parse_bitwise() - elif self._match(TokenType.FOR): - if not start: - start = exp.Literal.number(1) - length = self._parse_bitwise() - else: - break - - if start: - args.append(start) - if length: - args.append(length) - - return self.validate_expression(exp.Substring.from_arg_list(args), args) - - def _parse_trim(self) -> exp.Trim: - # https://www.w3resource.com/sql/character-functions/trim.php - # https://docs.oracle.com/javadb/10.8.3.0/ref/rreftrimfunc.html - - position = None - collation = None - expression = None - - if self._match_texts(self.TRIM_TYPES): - position = self._prev.text.upper() - - this = self._parse_bitwise() - if self._match_set((TokenType.FROM, TokenType.COMMA)): - invert_order = ( - self._prev.token_type == TokenType.FROM or self.TRIM_PATTERN_FIRST - ) - expression = self._parse_bitwise() - - if invert_order: - this, expression = expression, this - - if self._match(TokenType.COLLATE): - collation = self._parse_bitwise() - - return self.expression( - exp.Trim, - this=this, - position=position, - expression=expression, - collation=collation, - ) - - def _parse_window_clause(self) -> t.Optional[t.List[exp.Expression]]: - return self._match(TokenType.WINDOW) and self._parse_csv( - self._parse_named_window - ) - - def _parse_named_window(self) -> t.Optional[exp.Expression]: - return self._parse_window(self._parse_id_var(), alias=True) - - def _parse_respect_or_ignore_nulls( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if self._match_text_seq("IGNORE", "NULLS"): - return self.expression(exp.IgnoreNulls, this=this) - if self._match_text_seq("RESPECT", "NULLS"): - return self.expression(exp.RespectNulls, this=this) - return this - - def _parse_having_max( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - if self._match(TokenType.HAVING): - self._match_texts(("MAX", "MIN")) - max = self._prev.text.upper() != "MIN" - return self.expression( - exp.HavingMax, this=this, expression=self._parse_column(), max=max - ) - - return this - - def _parse_window( - self, this: t.Optional[exp.Expression], alias: bool = False - ) -> t.Optional[exp.Expression]: - func = this - comments = func.comments if isinstance(func, exp.Expression) else None - - # T-SQL allows the OVER (...) syntax after WITHIN GROUP. - # https://learn.microsoft.com/en-us/sql/t-sql/functions/percentile-disc-transact-sql?view=sql-server-ver16 - if self._match_text_seq("WITHIN", "GROUP"): - order = self._parse_wrapped(self._parse_order) - this = self.expression(exp.WithinGroup, this=this, expression=order) - - if self._match_pair(TokenType.FILTER, TokenType.L_PAREN): - self._match(TokenType.WHERE) - this = self.expression( - exp.Filter, - this=this, - expression=self._parse_where(skip_where_token=True), - ) - self._match_r_paren() - - # SQL spec defines an optional [ { IGNORE | RESPECT } NULLS ] OVER - # Some dialects choose to implement and some do not. - # https://dev.mysql.com/doc/refman/8.0/en/window-function-descriptions.html - - # There is some code above in _parse_lambda that handles - # SELECT FIRST_VALUE(TABLE.COLUMN IGNORE|RESPECT NULLS) OVER ... - - # The below changes handle - # SELECT FIRST_VALUE(TABLE.COLUMN) IGNORE|RESPECT NULLS OVER ... - - # Oracle allows both formats - # (https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/img_text/first_value.html) - # and Snowflake chose to do the same for familiarity - # https://docs.snowflake.com/en/sql-reference/functions/first_value.html#usage-notes - if isinstance(this, exp.AggFunc): - ignore_respect = this.find(exp.IgnoreNulls, exp.RespectNulls) - - if ignore_respect and ignore_respect is not this: - ignore_respect.replace(ignore_respect.this) - this = self.expression(ignore_respect.__class__, this=this) - - this = self._parse_respect_or_ignore_nulls(this) - - # bigquery select from window x AS (partition by ...) - if alias: - over = None - self._match(TokenType.ALIAS) - elif not self._match_set(self.WINDOW_BEFORE_PAREN_TOKENS): - return this - else: - over = self._prev.text.upper() - - if comments and isinstance(func, exp.Expression): - func.pop_comments() - - if not self._match(TokenType.L_PAREN): - return self.expression( - exp.Window, - comments=comments, - this=this, - alias=self._parse_id_var(False), - over=over, - ) - - window_alias = self._parse_id_var( - any_token=False, tokens=self.WINDOW_ALIAS_TOKENS - ) - - first = self._match(TokenType.FIRST) - if self._match_text_seq("LAST"): - first = False - - partition, order = self._parse_partition_and_order() - kind = self._match_set((TokenType.ROWS, TokenType.RANGE)) and self._prev.text - - if kind: - self._match(TokenType.BETWEEN) - start = self._parse_window_spec() - - end = self._parse_window_spec() if self._match(TokenType.AND) else {} - exclude = ( - self._parse_var_from_options(self.WINDOW_EXCLUDE_OPTIONS) - if self._match_text_seq("EXCLUDE") - else None - ) - - spec = self.expression( - exp.WindowSpec, - kind=kind, - start=start["value"], - start_side=start["side"], - end=end.get("value"), - end_side=end.get("side"), - exclude=exclude, - ) - else: - spec = None - - self._match_r_paren() - - window = self.expression( - exp.Window, - comments=comments, - this=this, - partition_by=partition, - order=order, - spec=spec, - alias=window_alias, - over=over, - first=first, - ) - - # This covers Oracle's FIRST/LAST syntax: aggregate KEEP (...) OVER (...) - if self._match_set(self.WINDOW_BEFORE_PAREN_TOKENS, advance=False): - return self._parse_window(window, alias=alias) - - return window - - def _parse_partition_and_order( - self, - ) -> t.Tuple[t.List[exp.Expression], t.Optional[exp.Expression]]: - return self._parse_partition_by(), self._parse_order() - - def _parse_window_spec(self) -> t.Dict[str, t.Optional[str | exp.Expression]]: - self._match(TokenType.BETWEEN) - - return { - "value": ( - (self._match_text_seq("UNBOUNDED") and "UNBOUNDED") - or (self._match_text_seq("CURRENT", "ROW") and "CURRENT ROW") - or self._parse_bitwise() - ), - "side": self._match_texts(self.WINDOW_SIDES) and self._prev.text, - } - - def _parse_alias( - self, this: t.Optional[exp.Expression], explicit: bool = False - ) -> t.Optional[exp.Expression]: - # In some dialects, LIMIT and OFFSET can act as both identifiers and keywords (clauses) - # so this section tries to parse the clause version and if it fails, it treats the token - # as an identifier (alias) - if self._can_parse_limit_or_offset(): - return this - - any_token = self._match(TokenType.ALIAS) - comments = self._prev_comments or [] - - if explicit and not any_token: - return this - - if self._match(TokenType.L_PAREN): - aliases = self.expression( - exp.Aliases, - comments=comments, - this=this, - expressions=self._parse_csv(lambda: self._parse_id_var(any_token)), - ) - self._match_r_paren(aliases) - return aliases - - alias = self._parse_id_var(any_token, tokens=self.ALIAS_TOKENS) or ( - self.STRING_ALIASES and self._parse_string_as_identifier() - ) - - if alias: - comments.extend(alias.pop_comments()) - this = self.expression(exp.Alias, comments=comments, this=this, alias=alias) - column = this.this - - # Moves the comment next to the alias in `expr /* comment */ AS alias` - if not this.comments and column and column.comments: - this.comments = column.pop_comments() - - return this - - def _parse_id_var( - self, - any_token: bool = True, - tokens: t.Optional[t.Collection[TokenType]] = None, - ) -> t.Optional[exp.Expression]: - expression = self._parse_identifier() - if not expression and ( - (any_token and self._advance_any()) - or self._match_set(tokens or self.ID_VAR_TOKENS) - ): - quoted = self._prev.token_type == TokenType.STRING - expression = self._identifier_expression(quoted=quoted) - - return expression - - def _parse_string(self) -> t.Optional[exp.Expression]: - if self._match_set(self.STRING_PARSERS): - return self.STRING_PARSERS[self._prev.token_type](self, self._prev) - return self._parse_placeholder() - - def _parse_string_as_identifier(self) -> t.Optional[exp.Identifier]: - output = exp.to_identifier( - self._match(TokenType.STRING) and self._prev.text, quoted=True - ) - if output: - output.update_positions(self._prev) - return output - - def _parse_number(self) -> t.Optional[exp.Expression]: - if self._match_set(self.NUMERIC_PARSERS): - return self.NUMERIC_PARSERS[self._prev.token_type](self, self._prev) - return self._parse_placeholder() - - def _parse_identifier(self) -> t.Optional[exp.Expression]: - if self._match(TokenType.IDENTIFIER): - return self._identifier_expression(quoted=True) - return self._parse_placeholder() - - def _parse_var( - self, - any_token: bool = False, - tokens: t.Optional[t.Collection[TokenType]] = None, - upper: bool = False, - ) -> t.Optional[exp.Expression]: - if ( - (any_token and self._advance_any()) - or self._match(TokenType.VAR) - or (self._match_set(tokens) if tokens else False) - ): - return self.expression( - exp.Var, this=self._prev.text.upper() if upper else self._prev.text - ) - return self._parse_placeholder() - - def _advance_any(self, ignore_reserved: bool = False) -> t.Optional[Token]: - if self._curr and ( - ignore_reserved or self._curr.token_type not in self.RESERVED_TOKENS - ): - self._advance() - return self._prev - return None - - def _parse_var_or_string(self, upper: bool = False) -> t.Optional[exp.Expression]: - return self._parse_string() or self._parse_var(any_token=True, upper=upper) - - def _parse_primary_or_var(self) -> t.Optional[exp.Expression]: - return self._parse_primary() or self._parse_var(any_token=True) - - def _parse_null(self) -> t.Optional[exp.Expression]: - if self._match_set((TokenType.NULL, TokenType.UNKNOWN)): - return self.PRIMARY_PARSERS[TokenType.NULL](self, self._prev) - return self._parse_placeholder() - - def _parse_boolean(self) -> t.Optional[exp.Expression]: - if self._match(TokenType.TRUE): - return self.PRIMARY_PARSERS[TokenType.TRUE](self, self._prev) - if self._match(TokenType.FALSE): - return self.PRIMARY_PARSERS[TokenType.FALSE](self, self._prev) - return self._parse_placeholder() - - def _parse_star(self) -> t.Optional[exp.Expression]: - if self._match(TokenType.STAR): - return self.PRIMARY_PARSERS[TokenType.STAR](self, self._prev) - return self._parse_placeholder() - - def _parse_parameter(self) -> exp.Parameter: - this = self._parse_identifier() or self._parse_primary_or_var() - return self.expression(exp.Parameter, this=this) - - def _parse_placeholder(self) -> t.Optional[exp.Expression]: - if self._match_set(self.PLACEHOLDER_PARSERS): - placeholder = self.PLACEHOLDER_PARSERS[self._prev.token_type](self) - if placeholder: - return placeholder - self._advance(-1) - return None - - def _parse_star_op(self, *keywords: str) -> t.Optional[t.List[exp.Expression]]: - if not self._match_texts(keywords): - return None - if self._match(TokenType.L_PAREN, advance=False): - return self._parse_wrapped_csv(self._parse_expression) - - expression = self._parse_alias(self._parse_disjunction(), explicit=True) - return [expression] if expression else None - - def _parse_csv( - self, parse_method: t.Callable, sep: TokenType = TokenType.COMMA - ) -> t.List[exp.Expression]: - parse_result = parse_method() - items = [parse_result] if parse_result is not None else [] - - while self._match(sep): - self._add_comments(parse_result) - parse_result = parse_method() - if parse_result is not None: - items.append(parse_result) - - return items - - def _parse_tokens( - self, parse_method: t.Callable, expressions: t.Dict - ) -> t.Optional[exp.Expression]: - this = parse_method() - - while self._match_set(expressions): - this = self.expression( - expressions[self._prev.token_type], - this=this, - comments=self._prev_comments, - expression=parse_method(), - ) - - return this - - def _parse_wrapped_id_vars(self, optional: bool = False) -> t.List[exp.Expression]: - return self._parse_wrapped_csv(self._parse_id_var, optional=optional) - - def _parse_wrapped_csv( - self, - parse_method: t.Callable, - sep: TokenType = TokenType.COMMA, - optional: bool = False, - ) -> t.List[exp.Expression]: - return self._parse_wrapped( - lambda: self._parse_csv(parse_method, sep=sep), optional=optional - ) - - def _parse_wrapped(self, parse_method: t.Callable, optional: bool = False) -> t.Any: - wrapped = self._match(TokenType.L_PAREN) - if not wrapped and not optional: - self.raise_error("Expecting (") - parse_result = parse_method() - if wrapped: - self._match_r_paren() - return parse_result - - def _parse_expressions(self) -> t.List[exp.Expression]: - return self._parse_csv(self._parse_expression) - - def _parse_select_or_expression( - self, alias: bool = False - ) -> t.Optional[exp.Expression]: - return ( - self._parse_set_operations( - self._parse_alias(self._parse_assignment(), explicit=True) - if alias - else self._parse_assignment() - ) - or self._parse_select() - ) - - def _parse_ddl_select(self) -> t.Optional[exp.Expression]: - return self._parse_query_modifiers( - self._parse_set_operations( - self._parse_select(nested=True, parse_subquery_alias=False) - ) - ) - - def _parse_transaction(self) -> exp.Transaction | exp.Command: - this = None - if self._match_texts(self.TRANSACTION_KIND): - this = self._prev.text - - self._match_texts(("TRANSACTION", "WORK")) - - modes = [] - while True: - mode = [] - while self._match(TokenType.VAR) or self._match(TokenType.NOT): - mode.append(self._prev.text) - - if mode: - modes.append(" ".join(mode)) - if not self._match(TokenType.COMMA): - break - - return self.expression(exp.Transaction, this=this, modes=modes) - - def _parse_commit_or_rollback(self) -> exp.Commit | exp.Rollback: - chain = None - savepoint = None - is_rollback = self._prev.token_type == TokenType.ROLLBACK - - self._match_texts(("TRANSACTION", "WORK")) - - if self._match_text_seq("TO"): - self._match_text_seq("SAVEPOINT") - savepoint = self._parse_id_var() - - if self._match(TokenType.AND): - chain = not self._match_text_seq("NO") - self._match_text_seq("CHAIN") - - if is_rollback: - return self.expression(exp.Rollback, savepoint=savepoint) - - return self.expression(exp.Commit, chain=chain) - - def _parse_refresh(self) -> exp.Refresh | exp.Command: - if self._match(TokenType.TABLE): - kind = "TABLE" - elif self._match_text_seq("MATERIALIZED", "VIEW"): - kind = "MATERIALIZED VIEW" - else: - kind = "" - - this = self._parse_string() or self._parse_table() - if not kind and not isinstance(this, exp.Literal): - return self._parse_as_command(self._prev) - - return self.expression(exp.Refresh, this=this, kind=kind) - - def _parse_column_def_with_exists(self): - start = self._index - self._match(TokenType.COLUMN) - - exists_column = self._parse_exists(not_=True) - expression = self._parse_field_def() - - if not isinstance(expression, exp.ColumnDef): - self._retreat(start) - return None - - expression.set("exists", exists_column) - - return expression - - def _parse_add_column(self) -> t.Optional[exp.ColumnDef]: - if not self._prev.text.upper() == "ADD": - return None - - expression = self._parse_column_def_with_exists() - if not expression: - return None - - # https://docs.databricks.com/delta/update-schema.html#explicitly-update-schema-to-add-columns - if self._match_texts(("FIRST", "AFTER")): - position = self._prev.text - column_position = self.expression( - exp.ColumnPosition, this=self._parse_column(), position=position - ) - expression.set("position", column_position) - - return expression - - def _parse_drop_column(self) -> t.Optional[exp.Drop | exp.Command]: - drop = self._match(TokenType.DROP) and self._parse_drop() - if drop and not isinstance(drop, exp.Command): - drop.set("kind", drop.args.get("kind", "COLUMN")) - return drop - - # https://docs.aws.amazon.com/athena/latest/ug/alter-table-drop-partition.html - def _parse_drop_partition( - self, exists: t.Optional[bool] = None - ) -> exp.DropPartition: - return self.expression( - exp.DropPartition, - expressions=self._parse_csv(self._parse_partition), - exists=exists, - ) - - def _parse_alter_table_add(self) -> t.List[exp.Expression]: - def _parse_add_alteration() -> t.Optional[exp.Expression]: - self._match_text_seq("ADD") - if self._match_set(self.ADD_CONSTRAINT_TOKENS, advance=False): - return self.expression( - exp.AddConstraint, - expressions=self._parse_csv(self._parse_constraint), - ) - - column_def = self._parse_add_column() - if isinstance(column_def, exp.ColumnDef): - return column_def - - exists = self._parse_exists(not_=True) - if self._match_pair(TokenType.PARTITION, TokenType.L_PAREN, advance=False): - return self.expression( - exp.AddPartition, - exists=exists, - this=self._parse_field(any_token=True), - location=self._match_text_seq("LOCATION", advance=False) - and self._parse_property(), - ) - - return None - - if not self._match_set(self.ADD_CONSTRAINT_TOKENS, advance=False) and ( - not self.dialect.ALTER_TABLE_ADD_REQUIRED_FOR_EACH_COLUMN - or self._match_text_seq("COLUMNS") - ): - schema = self._parse_schema() - - return ( - ensure_list(schema) - if schema - else self._parse_csv(self._parse_column_def_with_exists) - ) - - return self._parse_csv(_parse_add_alteration) - - def _parse_alter_table_alter(self) -> t.Optional[exp.Expression]: - if self._match_texts(self.ALTER_ALTER_PARSERS): - return self.ALTER_ALTER_PARSERS[self._prev.text.upper()](self) - - # Many dialects support the ALTER [COLUMN] syntax, so if there is no - # keyword after ALTER we default to parsing this statement - self._match(TokenType.COLUMN) - column = self._parse_field(any_token=True) - - if self._match_pair(TokenType.DROP, TokenType.DEFAULT): - return self.expression(exp.AlterColumn, this=column, drop=True) - if self._match_pair(TokenType.SET, TokenType.DEFAULT): - return self.expression( - exp.AlterColumn, this=column, default=self._parse_disjunction() - ) - if self._match(TokenType.COMMENT): - return self.expression( - exp.AlterColumn, this=column, comment=self._parse_string() - ) - if self._match_text_seq("DROP", "NOT", "NULL"): - return self.expression( - exp.AlterColumn, - this=column, - drop=True, - allow_null=True, - ) - if self._match_text_seq("SET", "NOT", "NULL"): - return self.expression( - exp.AlterColumn, - this=column, - allow_null=False, - ) - - if self._match_text_seq("SET", "VISIBLE"): - return self.expression(exp.AlterColumn, this=column, visible="VISIBLE") - if self._match_text_seq("SET", "INVISIBLE"): - return self.expression(exp.AlterColumn, this=column, visible="INVISIBLE") - - self._match_text_seq("SET", "DATA") - self._match_text_seq("TYPE") - return self.expression( - exp.AlterColumn, - this=column, - dtype=self._parse_types(), - collate=self._match(TokenType.COLLATE) and self._parse_term(), - using=self._match(TokenType.USING) and self._parse_disjunction(), - ) - - def _parse_alter_diststyle(self) -> exp.AlterDistStyle: - if self._match_texts(("ALL", "EVEN", "AUTO")): - return self.expression( - exp.AlterDistStyle, this=exp.var(self._prev.text.upper()) - ) - - self._match_text_seq("KEY", "DISTKEY") - return self.expression(exp.AlterDistStyle, this=self._parse_column()) - - def _parse_alter_sortkey( - self, compound: t.Optional[bool] = None - ) -> exp.AlterSortKey: - if compound: - self._match_text_seq("SORTKEY") - - if self._match(TokenType.L_PAREN, advance=False): - return self.expression( - exp.AlterSortKey, - expressions=self._parse_wrapped_id_vars(), - compound=compound, - ) - - self._match_texts(("AUTO", "NONE")) - return self.expression( - exp.AlterSortKey, this=exp.var(self._prev.text.upper()), compound=compound - ) - - def _parse_alter_table_drop(self) -> t.List[exp.Expression]: - index = self._index - 1 - - partition_exists = self._parse_exists() - if self._match(TokenType.PARTITION, advance=False): - return self._parse_csv( - lambda: self._parse_drop_partition(exists=partition_exists) - ) - - self._retreat(index) - return self._parse_csv(self._parse_drop_column) - - def _parse_alter_table_rename( - self, - ) -> t.Optional[exp.AlterRename | exp.RenameColumn]: - if self._match(TokenType.COLUMN) or not self.ALTER_RENAME_REQUIRES_COLUMN: - exists = self._parse_exists() - old_column = self._parse_column() - to = self._match_text_seq("TO") - new_column = self._parse_column() - - if old_column is None or to is None or new_column is None: - return None - - return self.expression( - exp.RenameColumn, this=old_column, to=new_column, exists=exists - ) - - self._match_text_seq("TO") - return self.expression(exp.AlterRename, this=self._parse_table(schema=True)) - - def _parse_alter_table_set(self) -> exp.AlterSet: - alter_set = self.expression(exp.AlterSet) - - if self._match(TokenType.L_PAREN, advance=False) or self._match_text_seq( - "TABLE", "PROPERTIES" - ): - alter_set.set( - "expressions", self._parse_wrapped_csv(self._parse_assignment) - ) - elif self._match_text_seq("FILESTREAM_ON", advance=False): - alter_set.set("expressions", [self._parse_assignment()]) - elif self._match_texts(("LOGGED", "UNLOGGED")): - alter_set.set("option", exp.var(self._prev.text.upper())) - elif self._match_text_seq("WITHOUT") and self._match_texts(("CLUSTER", "OIDS")): - alter_set.set("option", exp.var(f"WITHOUT {self._prev.text.upper()}")) - elif self._match_text_seq("LOCATION"): - alter_set.set("location", self._parse_field()) - elif self._match_text_seq("ACCESS", "METHOD"): - alter_set.set("access_method", self._parse_field()) - elif self._match_text_seq("TABLESPACE"): - alter_set.set("tablespace", self._parse_field()) - elif self._match_text_seq("FILE", "FORMAT") or self._match_text_seq( - "FILEFORMAT" - ): - alter_set.set("file_format", [self._parse_field()]) - elif self._match_text_seq("STAGE_FILE_FORMAT"): - alter_set.set("file_format", self._parse_wrapped_options()) - elif self._match_text_seq("STAGE_COPY_OPTIONS"): - alter_set.set("copy_options", self._parse_wrapped_options()) - elif self._match_text_seq("TAG") or self._match_text_seq("TAGS"): - alter_set.set("tag", self._parse_csv(self._parse_assignment)) - else: - if self._match_text_seq("SERDE"): - alter_set.set("serde", self._parse_field()) - - properties = self._parse_wrapped(self._parse_properties, optional=True) - alter_set.set("expressions", [properties]) - - return alter_set - - def _parse_alter_session(self) -> exp.AlterSession: - """Parse ALTER SESSION SET/UNSET statements.""" - if self._match(TokenType.SET): - expressions = self._parse_csv(lambda: self._parse_set_item_assignment()) - return self.expression( - exp.AlterSession, expressions=expressions, unset=False - ) - - self._match_text_seq("UNSET") - expressions = self._parse_csv( - lambda: self.expression( - exp.SetItem, this=self._parse_id_var(any_token=True) - ) - ) - return self.expression(exp.AlterSession, expressions=expressions, unset=True) - - def _parse_alter(self) -> exp.Alter | exp.Command: - start = self._prev - - alter_token = self._match_set(self.ALTERABLES) and self._prev - if not alter_token: - return self._parse_as_command(start) - - exists = self._parse_exists() - only = self._match_text_seq("ONLY") - - if alter_token.token_type == TokenType.SESSION: - this = None - check = None - cluster = None - else: - this = self._parse_table( - schema=True, parse_partition=self.ALTER_TABLE_PARTITIONS - ) - check = self._match_text_seq("WITH", "CHECK") - cluster = self._parse_on_property() if self._match(TokenType.ON) else None - - if self._next: - self._advance() - - parser = self.ALTER_PARSERS.get(self._prev.text.upper()) if self._prev else None - if parser: - actions = ensure_list(parser(self)) - not_valid = self._match_text_seq("NOT", "VALID") - options = self._parse_csv(self._parse_property) - cascade = ( - self.dialect.ALTER_TABLE_SUPPORTS_CASCADE - and self._match_text_seq("CASCADE") - ) - - if not self._curr and actions: - return self.expression( - exp.Alter, - this=this, - kind=alter_token.text.upper(), - exists=exists, - actions=actions, - only=only, - options=options, - cluster=cluster, - not_valid=not_valid, - check=check, - cascade=cascade, - ) - - return self._parse_as_command(start) - - def _parse_analyze(self) -> exp.Analyze | exp.Command: - start = self._prev - # https://duckdb.org/docs/sql/statements/analyze - if not self._curr: - return self.expression(exp.Analyze) - - options = [] - while self._match_texts(self.ANALYZE_STYLES): - if self._prev.text.upper() == "BUFFER_USAGE_LIMIT": - options.append(f"BUFFER_USAGE_LIMIT {self._parse_number()}") - else: - options.append(self._prev.text.upper()) - - this: t.Optional[exp.Expression] = None - inner_expression: t.Optional[exp.Expression] = None - - kind = self._curr and self._curr.text.upper() - - if self._match(TokenType.TABLE) or self._match(TokenType.INDEX): - this = self._parse_table_parts() - elif self._match_text_seq("TABLES"): - if self._match_set((TokenType.FROM, TokenType.IN)): - kind = f"{kind} {self._prev.text.upper()}" - this = self._parse_table(schema=True, is_db_reference=True) - elif self._match_text_seq("DATABASE"): - this = self._parse_table(schema=True, is_db_reference=True) - elif self._match_text_seq("CLUSTER"): - this = self._parse_table() - # Try matching inner expr keywords before fallback to parse table. - elif self._match_texts(self.ANALYZE_EXPRESSION_PARSERS): - kind = None - inner_expression = self.ANALYZE_EXPRESSION_PARSERS[self._prev.text.upper()]( - self - ) - else: - # Empty kind https://prestodb.io/docs/current/sql/analyze.html - kind = None - this = self._parse_table_parts() - - partition = self._try_parse(self._parse_partition) - if not partition and self._match_texts(self.PARTITION_KEYWORDS): - return self._parse_as_command(start) - - # https://docs.starrocks.io/docs/sql-reference/sql-statements/cbo_stats/ANALYZE_TABLE/ - if self._match_text_seq("WITH", "SYNC", "MODE") or self._match_text_seq( - "WITH", "ASYNC", "MODE" - ): - mode = f"WITH {self._tokens[self._index - 2].text.upper()} MODE" - else: - mode = None - - if self._match_texts(self.ANALYZE_EXPRESSION_PARSERS): - inner_expression = self.ANALYZE_EXPRESSION_PARSERS[self._prev.text.upper()]( - self - ) - - properties = self._parse_properties() - return self.expression( - exp.Analyze, - kind=kind, - this=this, - mode=mode, - partition=partition, - properties=properties, - expression=inner_expression, - options=options, - ) - - # https://spark.apache.org/docs/3.5.1/sql-ref-syntax-aux-analyze-table.html - def _parse_analyze_statistics(self) -> exp.AnalyzeStatistics: - this = None - kind = self._prev.text.upper() - option = self._prev.text.upper() if self._match_text_seq("DELTA") else None - expressions = [] - - if not self._match_text_seq("STATISTICS"): - self.raise_error("Expecting token STATISTICS") - - if self._match_text_seq("NOSCAN"): - this = "NOSCAN" - elif self._match(TokenType.FOR): - if self._match_text_seq("ALL", "COLUMNS"): - this = "FOR ALL COLUMNS" - if self._match_texts("COLUMNS"): - this = "FOR COLUMNS" - expressions = self._parse_csv(self._parse_column_reference) - elif self._match_text_seq("SAMPLE"): - sample = self._parse_number() - expressions = [ - self.expression( - exp.AnalyzeSample, - sample=sample, - kind=self._prev.text.upper() - if self._match(TokenType.PERCENT) - else None, - ) - ] - - return self.expression( - exp.AnalyzeStatistics, - kind=kind, - option=option, - this=this, - expressions=expressions, - ) - - # https://docs.oracle.com/en/database/oracle/oracle-database/21/sqlrf/ANALYZE.html - def _parse_analyze_validate(self) -> exp.AnalyzeValidate: - kind = None - this = None - expression: t.Optional[exp.Expression] = None - if self._match_text_seq("REF", "UPDATE"): - kind = "REF" - this = "UPDATE" - if self._match_text_seq("SET", "DANGLING", "TO", "NULL"): - this = "UPDATE SET DANGLING TO NULL" - elif self._match_text_seq("STRUCTURE"): - kind = "STRUCTURE" - if self._match_text_seq("CASCADE", "FAST"): - this = "CASCADE FAST" - elif self._match_text_seq("CASCADE", "COMPLETE") and self._match_texts( - ("ONLINE", "OFFLINE") - ): - this = f"CASCADE COMPLETE {self._prev.text.upper()}" - expression = self._parse_into() - - return self.expression( - exp.AnalyzeValidate, kind=kind, this=this, expression=expression - ) - - def _parse_analyze_columns(self) -> t.Optional[exp.AnalyzeColumns]: - this = self._prev.text.upper() - if self._match_text_seq("COLUMNS"): - return self.expression( - exp.AnalyzeColumns, this=f"{this} {self._prev.text.upper()}" - ) - return None - - def _parse_analyze_delete(self) -> t.Optional[exp.AnalyzeDelete]: - kind = self._prev.text.upper() if self._match_text_seq("SYSTEM") else None - if self._match_text_seq("STATISTICS"): - return self.expression(exp.AnalyzeDelete, kind=kind) - return None - - def _parse_analyze_list(self) -> t.Optional[exp.AnalyzeListChainedRows]: - if self._match_text_seq("CHAINED", "ROWS"): - return self.expression( - exp.AnalyzeListChainedRows, expression=self._parse_into() - ) - return None - - # https://dev.mysql.com/doc/refman/8.4/en/analyze-table.html - def _parse_analyze_histogram(self) -> exp.AnalyzeHistogram: - this = self._prev.text.upper() - expression: t.Optional[exp.Expression] = None - expressions = [] - update_options = None - - if self._match_text_seq("HISTOGRAM", "ON"): - expressions = self._parse_csv(self._parse_column_reference) - with_expressions = [] - while self._match(TokenType.WITH): - # https://docs.starrocks.io/docs/sql-reference/sql-statements/cbo_stats/ANALYZE_TABLE/ - if self._match_texts(("SYNC", "ASYNC")): - if self._match_text_seq("MODE", advance=False): - with_expressions.append(f"{self._prev.text.upper()} MODE") - self._advance() - else: - buckets = self._parse_number() - if self._match_text_seq("BUCKETS"): - with_expressions.append(f"{buckets} BUCKETS") - if with_expressions: - expression = self.expression( - exp.AnalyzeWith, expressions=with_expressions - ) - - if self._match_texts(("MANUAL", "AUTO")) and self._match( - TokenType.UPDATE, advance=False - ): - update_options = self._prev.text.upper() - self._advance() - elif self._match_text_seq("USING", "DATA"): - expression = self.expression(exp.UsingData, this=self._parse_string()) - - return self.expression( - exp.AnalyzeHistogram, - this=this, - expressions=expressions, - expression=expression, - update_options=update_options, - ) - - def _parse_merge(self) -> exp.Merge: - self._match(TokenType.INTO) - target = self._parse_table() - - if target and self._match(TokenType.ALIAS, advance=False): - target.set("alias", self._parse_table_alias()) - - self._match(TokenType.USING) - using = self._parse_table() - - return self.expression( - exp.Merge, - this=target, - using=using, - on=self._match(TokenType.ON) and self._parse_disjunction(), - using_cond=self._match(TokenType.USING) and self._parse_using_identifiers(), - whens=self._parse_when_matched(), - returning=self._parse_returning(), - ) - - def _parse_when_matched(self) -> exp.Whens: - whens = [] - - while self._match(TokenType.WHEN): - matched = not self._match(TokenType.NOT) - self._match_text_seq("MATCHED") - source = ( - False - if self._match_text_seq("BY", "TARGET") - else self._match_text_seq("BY", "SOURCE") - ) - condition = ( - self._parse_disjunction() if self._match(TokenType.AND) else None - ) - - self._match(TokenType.THEN) - - if self._match(TokenType.INSERT): - this = self._parse_star() - if this: - then: t.Optional[exp.Expression] = self.expression( - exp.Insert, this=this - ) - else: - then = self.expression( - exp.Insert, - this=exp.var("ROW") - if self._match_text_seq("ROW") - else self._parse_value(values=False), - expression=self._match_text_seq("VALUES") - and self._parse_value(), - ) - elif self._match(TokenType.UPDATE): - expressions = self._parse_star() - if expressions: - then = self.expression(exp.Update, expressions=expressions) - else: - then = self.expression( - exp.Update, - expressions=self._match(TokenType.SET) - and self._parse_csv(self._parse_equality), - ) - elif self._match(TokenType.DELETE): - then = self.expression(exp.Var, this=self._prev.text) - else: - then = self._parse_var_from_options(self.CONFLICT_ACTIONS) - - whens.append( - self.expression( - exp.When, - matched=matched, - source=source, - condition=condition, - then=then, - ) - ) - return self.expression(exp.Whens, expressions=whens) - - def _parse_show(self) -> t.Optional[exp.Expression]: - parser = self._find_parser(self.SHOW_PARSERS, self.SHOW_TRIE) - if parser: - return parser(self) - return self._parse_as_command(self._prev) - - def _parse_set_item_assignment( - self, kind: t.Optional[str] = None - ) -> t.Optional[exp.Expression]: - index = self._index - - if kind in ("GLOBAL", "SESSION") and self._match_text_seq("TRANSACTION"): - return self._parse_set_transaction(global_=kind == "GLOBAL") - - left = self._parse_primary() or self._parse_column() - assignment_delimiter = self._match_texts(self.SET_ASSIGNMENT_DELIMITERS) - - if not left or ( - self.SET_REQUIRES_ASSIGNMENT_DELIMITER and not assignment_delimiter - ): - self._retreat(index) - return None - - right = self._parse_statement() or self._parse_id_var() - if isinstance(right, (exp.Column, exp.Identifier)): - right = exp.var(right.name) - - this = self.expression(exp.EQ, this=left, expression=right) - return self.expression(exp.SetItem, this=this, kind=kind) - - def _parse_set_transaction(self, global_: bool = False) -> exp.Expression: - self._match_text_seq("TRANSACTION") - characteristics = self._parse_csv( - lambda: self._parse_var_from_options(self.TRANSACTION_CHARACTERISTICS) - ) - return self.expression( - exp.SetItem, - expressions=characteristics, - kind="TRANSACTION", - global_=global_, - ) - - def _parse_set_item(self) -> t.Optional[exp.Expression]: - parser = self._find_parser(self.SET_PARSERS, self.SET_TRIE) - return parser(self) if parser else self._parse_set_item_assignment(kind=None) - - def _parse_set( - self, unset: bool = False, tag: bool = False - ) -> exp.Set | exp.Command: - index = self._index - set_ = self.expression( - exp.Set, - expressions=self._parse_csv(self._parse_set_item), - unset=unset, - tag=tag, - ) - - if self._curr: - self._retreat(index) - return self._parse_as_command(self._prev) - - return set_ - - def _parse_var_from_options( - self, options: OPTIONS_TYPE, raise_unmatched: bool = True - ) -> t.Optional[exp.Var]: - start = self._curr - if not start: - return None - - option = start.text.upper() - continuations = options.get(option) - - index = self._index - self._advance() - for keywords in continuations or []: - if isinstance(keywords, str): - keywords = (keywords,) - - if self._match_text_seq(*keywords): - option = f"{option} {' '.join(keywords)}" - break - else: - if continuations or continuations is None: - if raise_unmatched: - self.raise_error(f"Unknown option {option}") - - self._retreat(index) - return None - - return exp.var(option) - - def _parse_as_command(self, start: Token) -> exp.Command: - while self._curr: - self._advance() - text = self._find_sql(start, self._prev) - size = len(start.text) - self._warn_unsupported() - return exp.Command(this=text[:size], expression=text[size:]) - - def _parse_dict_property(self, this: str) -> exp.DictProperty: - settings = [] - - self._match_l_paren() - kind = self._parse_id_var() - - if self._match(TokenType.L_PAREN): - while True: - key = self._parse_id_var() - value = self._parse_primary() - if not key and value is None: - break - settings.append( - self.expression(exp.DictSubProperty, this=key, value=value) - ) - self._match(TokenType.R_PAREN) - - self._match_r_paren() - - return self.expression( - exp.DictProperty, - this=this, - kind=kind.this if kind else None, - settings=settings, - ) - - def _parse_dict_range(self, this: str) -> exp.DictRange: - self._match_l_paren() - has_min = self._match_text_seq("MIN") - if has_min: - min = self._parse_var() or self._parse_primary() - self._match_text_seq("MAX") - max = self._parse_var() or self._parse_primary() - else: - max = self._parse_var() or self._parse_primary() - min = exp.Literal.number(0) - self._match_r_paren() - return self.expression(exp.DictRange, this=this, min=min, max=max) - - def _parse_comprehension( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Comprehension]: - index = self._index - expression = self._parse_column() - position = self._match(TokenType.COMMA) and self._parse_column() - - if not self._match(TokenType.IN): - self._retreat(index - 1) - return None - iterator = self._parse_column() - condition = self._parse_disjunction() if self._match_text_seq("IF") else None - return self.expression( - exp.Comprehension, - this=this, - expression=expression, - position=position, - iterator=iterator, - condition=condition, - ) - - def _parse_heredoc(self) -> t.Optional[exp.Heredoc]: - if self._match(TokenType.HEREDOC_STRING): - return self.expression(exp.Heredoc, this=self._prev.text) - - if not self._match_text_seq("$"): - return None - - tags = ["$"] - tag_text = None - - if self._is_connected(): - self._advance() - tags.append(self._prev.text.upper()) - else: - self.raise_error("No closing $ found") - - if tags[-1] != "$": - if self._is_connected() and self._match_text_seq("$"): - tag_text = tags[-1] - tags.append("$") - else: - self.raise_error("No closing $ found") - - heredoc_start = self._curr - - while self._curr: - if self._match_text_seq(*tags, advance=False): - this = self._find_sql(heredoc_start, self._prev) - self._advance(len(tags)) - return self.expression(exp.Heredoc, this=this, tag=tag_text) - - self._advance() - - self.raise_error(f"No closing {''.join(tags)} found") - return None - - def _find_parser( - self, parsers: t.Dict[str, t.Callable], trie: t.Dict - ) -> t.Optional[t.Callable]: - if not self._curr: - return None - - index = self._index - this = [] - while True: - # The current token might be multiple words - curr = self._curr.text.upper() - key = curr.split(" ") - this.append(curr) - - self._advance() - result, trie = in_trie(trie, key) - if result == TrieResult.FAILED: - break - - if result == TrieResult.EXISTS: - subparser = parsers[" ".join(this)] - return subparser - - self._retreat(index) - return None - - def _match(self, token_type, advance=True, expression=None): - if not self._curr: - return None - - if self._curr.token_type == token_type: - if advance: - self._advance() - self._add_comments(expression) - return True - - return None - - def _match_set(self, types, advance=True): - if not self._curr: - return None - - if self._curr.token_type in types: - if advance: - self._advance() - return True - - return None - - def _match_pair(self, token_type_a, token_type_b, advance=True): - if not self._curr or not self._next: - return None - - if ( - self._curr.token_type == token_type_a - and self._next.token_type == token_type_b - ): - if advance: - self._advance(2) - return True - - return None - - def _match_l_paren(self, expression: t.Optional[exp.Expression] = None) -> None: - if not self._match(TokenType.L_PAREN, expression=expression): - self.raise_error("Expecting (") - - def _match_r_paren(self, expression: t.Optional[exp.Expression] = None) -> None: - if not self._match(TokenType.R_PAREN, expression=expression): - self.raise_error("Expecting )") - - def _match_texts(self, texts, advance=True): - if ( - self._curr - and self._curr.token_type != TokenType.STRING - and self._curr.text.upper() in texts - ): - if advance: - self._advance() - return True - return None - - def _match_text_seq(self, *texts, advance=True): - index = self._index - for text in texts: - if ( - self._curr - and self._curr.token_type != TokenType.STRING - and self._curr.text.upper() == text - ): - self._advance() - else: - self._retreat(index) - return None - - if not advance: - self._retreat(index) - - return True - - def _replace_lambda( - self, node: t.Optional[exp.Expression], expressions: t.List[exp.Expression] - ) -> t.Optional[exp.Expression]: - if not node: - return node - - lambda_types = {e.name: e.args.get("to") or False for e in expressions} - - for column in node.find_all(exp.Column): - typ = lambda_types.get(column.parts[0].name) - if typ is not None: - dot_or_id = column.to_dot() if column.table else column.this - - if typ: - dot_or_id = self.expression( - exp.Cast, - this=dot_or_id, - to=typ, - ) - - parent = column.parent - - while isinstance(parent, exp.Dot): - if not isinstance(parent.parent, exp.Dot): - parent.replace(dot_or_id) - break - parent = parent.parent - else: - if column is node: - node = dot_or_id - else: - column.replace(dot_or_id) - return node - - def _parse_truncate_table(self) -> t.Optional[exp.TruncateTable] | exp.Expression: - start = self._prev - - # Not to be confused with TRUNCATE(number, decimals) function call - if self._match(TokenType.L_PAREN): - self._retreat(self._index - 2) - return self._parse_function() - - # Clickhouse supports TRUNCATE DATABASE as well - is_database = self._match(TokenType.DATABASE) - - self._match(TokenType.TABLE) - - exists = self._parse_exists(not_=False) - - expressions = self._parse_csv( - lambda: self._parse_table(schema=True, is_db_reference=is_database) - ) - - cluster = self._parse_on_property() if self._match(TokenType.ON) else None - - if self._match_text_seq("RESTART", "IDENTITY"): - identity = "RESTART" - elif self._match_text_seq("CONTINUE", "IDENTITY"): - identity = "CONTINUE" - else: - identity = None - - if self._match_text_seq("CASCADE") or self._match_text_seq("RESTRICT"): - option = self._prev.text - else: - option = None - - partition = self._parse_partition() - - # Fallback case - if self._curr: - return self._parse_as_command(start) - - return self.expression( - exp.TruncateTable, - expressions=expressions, - is_database=is_database, - exists=exists, - cluster=cluster, - identity=identity, - option=option, - partition=partition, - ) - - def _parse_with_operator(self) -> t.Optional[exp.Expression]: - this = self._parse_ordered(self._parse_opclass) - - if not self._match(TokenType.WITH): - return this - - op = self._parse_var(any_token=True) - - return self.expression(exp.WithOperator, this=this, op=op) - - def _parse_wrapped_options(self) -> t.List[t.Optional[exp.Expression]]: - self._match(TokenType.EQ) - self._match(TokenType.L_PAREN) - - opts: t.List[t.Optional[exp.Expression]] = [] - option: exp.Expression | None - while self._curr and not self._match(TokenType.R_PAREN): - if self._match_text_seq("FORMAT_NAME", "="): - # The FORMAT_NAME can be set to an identifier for Snowflake and T-SQL - option = self._parse_format_name() - else: - option = self._parse_property() - - if option is None: - self.raise_error("Unable to parse option") - break - - opts.append(option) - - return opts - - def _parse_copy_parameters(self) -> t.List[exp.CopyParameter]: - sep = TokenType.COMMA if self.dialect.COPY_PARAMS_ARE_CSV else None - - options = [] - while self._curr and not self._match(TokenType.R_PAREN, advance=False): - option = self._parse_var(any_token=True) - prev = self._prev.text.upper() - - # Different dialects might separate options and values by white space, "=" and "AS" - self._match(TokenType.EQ) - self._match(TokenType.ALIAS) - - param = self.expression(exp.CopyParameter, this=option) - - if prev in self.COPY_INTO_VARLEN_OPTIONS and self._match( - TokenType.L_PAREN, advance=False - ): - # Snowflake FILE_FORMAT case, Databricks COPY & FORMAT options - param.set("expressions", self._parse_wrapped_options()) - elif prev == "FILE_FORMAT": - # T-SQL's external file format case - param.set("expression", self._parse_field()) - elif ( - prev == "FORMAT" - and self._prev.token_type == TokenType.ALIAS - and self._match_texts(("AVRO", "JSON")) - ): - param.set("this", exp.var(f"FORMAT AS {self._prev.text.upper()}")) - param.set("expression", self._parse_field()) - else: - param.set( - "expression", self._parse_unquoted_field() or self._parse_bracket() - ) - - options.append(param) - self._match(sep) - - return options - - def _parse_credentials(self) -> t.Optional[exp.Credentials]: - expr = self.expression(exp.Credentials) - - if self._match_text_seq("STORAGE_INTEGRATION", "="): - expr.set("storage", self._parse_field()) - if self._match_text_seq("CREDENTIALS"): - # Snowflake case: CREDENTIALS = (...), Redshift case: CREDENTIALS - creds = ( - self._parse_wrapped_options() - if self._match(TokenType.EQ) - else self._parse_field() - ) - expr.set("credentials", creds) - if self._match_text_seq("ENCRYPTION"): - expr.set("encryption", self._parse_wrapped_options()) - if self._match_text_seq("IAM_ROLE"): - expr.set( - "iam_role", - exp.var(self._prev.text) - if self._match(TokenType.DEFAULT) - else self._parse_field(), - ) - if self._match_text_seq("REGION"): - expr.set("region", self._parse_field()) - - return expr - - def _parse_file_location(self) -> t.Optional[exp.Expression]: - return self._parse_field() - - def _parse_copy(self) -> exp.Copy | exp.Command: - start = self._prev - - self._match(TokenType.INTO) - - this = ( - self._parse_select(nested=True, parse_subquery_alias=False) - if self._match(TokenType.L_PAREN, advance=False) - else self._parse_table(schema=True) - ) - - kind = self._match(TokenType.FROM) or not self._match_text_seq("TO") - - files = self._parse_csv(self._parse_file_location) - if self._match(TokenType.EQ, advance=False): - # Backtrack one token since we've consumed the lhs of a parameter assignment here. - # This can happen for Snowflake dialect. Instead, we'd like to parse the parameter - # list via `_parse_wrapped(..)` below. - self._advance(-1) - files = [] - - credentials = self._parse_credentials() - - self._match_text_seq("WITH") - - params = self._parse_wrapped(self._parse_copy_parameters, optional=True) - - # Fallback case - if self._curr: - return self._parse_as_command(start) - - return self.expression( - exp.Copy, - this=this, - kind=kind, - credentials=credentials, - files=files, - params=params, - ) - - def _parse_normalize(self) -> exp.Normalize: - return self.expression( - exp.Normalize, - this=self._parse_bitwise(), - form=self._match(TokenType.COMMA) and self._parse_var(), - ) - - def _parse_ceil_floor(self, expr_type: t.Type[TCeilFloor]) -> TCeilFloor: - args = self._parse_csv(lambda: self._parse_lambda()) - - this = seq_get(args, 0) - decimals = seq_get(args, 1) - - return expr_type( - this=this, - decimals=decimals, - to=self._match_text_seq("TO") and self._parse_var(), - ) - - def _parse_star_ops(self) -> t.Optional[exp.Expression]: - star_token = self._prev - - if self._match_text_seq("COLUMNS", "(", advance=False): - this = self._parse_function() - if isinstance(this, exp.Columns): - this.set("unpack", True) - return this - - return self.expression( - exp.Star, - except_=self._parse_star_op("EXCEPT", "EXCLUDE"), - replace=self._parse_star_op("REPLACE"), - rename=self._parse_star_op("RENAME"), - ).update_positions(star_token) - - def _parse_grant_privilege(self) -> t.Optional[exp.GrantPrivilege]: - privilege_parts = [] - - # Keep consuming consecutive keywords until comma (end of this privilege) or ON - # (end of privilege list) or L_PAREN (start of column list) are met - while self._curr and not self._match_set( - self.PRIVILEGE_FOLLOW_TOKENS, advance=False - ): - privilege_parts.append(self._curr.text.upper()) - self._advance() - - this = exp.var(" ".join(privilege_parts)) - expressions = ( - self._parse_wrapped_csv(self._parse_column) - if self._match(TokenType.L_PAREN, advance=False) - else None - ) - - return self.expression(exp.GrantPrivilege, this=this, expressions=expressions) - - def _parse_grant_principal(self) -> t.Optional[exp.GrantPrincipal]: - kind = self._match_texts(("ROLE", "GROUP")) and self._prev.text.upper() - principal = self._parse_id_var() - - if not principal: - return None - - return self.expression(exp.GrantPrincipal, this=principal, kind=kind) - - def _parse_grant_revoke_common( - self, - ) -> t.Tuple[t.Optional[t.List], t.Optional[str], t.Optional[exp.Expression]]: - privileges = self._parse_csv(self._parse_grant_privilege) - - self._match(TokenType.ON) - kind = self._match_set(self.CREATABLES) and self._prev.text.upper() - - # Attempt to parse the securable e.g. MySQL allows names - # such as "foo.*", "*.*" which are not easily parseable yet - securable = self._try_parse(self._parse_table_parts) - - return privileges, kind, securable - - def _parse_grant(self) -> exp.Grant | exp.Command: - start = self._prev - - privileges, kind, securable = self._parse_grant_revoke_common() - - if not securable or not self._match_text_seq("TO"): - return self._parse_as_command(start) - - principals = self._parse_csv(self._parse_grant_principal) - - grant_option = self._match_text_seq("WITH", "GRANT", "OPTION") - - if self._curr: - return self._parse_as_command(start) - - return self.expression( - exp.Grant, - privileges=privileges, - kind=kind, - securable=securable, - principals=principals, - grant_option=grant_option, - ) - - def _parse_revoke(self) -> exp.Revoke | exp.Command: - start = self._prev - - grant_option = self._match_text_seq("GRANT", "OPTION", "FOR") - - privileges, kind, securable = self._parse_grant_revoke_common() - - if not securable or not self._match_text_seq("FROM"): - return self._parse_as_command(start) - - principals = self._parse_csv(self._parse_grant_principal) - - cascade = None - if self._match_texts(("CASCADE", "RESTRICT")): - cascade = self._prev.text.upper() - - if self._curr: - return self._parse_as_command(start) - - return self.expression( - exp.Revoke, - privileges=privileges, - kind=kind, - securable=securable, - principals=principals, - grant_option=grant_option, - cascade=cascade, - ) - - def _parse_overlay(self) -> exp.Overlay: - def _parse_overlay_arg(text: str) -> t.Optional[exp.Expression]: - return ( - self._match(TokenType.COMMA) or self._match_text_seq(text) - ) and self._parse_bitwise() - - return self.expression( - exp.Overlay, - this=self._parse_bitwise(), - expression=_parse_overlay_arg("PLACING"), - from_=_parse_overlay_arg("FROM"), - for_=_parse_overlay_arg("FOR"), - ) - - def _parse_format_name(self) -> exp.Property: - # Note: Although not specified in the docs, Snowflake does accept a string/identifier - # for FILE_FORMAT = - return self.expression( - exp.Property, - this=exp.var("FORMAT_NAME"), - value=self._parse_string() or self._parse_table_parts(), - ) - - def _parse_max_min_by(self, expr_type: t.Type[exp.AggFunc]) -> exp.AggFunc: - args: t.List[exp.Expression] = [] - - if self._match(TokenType.DISTINCT): - args.append( - self.expression(exp.Distinct, expressions=[self._parse_lambda()]) - ) - self._match(TokenType.COMMA) - - args.extend(self._parse_function_args()) - - return self.expression( - expr_type, - this=seq_get(args, 0), - expression=seq_get(args, 1), - count=seq_get(args, 2), - ) - - def _identifier_expression( - self, token: t.Optional[Token] = None, **kwargs: t.Any - ) -> exp.Identifier: - return self.expression(exp.Identifier, token=token or self._prev, **kwargs) - - def _build_pipe_cte( - self, - query: exp.Query, - expressions: t.List[exp.Expression], - alias_cte: t.Optional[exp.TableAlias] = None, - ) -> exp.Select: - new_cte: t.Optional[t.Union[str, exp.TableAlias]] - if alias_cte: - new_cte = alias_cte - else: - self._pipe_cte_counter += 1 - new_cte = f"__tmp{self._pipe_cte_counter}" - - with_ = query.args.get("with_") - ctes = with_.pop() if with_ else None - - new_select = exp.select(*expressions, copy=False).from_(new_cte, copy=False) - if ctes: - new_select.set("with_", ctes) - - return new_select.with_(new_cte, as_=query, copy=False) - - def _parse_pipe_syntax_select(self, query: exp.Select) -> exp.Select: - select = self._parse_select(consume_pipe=False) - if not select: - return query - - return self._build_pipe_cte( - query=query.select(*select.expressions, append=False), - expressions=[exp.Star()], - ) - - def _parse_pipe_syntax_limit(self, query: exp.Select) -> exp.Select: - limit = self._parse_limit() - offset = self._parse_offset() - if limit: - curr_limit = query.args.get("limit", limit) - if curr_limit.expression.to_py() >= limit.expression.to_py(): - query.limit(limit, copy=False) - if offset: - curr_offset = query.args.get("offset") - curr_offset = curr_offset.expression.to_py() if curr_offset else 0 - query.offset( - exp.Literal.number(curr_offset + offset.expression.to_py()), copy=False - ) - - return query - - def _parse_pipe_syntax_aggregate_fields(self) -> t.Optional[exp.Expression]: - this = self._parse_disjunction() - if self._match_text_seq("GROUP", "AND", advance=False): - return this - - this = self._parse_alias(this) - - if self._match_set((TokenType.ASC, TokenType.DESC), advance=False): - return self._parse_ordered(lambda: this) - - return this - - def _parse_pipe_syntax_aggregate_group_order_by( - self, query: exp.Select, group_by_exists: bool = True - ) -> exp.Select: - expr = self._parse_csv(self._parse_pipe_syntax_aggregate_fields) - aggregates_or_groups, orders = [], [] - for element in expr: - if isinstance(element, exp.Ordered): - this = element.this - if isinstance(this, exp.Alias): - element.set("this", this.args["alias"]) - orders.append(element) - else: - this = element - aggregates_or_groups.append(this) - - if group_by_exists: - query.select(*aggregates_or_groups, copy=False).group_by( - *[ - projection.args.get("alias", projection) - for projection in aggregates_or_groups - ], - copy=False, - ) - else: - query.select(*aggregates_or_groups, append=False, copy=False) - - if orders: - return query.order_by(*orders, append=False, copy=False) - - return query - - def _parse_pipe_syntax_aggregate(self, query: exp.Select) -> exp.Select: - self._match_text_seq("AGGREGATE") - query = self._parse_pipe_syntax_aggregate_group_order_by( - query, group_by_exists=False - ) - - if self._match(TokenType.GROUP_BY) or ( - self._match_text_seq("GROUP", "AND") and self._match(TokenType.ORDER_BY) - ): - query = self._parse_pipe_syntax_aggregate_group_order_by(query) - - return self._build_pipe_cte(query=query, expressions=[exp.Star()]) - - def _parse_pipe_syntax_set_operator( - self, query: exp.Query - ) -> t.Optional[exp.Query]: - first_setop = self.parse_set_operation(this=query) - if not first_setop: - return None - - def _parse_and_unwrap_query() -> t.Optional[exp.Select]: - expr = self._parse_paren() - return expr.assert_is(exp.Subquery).unnest() if expr else None - - first_setop.this.pop() - - setops = [ - first_setop.expression.pop().assert_is(exp.Subquery).unnest(), - *self._parse_csv(_parse_and_unwrap_query), - ] - - query = self._build_pipe_cte(query=query, expressions=[exp.Star()]) - with_ = query.args.get("with_") - ctes = with_.pop() if with_ else None - - if isinstance(first_setop, exp.Union): - query = query.union(*setops, copy=False, **first_setop.args) - elif isinstance(first_setop, exp.Except): - query = query.except_(*setops, copy=False, **first_setop.args) - else: - query = query.intersect(*setops, copy=False, **first_setop.args) - - query.set("with_", ctes) - - return self._build_pipe_cte(query=query, expressions=[exp.Star()]) - - def _parse_pipe_syntax_join(self, query: exp.Query) -> t.Optional[exp.Query]: - join = self._parse_join() - if not join: - return None - - if isinstance(query, exp.Select): - return query.join(join, copy=False) - - return query - - def _parse_pipe_syntax_pivot(self, query: exp.Select) -> exp.Select: - pivots = self._parse_pivots() - if not pivots: - return query - - from_ = query.args.get("from_") - if from_: - from_.this.set("pivots", pivots) - - return self._build_pipe_cte(query=query, expressions=[exp.Star()]) - - def _parse_pipe_syntax_extend(self, query: exp.Select) -> exp.Select: - self._match_text_seq("EXTEND") - query.select( - *[exp.Star(), *self._parse_expressions()], append=False, copy=False - ) - return self._build_pipe_cte(query=query, expressions=[exp.Star()]) - - def _parse_pipe_syntax_tablesample(self, query: exp.Select) -> exp.Select: - sample = self._parse_table_sample() - - with_ = query.args.get("with_") - if with_: - with_.expressions[-1].this.set("sample", sample) - else: - query.set("sample", sample) - - return query - - def _parse_pipe_syntax_query(self, query: exp.Query) -> t.Optional[exp.Query]: - if isinstance(query, exp.Subquery): - query = exp.select("*").from_(query, copy=False) - - if not query.args.get("from_"): - query = exp.select("*").from_(query.subquery(copy=False), copy=False) - - while self._match(TokenType.PIPE_GT): - start = self._curr - parser = self.PIPE_SYNTAX_TRANSFORM_PARSERS.get(self._curr.text.upper()) - if not parser: - # The set operators (UNION, etc) and the JOIN operator have a few common starting - # keywords, making it tricky to disambiguate them without lookahead. The approach - # here is to try and parse a set operation and if that fails, then try to parse a - # join operator. If that fails as well, then the operator is not supported. - parsed_query = self._parse_pipe_syntax_set_operator(query) - parsed_query = parsed_query or self._parse_pipe_syntax_join(query) - if not parsed_query: - self._retreat(start) - self.raise_error( - f"Unsupported pipe syntax operator: '{start.text.upper()}'." - ) - break - query = parsed_query - else: - query = parser(self, query) - - return query - - def _parse_declareitem(self) -> t.Optional[exp.DeclareItem]: - vars = self._parse_csv(self._parse_id_var) - if not vars: - return None - - return self.expression( - exp.DeclareItem, - this=vars, - kind=self._parse_types(), - default=self._match(TokenType.DEFAULT) and self._parse_bitwise(), - ) - - def _parse_declare(self) -> exp.Declare | exp.Command: - start = self._prev - expressions = self._try_parse(lambda: self._parse_csv(self._parse_declareitem)) - - if not expressions or self._curr: - return self._parse_as_command(start) - - return self.expression(exp.Declare, expressions=expressions) - - def build_cast(self, strict: bool, **kwargs) -> exp.Cast: - exp_class = exp.Cast if strict else exp.TryCast - - if exp_class == exp.TryCast: - kwargs["requires_string"] = self.dialect.TRY_CAST_REQUIRES_STRING - - return self.expression(exp_class, **kwargs) - - def _parse_json_value(self) -> exp.JSONValue: - this = self._parse_bitwise() - self._match(TokenType.COMMA) - path = self._parse_bitwise() - - returning = self._match(TokenType.RETURNING) and self._parse_type() - - return self.expression( - exp.JSONValue, - this=this, - path=self.dialect.to_json_path(path), - returning=returning, - on_condition=self._parse_on_condition(), - ) - - def _parse_group_concat(self) -> t.Optional[exp.Expression]: - def concat_exprs( - node: t.Optional[exp.Expression], exprs: t.List[exp.Expression] - ) -> exp.Expression: - if isinstance(node, exp.Distinct) and len(node.expressions) > 1: - concat_exprs = [ - self.expression( - exp.Concat, - expressions=node.expressions, - safe=True, - coalesce=self.dialect.CONCAT_COALESCE, - ) - ] - node.set("expressions", concat_exprs) - return node - if len(exprs) == 1: - return exprs[0] - return self.expression( - exp.Concat, - expressions=args, - safe=True, - coalesce=self.dialect.CONCAT_COALESCE, - ) - - args = self._parse_csv(self._parse_lambda) - - if args: - order = args[-1] if isinstance(args[-1], exp.Order) else None - - if order: - # Order By is the last (or only) expression in the list and has consumed the 'expr' before it, - # remove 'expr' from exp.Order and add it back to args - args[-1] = order.this - order.set("this", concat_exprs(order.this, args)) - - this = order or concat_exprs(args[0], args) - else: - this = None - - separator = self._parse_field() if self._match(TokenType.SEPARATOR) else None - - return self.expression(exp.GroupConcat, this=this, separator=separator) - - def _parse_initcap(self) -> exp.Initcap: - expr = exp.Initcap.from_arg_list(self._parse_function_args()) - - # attach dialect's default delimiters - if expr.args.get("expression") is None: - expr.set( - "expression", - exp.Literal.string(self.dialect.INITCAP_DEFAULT_DELIMITER_CHARS), - ) - - return expr - - def _parse_operator( - self, this: t.Optional[exp.Expression] - ) -> t.Optional[exp.Expression]: - while True: - if not self._match(TokenType.L_PAREN): - break - - op = "" - while self._curr and not self._match(TokenType.R_PAREN): - op += self._curr.text - self._advance() - - this = self.expression( - exp.Operator, - comments=self._prev_comments, - this=this, - operator=op, - expression=self._parse_bitwise(), - ) - - if not self._match(TokenType.OPERATOR): - break - - return this diff --git a/third_party/bigframes_vendored/sqlglot/planner.py b/third_party/bigframes_vendored/sqlglot/planner.py deleted file mode 100644 index d564253e57b..00000000000 --- a/third_party/bigframes_vendored/sqlglot/planner.py +++ /dev/null @@ -1,473 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/planner.py - -from __future__ import annotations - -import math -import typing as t - -from bigframes_vendored.sqlglot import alias, exp -from bigframes_vendored.sqlglot.helper import name_sequence -from bigframes_vendored.sqlglot.optimizer.eliminate_joins import join_condition - - -class Plan: - def __init__(self, expression: exp.Expression) -> None: - self.expression = expression.copy() - self.root = Step.from_expression(self.expression) - self._dag: t.Dict[Step, t.Set[Step]] = {} - - @property - def dag(self) -> t.Dict[Step, t.Set[Step]]: - if not self._dag: - dag: t.Dict[Step, t.Set[Step]] = {} - nodes = {self.root} - - while nodes: - node = nodes.pop() - dag[node] = set() - - for dep in node.dependencies: - dag[node].add(dep) - nodes.add(dep) - - self._dag = dag - - return self._dag - - @property - def leaves(self) -> t.Iterator[Step]: - return (node for node, deps in self.dag.items() if not deps) - - def __repr__(self) -> str: - return f"Plan\n----\n{repr(self.root)}" - - -class Step: - @classmethod - def from_expression( - cls, expression: exp.Expression, ctes: t.Optional[t.Dict[str, Step]] = None - ) -> Step: - """ - Builds a DAG of Steps from a SQL expression so that it's easier to execute in an engine. - Note: the expression's tables and subqueries must be aliased for this method to work. For - example, given the following expression: - - SELECT - x.a, - SUM(x.b) - FROM x AS x - JOIN y AS y - ON x.a = y.a - GROUP BY x.a - - the following DAG is produced (the expression IDs might differ per execution): - - - Aggregate: x (4347984624) - Context: - Aggregations: - - SUM(x.b) - Group: - - x.a - Projections: - - x.a - - "x"."" - Dependencies: - - Join: x (4347985296) - Context: - y: - On: x.a = y.a - Projections: - Dependencies: - - Scan: x (4347983136) - Context: - Source: x AS x - Projections: - - Scan: y (4343416624) - Context: - Source: y AS y - Projections: - - Args: - expression: the expression to build the DAG from. - ctes: a dictionary that maps CTEs to their corresponding Step DAG by name. - - Returns: - A Step DAG corresponding to `expression`. - """ - ctes = ctes or {} - expression = expression.unnest() - with_ = expression.args.get("with_") - - # CTEs break the mold of scope and introduce themselves to all in the context. - if with_: - ctes = ctes.copy() - for cte in with_.expressions: - step = Step.from_expression(cte.this, ctes) - step.name = cte.alias - ctes[step.name] = step # type: ignore - - from_ = expression.args.get("from_") - - if isinstance(expression, exp.Select) and from_: - step = Scan.from_expression(from_.this, ctes) - elif isinstance(expression, exp.SetOperation): - step = SetOperation.from_expression(expression, ctes) - else: - step = Scan() - - joins = expression.args.get("joins") - - if joins: - join = Join.from_joins(joins, ctes) - join.name = step.name - join.source_name = step.name - join.add_dependency(step) - step = join - - projections = [] # final selects in this chain of steps representing a select - operands = {} # intermediate computations of agg funcs eg x + 1 in SUM(x + 1) - aggregations = {} - next_operand_name = name_sequence("_a_") - - def extract_agg_operands(expression): - agg_funcs = tuple(expression.find_all(exp.AggFunc)) - if agg_funcs: - aggregations[expression] = None - - for agg in agg_funcs: - for operand in agg.unnest_operands(): - if isinstance(operand, exp.Column): - continue - if operand not in operands: - operands[operand] = next_operand_name() - - operand.replace(exp.column(operands[operand], quoted=True)) - - return bool(agg_funcs) - - def set_ops_and_aggs(step): - step.operands = tuple( - alias(operand, alias_) for operand, alias_ in operands.items() - ) - step.aggregations = list(aggregations) - - for e in expression.expressions: - if e.find(exp.AggFunc): - projections.append(exp.column(e.alias_or_name, step.name, quoted=True)) - extract_agg_operands(e) - else: - projections.append(e) - - where = expression.args.get("where") - - if where: - step.condition = where.this - - group = expression.args.get("group") - - if group or aggregations: - aggregate = Aggregate() - aggregate.source = step.name - aggregate.name = step.name - - having = expression.args.get("having") - - if having: - if extract_agg_operands(exp.alias_(having.this, "_h", quoted=True)): - aggregate.condition = exp.column("_h", step.name, quoted=True) - else: - aggregate.condition = having.this - - set_ops_and_aggs(aggregate) - - # give aggregates names and replace projections with references to them - aggregate.group = { - f"_g{i}": e for i, e in enumerate(group.expressions if group else []) - } - - intermediate: t.Dict[str | exp.Expression, str] = {} - for k, v in aggregate.group.items(): - intermediate[v] = k - if isinstance(v, exp.Column): - intermediate[v.name] = k - - for projection in projections: - for node in projection.walk(): - name = intermediate.get(node) - if name: - node.replace(exp.column(name, step.name)) - - if aggregate.condition: - for node in aggregate.condition.walk(): - name = intermediate.get(node) or intermediate.get(node.name) - if name: - node.replace(exp.column(name, step.name)) - - aggregate.add_dependency(step) - step = aggregate - else: - aggregate = None - - order = expression.args.get("order") - - if order: - if aggregate and isinstance(step, Aggregate): - for i, ordered in enumerate(order.expressions): - if extract_agg_operands( - exp.alias_(ordered.this, f"_o_{i}", quoted=True) - ): - ordered.this.replace( - exp.column(f"_o_{i}", step.name, quoted=True) - ) - - set_ops_and_aggs(aggregate) - - sort = Sort() - sort.name = step.name - sort.key = order.expressions - sort.add_dependency(step) - step = sort - - step.projections = projections - - if isinstance(expression, exp.Select) and expression.args.get("distinct"): - distinct = Aggregate() - distinct.source = step.name - distinct.name = step.name - distinct.group = { - e.alias_or_name: exp.column(col=e.alias_or_name, table=step.name) - for e in projections or expression.expressions - } - distinct.add_dependency(step) - step = distinct - - limit = expression.args.get("limit") - - if limit: - step.limit = int(limit.text("expression")) - - return step - - def __init__(self) -> None: - self.name: t.Optional[str] = None - self.dependencies: t.Set[Step] = set() - self.dependents: t.Set[Step] = set() - self.projections: t.Sequence[exp.Expression] = [] - self.limit: float = math.inf - self.condition: t.Optional[exp.Expression] = None - - def add_dependency(self, dependency: Step) -> None: - self.dependencies.add(dependency) - dependency.dependents.add(self) - - def __repr__(self) -> str: - return self.to_s() - - def to_s(self, level: int = 0) -> str: - indent = " " * level - nested = f"{indent} " - - context = self._to_s(f"{nested} ") - - if context: - context = [f"{nested}Context:"] + context - - lines = [ - f"{indent}- {self.id}", - *context, - f"{nested}Projections:", - ] - - for expression in self.projections: - lines.append(f"{nested} - {expression.sql()}") - - if self.condition: - lines.append(f"{nested}Condition: {self.condition.sql()}") - - if self.limit is not math.inf: - lines.append(f"{nested}Limit: {self.limit}") - - if self.dependencies: - lines.append(f"{nested}Dependencies:") - for dependency in self.dependencies: - lines.append(" " + dependency.to_s(level + 1)) - - return "\n".join(lines) - - @property - def type_name(self) -> str: - return self.__class__.__name__ - - @property - def id(self) -> str: - name = self.name - name = f" {name}" if name else "" - return f"{self.type_name}:{name} ({id(self)})" - - def _to_s(self, _indent: str) -> t.List[str]: - return [] - - -class Scan(Step): - @classmethod - def from_expression( - cls, expression: exp.Expression, ctes: t.Optional[t.Dict[str, Step]] = None - ) -> Step: - table = expression - alias_ = expression.alias_or_name - - if isinstance(expression, exp.Subquery): - table = expression.this - step = Step.from_expression(table, ctes) - step.name = alias_ - return step - - step = Scan() - step.name = alias_ - step.source = expression - if ctes and table.name in ctes: - step.add_dependency(ctes[table.name]) - - return step - - def __init__(self) -> None: - super().__init__() - self.source: t.Optional[exp.Expression] = None - - def _to_s(self, indent: str) -> t.List[str]: - return [f"{indent}Source: {self.source.sql() if self.source else '-static-'}"] # type: ignore - - -class Join(Step): - @classmethod - def from_joins( - cls, joins: t.Iterable[exp.Join], ctes: t.Optional[t.Dict[str, Step]] = None - ) -> Join: - step = Join() - - for join in joins: - source_key, join_key, condition = join_condition(join) - step.joins[join.alias_or_name] = { - "side": join.side, # type: ignore - "join_key": join_key, - "source_key": source_key, - "condition": condition, - } - - step.add_dependency(Scan.from_expression(join.this, ctes)) - - return step - - def __init__(self) -> None: - super().__init__() - self.source_name: t.Optional[str] = None - self.joins: t.Dict[str, t.Dict[str, t.List[str] | exp.Expression]] = {} - - def _to_s(self, indent: str) -> t.List[str]: - lines = [f"{indent}Source: {self.source_name or self.name}"] - for name, join in self.joins.items(): - lines.append(f"{indent}{name}: {join['side'] or 'INNER'}") - join_key = ", ".join( - str(key) for key in t.cast(list, join.get("join_key") or []) - ) - if join_key: - lines.append(f"{indent}Key: {join_key}") - if join.get("condition"): - lines.append(f"{indent}On: {join['condition'].sql()}") # type: ignore - return lines - - -class Aggregate(Step): - def __init__(self) -> None: - super().__init__() - self.aggregations: t.List[exp.Expression] = [] - self.operands: t.Tuple[exp.Expression, ...] = () - self.group: t.Dict[str, exp.Expression] = {} - self.source: t.Optional[str] = None - - def _to_s(self, indent: str) -> t.List[str]: - lines = [f"{indent}Aggregations:"] - - for expression in self.aggregations: - lines.append(f"{indent} - {expression.sql()}") - - if self.group: - lines.append(f"{indent}Group:") - for expression in self.group.values(): - lines.append(f"{indent} - {expression.sql()}") - if self.condition: - lines.append(f"{indent}Having:") - lines.append(f"{indent} - {self.condition.sql()}") - if self.operands: - lines.append(f"{indent}Operands:") - for expression in self.operands: - lines.append(f"{indent} - {expression.sql()}") - - return lines - - -class Sort(Step): - def __init__(self) -> None: - super().__init__() - self.key = None - - def _to_s(self, indent: str) -> t.List[str]: - lines = [f"{indent}Key:"] - - for expression in self.key: # type: ignore - lines.append(f"{indent} - {expression.sql()}") - - return lines - - -class SetOperation(Step): - def __init__( - self, - op: t.Type[exp.Expression], - left: str | None, - right: str | None, - distinct: bool = False, - ) -> None: - super().__init__() - self.op = op - self.left = left - self.right = right - self.distinct = distinct - - @classmethod - def from_expression( - cls, expression: exp.Expression, ctes: t.Optional[t.Dict[str, Step]] = None - ) -> SetOperation: - assert isinstance(expression, exp.SetOperation) - - left = Step.from_expression(expression.left, ctes) - # SELECT 1 UNION SELECT 2 <-- these subqueries don't have names - left.name = left.name or "left" - right = Step.from_expression(expression.right, ctes) - right.name = right.name or "right" - step = cls( - op=expression.__class__, - left=left.name, - right=right.name, - distinct=bool(expression.args.get("distinct")), - ) - - step.add_dependency(left) - step.add_dependency(right) - - limit = expression.args.get("limit") - - if limit: - step.limit = int(limit.text("expression")) - - return step - - def _to_s(self, indent: str) -> t.List[str]: - lines = [] - if self.distinct: - lines.append(f"{indent}Distinct: {self.distinct}") - return lines - - @property - def type_name(self) -> str: - return self.op.__name__ diff --git a/third_party/bigframes_vendored/sqlglot/schema.py b/third_party/bigframes_vendored/sqlglot/schema.py deleted file mode 100644 index 87928f2fc6f..00000000000 --- a/third_party/bigframes_vendored/sqlglot/schema.py +++ /dev/null @@ -1,641 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/schema.py - -from __future__ import annotations - -import abc -import typing as t - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.dialects.dialect import Dialect -from bigframes_vendored.sqlglot.errors import SchemaError -from bigframes_vendored.sqlglot.helper import dict_depth, first -from bigframes_vendored.sqlglot.trie import TrieResult, in_trie, new_trie - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - ColumnMapping = t.Union[t.Dict, str, t.List] - - -class Schema(abc.ABC): - """Abstract base class for database schemas""" - - @property - def dialect(self) -> t.Optional[Dialect]: - """ - Returns None by default. Subclasses that require dialect-specific - behavior should override this property. - """ - return None - - @abc.abstractmethod - def add_table( - self, - table: exp.Table | str, - column_mapping: t.Optional[ColumnMapping] = None, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - match_depth: bool = True, - ) -> None: - """ - Register or update a table. Some implementing classes may require column information to also be provided. - The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. - - Args: - table: the `Table` expression instance or string representing the table. - column_mapping: a column mapping that describes the structure of the table. - dialect: the SQL dialect that will be used to parse `table` if it's a string. - normalize: whether to normalize identifiers according to the dialect of interest. - match_depth: whether to enforce that the table must match the schema's depth or not. - """ - - @abc.abstractmethod - def column_names( - self, - table: exp.Table | str, - only_visible: bool = False, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> t.Sequence[str]: - """ - Get the column names for a table. - - Args: - table: the `Table` expression instance. - only_visible: whether to include invisible columns. - dialect: the SQL dialect that will be used to parse `table` if it's a string. - normalize: whether to normalize identifiers according to the dialect of interest. - - Returns: - The sequence of column names. - """ - - @abc.abstractmethod - def get_column_type( - self, - table: exp.Table | str, - column: exp.Column | str, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> exp.DataType: - """ - Get the `sqlglot.exp.DataType` type of a column in the schema. - - Args: - table: the source table. - column: the target column. - dialect: the SQL dialect that will be used to parse `table` if it's a string. - normalize: whether to normalize identifiers according to the dialect of interest. - - Returns: - The resulting column type. - """ - - def has_column( - self, - table: exp.Table | str, - column: exp.Column | str, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> bool: - """ - Returns whether `column` appears in `table`'s schema. - - Args: - table: the source table. - column: the target column. - dialect: the SQL dialect that will be used to parse `table` if it's a string. - normalize: whether to normalize identifiers according to the dialect of interest. - - Returns: - True if the column appears in the schema, False otherwise. - """ - name = column if isinstance(column, str) else column.name - return name in self.column_names(table, dialect=dialect, normalize=normalize) - - @property - @abc.abstractmethod - def supported_table_args(self) -> t.Tuple[str, ...]: - """ - Table arguments this schema support, e.g. `("this", "db", "catalog")` - """ - - @property - def empty(self) -> bool: - """Returns whether the schema is empty.""" - return True - - -class AbstractMappingSchema: - def __init__( - self, - mapping: t.Optional[t.Dict] = None, - ) -> None: - self.mapping = mapping or {} - self.mapping_trie = new_trie( - tuple(reversed(t)) for t in flatten_schema(self.mapping, depth=self.depth()) - ) - self._supported_table_args: t.Tuple[str, ...] = tuple() - - @property - def empty(self) -> bool: - return not self.mapping - - def depth(self) -> int: - return dict_depth(self.mapping) - - @property - def supported_table_args(self) -> t.Tuple[str, ...]: - if not self._supported_table_args and self.mapping: - depth = self.depth() - - if not depth: # None - self._supported_table_args = tuple() - elif 1 <= depth <= 3: - self._supported_table_args = exp.TABLE_PARTS[:depth] - else: - raise SchemaError(f"Invalid mapping shape. Depth: {depth}") - - return self._supported_table_args - - def table_parts(self, table: exp.Table) -> t.List[str]: - return [part.name for part in reversed(table.parts)] - - def find( - self, - table: exp.Table, - raise_on_missing: bool = True, - ensure_data_types: bool = False, - ) -> t.Optional[t.Any]: - """ - Returns the schema of a given table. - - Args: - table: the target table. - raise_on_missing: whether to raise in case the schema is not found. - ensure_data_types: whether to convert `str` types to their `DataType` equivalents. - - Returns: - The schema of the target table. - """ - parts = self.table_parts(table)[0 : len(self.supported_table_args)] - value, trie = in_trie(self.mapping_trie, parts) - - if value == TrieResult.FAILED: - return None - - if value == TrieResult.PREFIX: - possibilities = flatten_schema(trie) - - if len(possibilities) == 1: - parts.extend(possibilities[0]) - else: - message = ", ".join(".".join(parts) for parts in possibilities) - if raise_on_missing: - raise SchemaError(f"Ambiguous mapping for {table}: {message}.") - return None - - return self.nested_get(parts, raise_on_missing=raise_on_missing) - - def nested_get( - self, - parts: t.Sequence[str], - d: t.Optional[t.Dict] = None, - raise_on_missing=True, - ) -> t.Optional[t.Any]: - return nested_get( - d or self.mapping, - *zip(self.supported_table_args, reversed(parts)), - raise_on_missing=raise_on_missing, - ) - - -class MappingSchema(AbstractMappingSchema, Schema): - """ - Schema based on a nested mapping. - - Args: - schema: Mapping in one of the following forms: - 1. {table: {col: type}} - 2. {db: {table: {col: type}}} - 3. {catalog: {db: {table: {col: type}}}} - 4. None - Tables will be added later - visible: Optional mapping of which columns in the schema are visible. If not provided, all columns - are assumed to be visible. The nesting should mirror that of the schema: - 1. {table: set(*cols)}} - 2. {db: {table: set(*cols)}}} - 3. {catalog: {db: {table: set(*cols)}}}} - dialect: The dialect to be used for custom type mappings & parsing string arguments. - normalize: Whether to normalize identifier names according to the given dialect or not. - """ - - def __init__( - self, - schema: t.Optional[t.Dict] = None, - visible: t.Optional[t.Dict] = None, - dialect: DialectType = None, - normalize: bool = True, - ) -> None: - self.visible = {} if visible is None else visible - self.normalize = normalize - self._dialect = Dialect.get_or_raise(dialect) - self._type_mapping_cache: t.Dict[str, exp.DataType] = {} - self._depth = 0 - schema = {} if schema is None else schema - - super().__init__(self._normalize(schema) if self.normalize else schema) - - @property - def dialect(self) -> Dialect: - """Returns the dialect for this mapping schema.""" - return self._dialect - - @classmethod - def from_mapping_schema(cls, mapping_schema: MappingSchema) -> MappingSchema: - return MappingSchema( - schema=mapping_schema.mapping, - visible=mapping_schema.visible, - dialect=mapping_schema.dialect, - normalize=mapping_schema.normalize, - ) - - def find( - self, - table: exp.Table, - raise_on_missing: bool = True, - ensure_data_types: bool = False, - ) -> t.Optional[t.Any]: - schema = super().find( - table, - raise_on_missing=raise_on_missing, - ensure_data_types=ensure_data_types, - ) - if ensure_data_types and isinstance(schema, dict): - schema = { - col: self._to_data_type(dtype) if isinstance(dtype, str) else dtype - for col, dtype in schema.items() - } - - return schema - - def copy(self, **kwargs) -> MappingSchema: - return MappingSchema( - **{ # type: ignore - "schema": self.mapping.copy(), - "visible": self.visible.copy(), - "dialect": self.dialect, - "normalize": self.normalize, - **kwargs, - } - ) - - def add_table( - self, - table: exp.Table | str, - column_mapping: t.Optional[ColumnMapping] = None, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - match_depth: bool = True, - ) -> None: - """ - Register or update a table. Updates are only performed if a new column mapping is provided. - The added table must have the necessary number of qualifiers in its path to match the schema's nesting level. - - Args: - table: the `Table` expression instance or string representing the table. - column_mapping: a column mapping that describes the structure of the table. - dialect: the SQL dialect that will be used to parse `table` if it's a string. - normalize: whether to normalize identifiers according to the dialect of interest. - match_depth: whether to enforce that the table must match the schema's depth or not. - """ - normalized_table = self._normalize_table( - table, dialect=dialect, normalize=normalize - ) - - if ( - match_depth - and not self.empty - and len(normalized_table.parts) != self.depth() - ): - raise SchemaError( - f"Table {normalized_table.sql(dialect=self.dialect)} must match the " - f"schema's nesting level: {self.depth()}." - ) - - normalized_column_mapping = { - self._normalize_name(key, dialect=dialect, normalize=normalize): value - for key, value in ensure_column_mapping(column_mapping).items() - } - - schema = self.find(normalized_table, raise_on_missing=False) - if schema and not normalized_column_mapping: - return - - parts = self.table_parts(normalized_table) - - nested_set(self.mapping, tuple(reversed(parts)), normalized_column_mapping) - new_trie([parts], self.mapping_trie) - - def column_names( - self, - table: exp.Table | str, - only_visible: bool = False, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> t.List[str]: - normalized_table = self._normalize_table( - table, dialect=dialect, normalize=normalize - ) - - schema = self.find(normalized_table) - if schema is None: - return [] - - if not only_visible or not self.visible: - return list(schema) - - visible = ( - self.nested_get(self.table_parts(normalized_table), self.visible) or [] - ) - return [col for col in schema if col in visible] - - def get_column_type( - self, - table: exp.Table | str, - column: exp.Column | str, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> exp.DataType: - normalized_table = self._normalize_table( - table, dialect=dialect, normalize=normalize - ) - - normalized_column_name = self._normalize_name( - column if isinstance(column, str) else column.this, - dialect=dialect, - normalize=normalize, - ) - - table_schema = self.find(normalized_table, raise_on_missing=False) - if table_schema: - column_type = table_schema.get(normalized_column_name) - - if isinstance(column_type, exp.DataType): - return column_type - elif isinstance(column_type, str): - return self._to_data_type(column_type, dialect=dialect) - - return exp.DataType.build("unknown") - - def has_column( - self, - table: exp.Table | str, - column: exp.Column | str, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> bool: - normalized_table = self._normalize_table( - table, dialect=dialect, normalize=normalize - ) - - normalized_column_name = self._normalize_name( - column if isinstance(column, str) else column.this, - dialect=dialect, - normalize=normalize, - ) - - table_schema = self.find(normalized_table, raise_on_missing=False) - return normalized_column_name in table_schema if table_schema else False - - def _normalize(self, schema: t.Dict) -> t.Dict: - """ - Normalizes all identifiers in the schema. - - Args: - schema: the schema to normalize. - - Returns: - The normalized schema mapping. - """ - normalized_mapping: t.Dict = {} - flattened_schema = flatten_schema(schema) - error_msg = "Table {} must match the schema's nesting level: {}." - - for keys in flattened_schema: - columns = nested_get(schema, *zip(keys, keys)) - - if not isinstance(columns, dict): - raise SchemaError( - error_msg.format(".".join(keys[:-1]), len(flattened_schema[0])) - ) - if not columns: - raise SchemaError( - f"Table {'.'.join(keys[:-1])} must have at least one column" - ) - if isinstance(first(columns.values()), dict): - raise SchemaError( - error_msg.format( - ".".join(keys + flatten_schema(columns)[0]), - len(flattened_schema[0]), - ), - ) - - normalized_keys = [self._normalize_name(key, is_table=True) for key in keys] - for column_name, column_type in columns.items(): - nested_set( - normalized_mapping, - normalized_keys + [self._normalize_name(column_name)], - column_type, - ) - - return normalized_mapping - - def _normalize_table( - self, - table: exp.Table | str, - dialect: DialectType = None, - normalize: t.Optional[bool] = None, - ) -> exp.Table: - dialect = dialect or self.dialect - normalize = self.normalize if normalize is None else normalize - - normalized_table = exp.maybe_parse( - table, into=exp.Table, dialect=dialect, copy=normalize - ) - - if normalize: - for part in normalized_table.parts: - if isinstance(part, exp.Identifier): - part.replace( - normalize_name( - part, dialect=dialect, is_table=True, normalize=normalize - ) - ) - - return normalized_table - - def _normalize_name( - self, - name: str | exp.Identifier, - dialect: DialectType = None, - is_table: bool = False, - normalize: t.Optional[bool] = None, - ) -> str: - return normalize_name( - name, - dialect=dialect or self.dialect, - is_table=is_table, - normalize=self.normalize if normalize is None else normalize, - ).name - - def depth(self) -> int: - if not self.empty and not self._depth: - # The columns themselves are a mapping, but we don't want to include those - self._depth = super().depth() - 1 - return self._depth - - def _to_data_type( - self, schema_type: str, dialect: DialectType = None - ) -> exp.DataType: - """ - Convert a type represented as a string to the corresponding `sqlglot.exp.DataType` object. - - Args: - schema_type: the type we want to convert. - dialect: the SQL dialect that will be used to parse `schema_type`, if needed. - - Returns: - The resulting expression type. - """ - if schema_type not in self._type_mapping_cache: - dialect = Dialect.get_or_raise(dialect) if dialect else self.dialect - udt = dialect.SUPPORTS_USER_DEFINED_TYPES - - try: - expression = exp.DataType.build(schema_type, dialect=dialect, udt=udt) - self._type_mapping_cache[schema_type] = expression - except AttributeError: - in_dialect = f" in dialect {dialect}" if dialect else "" - raise SchemaError(f"Failed to build type '{schema_type}'{in_dialect}.") - - return self._type_mapping_cache[schema_type] - - -def normalize_name( - identifier: str | exp.Identifier, - dialect: DialectType = None, - is_table: bool = False, - normalize: t.Optional[bool] = True, -) -> exp.Identifier: - if isinstance(identifier, str): - identifier = exp.parse_identifier(identifier, dialect=dialect) - - if not normalize: - return identifier - - # this is used for normalize_identifier, bigquery has special rules pertaining tables - identifier.meta["is_table"] = is_table - return Dialect.get_or_raise(dialect).normalize_identifier(identifier) - - -def ensure_schema(schema: Schema | t.Optional[t.Dict], **kwargs: t.Any) -> Schema: - if isinstance(schema, Schema): - return schema - - return MappingSchema(schema, **kwargs) - - -def ensure_column_mapping(mapping: t.Optional[ColumnMapping]) -> t.Dict: - if mapping is None: - return {} - elif isinstance(mapping, dict): - return mapping - elif isinstance(mapping, str): - col_name_type_strs = [x.strip() for x in mapping.split(",")] - return { - name_type_str.split(":")[0].strip(): name_type_str.split(":")[1].strip() - for name_type_str in col_name_type_strs - } - elif isinstance(mapping, list): - return {x.strip(): None for x in mapping} - - raise ValueError(f"Invalid mapping provided: {type(mapping)}") - - -def flatten_schema( - schema: t.Dict, depth: t.Optional[int] = None, keys: t.Optional[t.List[str]] = None -) -> t.List[t.List[str]]: - tables = [] - keys = keys or [] - depth = dict_depth(schema) - 1 if depth is None else depth - - for k, v in schema.items(): - if depth == 1 or not isinstance(v, dict): - tables.append(keys + [k]) - elif depth >= 2: - tables.extend(flatten_schema(v, depth - 1, keys + [k])) - - return tables - - -def nested_get( - d: t.Dict, *path: t.Tuple[str, str], raise_on_missing: bool = True -) -> t.Optional[t.Any]: - """ - Get a value for a nested dictionary. - - Args: - d: the dictionary to search. - *path: tuples of (name, key), where: - `key` is the key in the dictionary to get. - `name` is a string to use in the error if `key` isn't found. - - Returns: - The value or None if it doesn't exist. - """ - for name, key in path: - d = d.get(key) # type: ignore - if d is None: - if raise_on_missing: - name = "table" if name == "this" else name - raise ValueError(f"Unknown {name}: {key}") - return None - - return d - - -def nested_set(d: t.Dict, keys: t.Sequence[str], value: t.Any) -> t.Dict: - """ - In-place set a value for a nested dictionary - - Example: - >>> nested_set({}, ["top_key", "second_key"], "value") - {'top_key': {'second_key': 'value'}} - - >>> nested_set({"top_key": {"third_key": "third_value"}}, ["top_key", "second_key"], "value") - {'top_key': {'third_key': 'third_value', 'second_key': 'value'}} - - Args: - d: dictionary to update. - keys: the keys that makeup the path to `value`. - value: the value to set in the dictionary for the given key path. - - Returns: - The (possibly) updated dictionary. - """ - if not keys: - return d - - if len(keys) == 1: - d[keys[0]] = value - return d - - subd = d - for key in keys[:-1]: - if key not in subd: - subd = subd.setdefault(key, {}) - else: - subd = subd[key] - - subd[keys[-1]] = value - return d diff --git a/third_party/bigframes_vendored/sqlglot/serde.py b/third_party/bigframes_vendored/sqlglot/serde.py deleted file mode 100644 index dc21407e5f3..00000000000 --- a/third_party/bigframes_vendored/sqlglot/serde.py +++ /dev/null @@ -1,127 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/serde.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import expressions as exp - -INDEX = "i" -ARG_KEY = "k" -IS_ARRAY = "a" -CLASS = "c" -TYPE = "t" -COMMENTS = "o" -META = "m" -VALUE = "v" -DATA_TYPE = "DataType.Type" - - -def dump(expression: exp.Expression) -> t.List[t.Dict[str, t.Any]]: - """ - Dump an Expression into a JSON serializable List. - """ - i = 0 - payloads = [] - stack: t.List[t.Tuple[t.Any, t.Optional[int], t.Optional[str], bool]] = [ - (expression, None, None, False) - ] - - while stack: - node, index, arg_key, is_array = stack.pop() - - payload: t.Dict[str, t.Any] = {} - - if index is not None: - payload[INDEX] = index - if arg_key is not None: - payload[ARG_KEY] = arg_key - if is_array: - payload[IS_ARRAY] = is_array - - payloads.append(payload) - - if hasattr(node, "parent"): - klass = node.__class__.__qualname__ - - if node.__class__.__module__ != exp.__name__: - klass = f"{node.__module__}.{klass}" - - payload[CLASS] = klass - - if node.type: - payload[TYPE] = dump(node.type) - if node.comments: - payload[COMMENTS] = node.comments - if node._meta is not None: - payload[META] = node._meta - if node.args: - for k, vs in reversed(node.args.items()): - if type(vs) is list: - for v in reversed(vs): - stack.append((v, i, k, True)) - elif vs is not None: - stack.append((vs, i, k, False)) - elif type(node) is exp.DataType.Type: - payload[CLASS] = DATA_TYPE - payload[VALUE] = node.value - else: - payload[VALUE] = node - - i += 1 - - return payloads - - -@t.overload -def load(payloads: None) -> None: ... - - -@t.overload -def load(payloads: t.List[t.Dict[str, t.Any]]) -> exp.Expression: ... - - -def load(payloads): - """ - Load a list of dicts generated by dump into an Expression. - """ - - if not payloads: - return None - - payload, *tail = payloads - root = _load(payload) - nodes = [root] - for payload in tail: - node = _load(payload) - nodes.append(node) - parent = nodes[payload[INDEX]] - arg_key = payload[ARG_KEY] - - if payload.get(IS_ARRAY): - parent.append(arg_key, node) - else: - parent.set(arg_key, node) - - return root - - -def _load(payload: t.Dict[str, t.Any]) -> exp.Expression | exp.DataType.Type: - class_name = payload.get(CLASS) - - if not class_name: - return payload[VALUE] - if class_name == DATA_TYPE: - return exp.DataType.Type(payload[VALUE]) - - if "." in class_name: - module_path, class_name = class_name.rsplit(".", maxsplit=1) - module = __import__(module_path, fromlist=[class_name]) - else: - module = exp - - expression = getattr(module, class_name)() - expression.type = load(payload.get(TYPE)) - expression.comments = payload.get(COMMENTS) - expression._meta = payload.get(META) - return expression diff --git a/third_party/bigframes_vendored/sqlglot/time.py b/third_party/bigframes_vendored/sqlglot/time.py deleted file mode 100644 index 05873b187a9..00000000000 --- a/third_party/bigframes_vendored/sqlglot/time.py +++ /dev/null @@ -1,689 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/time.py - -import datetime -import typing as t - -# The generic time format is based on python time.strftime. -# https://docs.python.org/3/library/time.html#time.strftime -from bigframes_vendored.sqlglot.trie import TrieResult, in_trie, new_trie - - -def format_time( - string: str, mapping: t.Dict[str, str], trie: t.Optional[t.Dict] = None -) -> t.Optional[str]: - """ - Converts a time string given a mapping. - - Examples: - >>> format_time("%Y", {"%Y": "YYYY"}) - 'YYYY' - - Args: - mapping: dictionary of time format to target time format. - trie: optional trie, can be passed in for performance. - - Returns: - The converted time string. - """ - if not string: - return None - - start = 0 - end = 1 - size = len(string) - trie = trie or new_trie(mapping) - current = trie - chunks = [] - sym = None - - while end <= size: - chars = string[start:end] - result, current = in_trie(current, chars[-1]) - - if result == TrieResult.FAILED: - if sym: - end -= 1 - chars = sym - sym = None - else: - chars = chars[0] - end = start + 1 - - start += len(chars) - chunks.append(chars) - current = trie - elif result == TrieResult.EXISTS: - sym = chars - - end += 1 - - if result != TrieResult.FAILED and end > size: - chunks.append(chars) - - return "".join(mapping.get(chars, chars) for chars in chunks) - - -TIMEZONES = { - tz.lower() - for tz in ( - "Africa/Abidjan", - "Africa/Accra", - "Africa/Addis_Ababa", - "Africa/Algiers", - "Africa/Asmara", - "Africa/Asmera", - "Africa/Bamako", - "Africa/Bangui", - "Africa/Banjul", - "Africa/Bissau", - "Africa/Blantyre", - "Africa/Brazzaville", - "Africa/Bujumbura", - "Africa/Cairo", - "Africa/Casablanca", - "Africa/Ceuta", - "Africa/Conakry", - "Africa/Dakar", - "Africa/Dar_es_Salaam", - "Africa/Djibouti", - "Africa/Douala", - "Africa/El_Aaiun", - "Africa/Freetown", - "Africa/Gaborone", - "Africa/Harare", - "Africa/Johannesburg", - "Africa/Juba", - "Africa/Kampala", - "Africa/Khartoum", - "Africa/Kigali", - "Africa/Kinshasa", - "Africa/Lagos", - "Africa/Libreville", - "Africa/Lome", - "Africa/Luanda", - "Africa/Lubumbashi", - "Africa/Lusaka", - "Africa/Malabo", - "Africa/Maputo", - "Africa/Maseru", - "Africa/Mbabane", - "Africa/Mogadishu", - "Africa/Monrovia", - "Africa/Nairobi", - "Africa/Ndjamena", - "Africa/Niamey", - "Africa/Nouakchott", - "Africa/Ouagadougou", - "Africa/Porto-Novo", - "Africa/Sao_Tome", - "Africa/Timbuktu", - "Africa/Tripoli", - "Africa/Tunis", - "Africa/Windhoek", - "America/Adak", - "America/Anchorage", - "America/Anguilla", - "America/Antigua", - "America/Araguaina", - "America/Argentina/Buenos_Aires", - "America/Argentina/Catamarca", - "America/Argentina/ComodRivadavia", - "America/Argentina/Cordoba", - "America/Argentina/Jujuy", - "America/Argentina/La_Rioja", - "America/Argentina/Mendoza", - "America/Argentina/Rio_Gallegos", - "America/Argentina/Salta", - "America/Argentina/San_Juan", - "America/Argentina/San_Luis", - "America/Argentina/Tucuman", - "America/Argentina/Ushuaia", - "America/Aruba", - "America/Asuncion", - "America/Atikokan", - "America/Atka", - "America/Bahia", - "America/Bahia_Banderas", - "America/Barbados", - "America/Belem", - "America/Belize", - "America/Blanc-Sablon", - "America/Boa_Vista", - "America/Bogota", - "America/Boise", - "America/Buenos_Aires", - "America/Cambridge_Bay", - "America/Campo_Grande", - "America/Cancun", - "America/Caracas", - "America/Catamarca", - "America/Cayenne", - "America/Cayman", - "America/Chicago", - "America/Chihuahua", - "America/Ciudad_Juarez", - "America/Coral_Harbour", - "America/Cordoba", - "America/Costa_Rica", - "America/Creston", - "America/Cuiaba", - "America/Curacao", - "America/Danmarkshavn", - "America/Dawson", - "America/Dawson_Creek", - "America/Denver", - "America/Detroit", - "America/Dominica", - "America/Edmonton", - "America/Eirunepe", - "America/El_Salvador", - "America/Ensenada", - "America/Fort_Nelson", - "America/Fort_Wayne", - "America/Fortaleza", - "America/Glace_Bay", - "America/Godthab", - "America/Goose_Bay", - "America/Grand_Turk", - "America/Grenada", - "America/Guadeloupe", - "America/Guatemala", - "America/Guayaquil", - "America/Guyana", - "America/Halifax", - "America/Havana", - "America/Hermosillo", - "America/Indiana/Indianapolis", - "America/Indiana/Knox", - "America/Indiana/Marengo", - "America/Indiana/Petersburg", - "America/Indiana/Tell_City", - "America/Indiana/Vevay", - "America/Indiana/Vincennes", - "America/Indiana/Winamac", - "America/Indianapolis", - "America/Inuvik", - "America/Iqaluit", - "America/Jamaica", - "America/Jujuy", - "America/Juneau", - "America/Kentucky/Louisville", - "America/Kentucky/Monticello", - "America/Knox_IN", - "America/Kralendijk", - "America/La_Paz", - "America/Lima", - "America/Los_Angeles", - "America/Louisville", - "America/Lower_Princes", - "America/Maceio", - "America/Managua", - "America/Manaus", - "America/Marigot", - "America/Martinique", - "America/Matamoros", - "America/Mazatlan", - "America/Mendoza", - "America/Menominee", - "America/Merida", - "America/Metlakatla", - "America/Mexico_City", - "America/Miquelon", - "America/Moncton", - "America/Monterrey", - "America/Montevideo", - "America/Montreal", - "America/Montserrat", - "America/Nassau", - "America/New_York", - "America/Nipigon", - "America/Nome", - "America/Noronha", - "America/North_Dakota/Beulah", - "America/North_Dakota/Center", - "America/North_Dakota/New_Salem", - "America/Nuuk", - "America/Ojinaga", - "America/Panama", - "America/Pangnirtung", - "America/Paramaribo", - "America/Phoenix", - "America/Port-au-Prince", - "America/Port_of_Spain", - "America/Porto_Acre", - "America/Porto_Velho", - "America/Puerto_Rico", - "America/Punta_Arenas", - "America/Rainy_River", - "America/Rankin_Inlet", - "America/Recife", - "America/Regina", - "America/Resolute", - "America/Rio_Branco", - "America/Rosario", - "America/Santa_Isabel", - "America/Santarem", - "America/Santiago", - "America/Santo_Domingo", - "America/Sao_Paulo", - "America/Scoresbysund", - "America/Shiprock", - "America/Sitka", - "America/St_Barthelemy", - "America/St_Johns", - "America/St_Kitts", - "America/St_Lucia", - "America/St_Thomas", - "America/St_Vincent", - "America/Swift_Current", - "America/Tegucigalpa", - "America/Thule", - "America/Thunder_Bay", - "America/Tijuana", - "America/Toronto", - "America/Tortola", - "America/Vancouver", - "America/Virgin", - "America/Whitehorse", - "America/Winnipeg", - "America/Yakutat", - "America/Yellowknife", - "Antarctica/Casey", - "Antarctica/Davis", - "Antarctica/DumontDUrville", - "Antarctica/Macquarie", - "Antarctica/Mawson", - "Antarctica/McMurdo", - "Antarctica/Palmer", - "Antarctica/Rothera", - "Antarctica/South_Pole", - "Antarctica/Syowa", - "Antarctica/Troll", - "Antarctica/Vostok", - "Arctic/Longyearbyen", - "Asia/Aden", - "Asia/Almaty", - "Asia/Amman", - "Asia/Anadyr", - "Asia/Aqtau", - "Asia/Aqtobe", - "Asia/Ashgabat", - "Asia/Ashkhabad", - "Asia/Atyrau", - "Asia/Baghdad", - "Asia/Bahrain", - "Asia/Baku", - "Asia/Bangkok", - "Asia/Barnaul", - "Asia/Beirut", - "Asia/Bishkek", - "Asia/Brunei", - "Asia/Calcutta", - "Asia/Chita", - "Asia/Choibalsan", - "Asia/Chongqing", - "Asia/Chungking", - "Asia/Colombo", - "Asia/Dacca", - "Asia/Damascus", - "Asia/Dhaka", - "Asia/Dili", - "Asia/Dubai", - "Asia/Dushanbe", - "Asia/Famagusta", - "Asia/Gaza", - "Asia/Harbin", - "Asia/Hebron", - "Asia/Ho_Chi_Minh", - "Asia/Hong_Kong", - "Asia/Hovd", - "Asia/Irkutsk", - "Asia/Istanbul", - "Asia/Jakarta", - "Asia/Jayapura", - "Asia/Jerusalem", - "Asia/Kabul", - "Asia/Kamchatka", - "Asia/Karachi", - "Asia/Kashgar", - "Asia/Kathmandu", - "Asia/Katmandu", - "Asia/Khandyga", - "Asia/Kolkata", - "Asia/Krasnoyarsk", - "Asia/Kuala_Lumpur", - "Asia/Kuching", - "Asia/Kuwait", - "Asia/Macao", - "Asia/Macau", - "Asia/Magadan", - "Asia/Makassar", - "Asia/Manila", - "Asia/Muscat", - "Asia/Nicosia", - "Asia/Novokuznetsk", - "Asia/Novosibirsk", - "Asia/Omsk", - "Asia/Oral", - "Asia/Phnom_Penh", - "Asia/Pontianak", - "Asia/Pyongyang", - "Asia/Qatar", - "Asia/Qostanay", - "Asia/Qyzylorda", - "Asia/Rangoon", - "Asia/Riyadh", - "Asia/Saigon", - "Asia/Sakhalin", - "Asia/Samarkand", - "Asia/Seoul", - "Asia/Shanghai", - "Asia/Singapore", - "Asia/Srednekolymsk", - "Asia/Taipei", - "Asia/Tashkent", - "Asia/Tbilisi", - "Asia/Tehran", - "Asia/Tel_Aviv", - "Asia/Thimbu", - "Asia/Thimphu", - "Asia/Tokyo", - "Asia/Tomsk", - "Asia/Ujung_Pandang", - "Asia/Ulaanbaatar", - "Asia/Ulan_Bator", - "Asia/Urumqi", - "Asia/Ust-Nera", - "Asia/Vientiane", - "Asia/Vladivostok", - "Asia/Yakutsk", - "Asia/Yangon", - "Asia/Yekaterinburg", - "Asia/Yerevan", - "Atlantic/Azores", - "Atlantic/Bermuda", - "Atlantic/Canary", - "Atlantic/Cape_Verde", - "Atlantic/Faeroe", - "Atlantic/Faroe", - "Atlantic/Jan_Mayen", - "Atlantic/Madeira", - "Atlantic/Reykjavik", - "Atlantic/South_Georgia", - "Atlantic/St_Helena", - "Atlantic/Stanley", - "Australia/ACT", - "Australia/Adelaide", - "Australia/Brisbane", - "Australia/Broken_Hill", - "Australia/Canberra", - "Australia/Currie", - "Australia/Darwin", - "Australia/Eucla", - "Australia/Hobart", - "Australia/LHI", - "Australia/Lindeman", - "Australia/Lord_Howe", - "Australia/Melbourne", - "Australia/NSW", - "Australia/North", - "Australia/Perth", - "Australia/Queensland", - "Australia/South", - "Australia/Sydney", - "Australia/Tasmania", - "Australia/Victoria", - "Australia/West", - "Australia/Yancowinna", - "Brazil/Acre", - "Brazil/DeNoronha", - "Brazil/East", - "Brazil/West", - "CET", - "CST6CDT", - "Canada/Atlantic", - "Canada/Central", - "Canada/Eastern", - "Canada/Mountain", - "Canada/Newfoundland", - "Canada/Pacific", - "Canada/Saskatchewan", - "Canada/Yukon", - "Chile/Continental", - "Chile/EasterIsland", - "Cuba", - "EET", - "EST", - "EST5EDT", - "Egypt", - "Eire", - "Etc/GMT", - "Etc/GMT+0", - "Etc/GMT+1", - "Etc/GMT+10", - "Etc/GMT+11", - "Etc/GMT+12", - "Etc/GMT+2", - "Etc/GMT+3", - "Etc/GMT+4", - "Etc/GMT+5", - "Etc/GMT+6", - "Etc/GMT+7", - "Etc/GMT+8", - "Etc/GMT+9", - "Etc/GMT-0", - "Etc/GMT-1", - "Etc/GMT-10", - "Etc/GMT-11", - "Etc/GMT-12", - "Etc/GMT-13", - "Etc/GMT-14", - "Etc/GMT-2", - "Etc/GMT-3", - "Etc/GMT-4", - "Etc/GMT-5", - "Etc/GMT-6", - "Etc/GMT-7", - "Etc/GMT-8", - "Etc/GMT-9", - "Etc/GMT0", - "Etc/Greenwich", - "Etc/UCT", - "Etc/UTC", - "Etc/Universal", - "Etc/Zulu", - "Europe/Amsterdam", - "Europe/Andorra", - "Europe/Astrakhan", - "Europe/Athens", - "Europe/Belfast", - "Europe/Belgrade", - "Europe/Berlin", - "Europe/Bratislava", - "Europe/Brussels", - "Europe/Bucharest", - "Europe/Budapest", - "Europe/Busingen", - "Europe/Chisinau", - "Europe/Copenhagen", - "Europe/Dublin", - "Europe/Gibraltar", - "Europe/Guernsey", - "Europe/Helsinki", - "Europe/Isle_of_Man", - "Europe/Istanbul", - "Europe/Jersey", - "Europe/Kaliningrad", - "Europe/Kiev", - "Europe/Kirov", - "Europe/Kyiv", - "Europe/Lisbon", - "Europe/Ljubljana", - "Europe/London", - "Europe/Luxembourg", - "Europe/Madrid", - "Europe/Malta", - "Europe/Mariehamn", - "Europe/Minsk", - "Europe/Monaco", - "Europe/Moscow", - "Europe/Nicosia", - "Europe/Oslo", - "Europe/Paris", - "Europe/Podgorica", - "Europe/Prague", - "Europe/Riga", - "Europe/Rome", - "Europe/Samara", - "Europe/San_Marino", - "Europe/Sarajevo", - "Europe/Saratov", - "Europe/Simferopol", - "Europe/Skopje", - "Europe/Sofia", - "Europe/Stockholm", - "Europe/Tallinn", - "Europe/Tirane", - "Europe/Tiraspol", - "Europe/Ulyanovsk", - "Europe/Uzhgorod", - "Europe/Vaduz", - "Europe/Vatican", - "Europe/Vienna", - "Europe/Vilnius", - "Europe/Volgograd", - "Europe/Warsaw", - "Europe/Zagreb", - "Europe/Zaporozhye", - "Europe/Zurich", - "GB", - "GB-Eire", - "GMT", - "GMT+0", - "GMT-0", - "GMT0", - "Greenwich", - "HST", - "Hongkong", - "Iceland", - "Indian/Antananarivo", - "Indian/Chagos", - "Indian/Christmas", - "Indian/Cocos", - "Indian/Comoro", - "Indian/Kerguelen", - "Indian/Mahe", - "Indian/Maldives", - "Indian/Mauritius", - "Indian/Mayotte", - "Indian/Reunion", - "Iran", - "Israel", - "Jamaica", - "Japan", - "Kwajalein", - "Libya", - "MET", - "MST", - "MST7MDT", - "Mexico/BajaNorte", - "Mexico/BajaSur", - "Mexico/General", - "NZ", - "NZ-CHAT", - "Navajo", - "PRC", - "PST8PDT", - "Pacific/Apia", - "Pacific/Auckland", - "Pacific/Bougainville", - "Pacific/Chatham", - "Pacific/Chuuk", - "Pacific/Easter", - "Pacific/Efate", - "Pacific/Enderbury", - "Pacific/Fakaofo", - "Pacific/Fiji", - "Pacific/Funafuti", - "Pacific/Galapagos", - "Pacific/Gambier", - "Pacific/Guadalcanal", - "Pacific/Guam", - "Pacific/Honolulu", - "Pacific/Johnston", - "Pacific/Kanton", - "Pacific/Kiritimati", - "Pacific/Kosrae", - "Pacific/Kwajalein", - "Pacific/Majuro", - "Pacific/Marquesas", - "Pacific/Midway", - "Pacific/Nauru", - "Pacific/Niue", - "Pacific/Norfolk", - "Pacific/Noumea", - "Pacific/Pago_Pago", - "Pacific/Palau", - "Pacific/Pitcairn", - "Pacific/Pohnpei", - "Pacific/Ponape", - "Pacific/Port_Moresby", - "Pacific/Rarotonga", - "Pacific/Saipan", - "Pacific/Samoa", - "Pacific/Tahiti", - "Pacific/Tarawa", - "Pacific/Tongatapu", - "Pacific/Truk", - "Pacific/Wake", - "Pacific/Wallis", - "Pacific/Yap", - "Poland", - "Portugal", - "ROC", - "ROK", - "Singapore", - "Turkey", - "UCT", - "US/Alaska", - "US/Aleutian", - "US/Arizona", - "US/Central", - "US/East-Indiana", - "US/Eastern", - "US/Hawaii", - "US/Indiana-Starke", - "US/Michigan", - "US/Mountain", - "US/Pacific", - "US/Samoa", - "UTC", - "Universal", - "W-SU", - "WET", - "Zulu", - ) -} - - -def subsecond_precision(timestamp_literal: str) -> int: - """ - Given an ISO-8601 timestamp literal, eg '2023-01-01 12:13:14.123456+00:00' - figure out its subsecond precision so we can construct types like DATETIME(6) - - Note that in practice, this is either 3 or 6 digits (3 = millisecond precision, 6 = microsecond precision) - - 6 is the maximum because strftime's '%f' formats to microseconds and almost every database supports microsecond precision in timestamps - - Except Presto/Trino which in most cases only supports millisecond precision but will still honour '%f' and format to microseconds (replacing the remaining 3 digits with 0's) - - Python prior to 3.11 only supports 0, 3 or 6 digits in a timestamp literal. Any other amounts will throw a 'ValueError: Invalid isoformat string:' error - """ - try: - parsed = datetime.datetime.fromisoformat(timestamp_literal) - subsecond_digit_count = len(str(parsed.microsecond).rstrip("0")) - precision = 0 - if subsecond_digit_count > 3: - precision = 6 - elif subsecond_digit_count > 0: - precision = 3 - return precision - except ValueError: - return 0 diff --git a/third_party/bigframes_vendored/sqlglot/tokens.py b/third_party/bigframes_vendored/sqlglot/tokens.py deleted file mode 100644 index 9a2c4f7a650..00000000000 --- a/third_party/bigframes_vendored/sqlglot/tokens.py +++ /dev/null @@ -1,1640 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/tokens.py - -from __future__ import annotations - -import os -import typing as t -from enum import auto - -from bigframes_vendored.sqlglot.errors import SqlglotError, TokenError -from bigframes_vendored.sqlglot.helper import AutoName -from bigframes_vendored.sqlglot.trie import TrieResult, in_trie, new_trie - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.dialects.dialect import DialectType - - -try: - from bigframes_vendored.sqlglotrs import Tokenizer as RsTokenizer # type: ignore - from bigframes_vendored.sqlglotrs import ( - TokenizerDialectSettings as RsTokenizerDialectSettings, - ) - from bigframes_vendored.sqlglotrs import TokenizerSettings as RsTokenizerSettings - from bigframes_vendored.sqlglotrs import TokenTypeSettings as RsTokenTypeSettings - - USE_RS_TOKENIZER = os.environ.get("SQLGLOTRS_TOKENIZER", "1") == "1" -except ImportError: - USE_RS_TOKENIZER = False - - -class TokenType(AutoName): - L_PAREN = auto() - R_PAREN = auto() - L_BRACKET = auto() - R_BRACKET = auto() - L_BRACE = auto() - R_BRACE = auto() - COMMA = auto() - DOT = auto() - DASH = auto() - PLUS = auto() - COLON = auto() - DOTCOLON = auto() - DCOLON = auto() - DCOLONDOLLAR = auto() - DCOLONPERCENT = auto() - DCOLONQMARK = auto() - DQMARK = auto() - SEMICOLON = auto() - STAR = auto() - BACKSLASH = auto() - SLASH = auto() - LT = auto() - LTE = auto() - GT = auto() - GTE = auto() - NOT = auto() - EQ = auto() - NEQ = auto() - NULLSAFE_EQ = auto() - COLON_EQ = auto() - COLON_GT = auto() - NCOLON_GT = auto() - AND = auto() - OR = auto() - AMP = auto() - DPIPE = auto() - PIPE_GT = auto() - PIPE = auto() - PIPE_SLASH = auto() - DPIPE_SLASH = auto() - CARET = auto() - CARET_AT = auto() - TILDA = auto() - ARROW = auto() - DARROW = auto() - FARROW = auto() - HASH = auto() - HASH_ARROW = auto() - DHASH_ARROW = auto() - LR_ARROW = auto() - DAT = auto() - LT_AT = auto() - AT_GT = auto() - DOLLAR = auto() - PARAMETER = auto() - SESSION = auto() - SESSION_PARAMETER = auto() - SESSION_USER = auto() - DAMP = auto() - AMP_LT = auto() - AMP_GT = auto() - ADJACENT = auto() - XOR = auto() - DSTAR = auto() - QMARK_AMP = auto() - QMARK_PIPE = auto() - HASH_DASH = auto() - EXCLAMATION = auto() - - URI_START = auto() - - BLOCK_START = auto() - BLOCK_END = auto() - - SPACE = auto() - BREAK = auto() - - STRING = auto() - NUMBER = auto() - IDENTIFIER = auto() - DATABASE = auto() - COLUMN = auto() - COLUMN_DEF = auto() - SCHEMA = auto() - TABLE = auto() - WAREHOUSE = auto() - STAGE = auto() - STREAMLIT = auto() - VAR = auto() - BIT_STRING = auto() - HEX_STRING = auto() - BYTE_STRING = auto() - NATIONAL_STRING = auto() - RAW_STRING = auto() - HEREDOC_STRING = auto() - UNICODE_STRING = auto() - - # types - BIT = auto() - BOOLEAN = auto() - TINYINT = auto() - UTINYINT = auto() - SMALLINT = auto() - USMALLINT = auto() - MEDIUMINT = auto() - UMEDIUMINT = auto() - INT = auto() - UINT = auto() - BIGINT = auto() - UBIGINT = auto() - BIGNUM = auto() # unlimited precision int - INT128 = auto() - UINT128 = auto() - INT256 = auto() - UINT256 = auto() - FLOAT = auto() - DOUBLE = auto() - UDOUBLE = auto() - DECIMAL = auto() - DECIMAL32 = auto() - DECIMAL64 = auto() - DECIMAL128 = auto() - DECIMAL256 = auto() - DECFLOAT = auto() - UDECIMAL = auto() - BIGDECIMAL = auto() - CHAR = auto() - NCHAR = auto() - VARCHAR = auto() - NVARCHAR = auto() - BPCHAR = auto() - TEXT = auto() - MEDIUMTEXT = auto() - LONGTEXT = auto() - BLOB = auto() - MEDIUMBLOB = auto() - LONGBLOB = auto() - TINYBLOB = auto() - TINYTEXT = auto() - NAME = auto() - BINARY = auto() - VARBINARY = auto() - JSON = auto() - JSONB = auto() - TIME = auto() - TIMETZ = auto() - TIME_NS = auto() - TIMESTAMP = auto() - TIMESTAMPTZ = auto() - TIMESTAMPLTZ = auto() - TIMESTAMPNTZ = auto() - TIMESTAMP_S = auto() - TIMESTAMP_MS = auto() - TIMESTAMP_NS = auto() - DATETIME = auto() - DATETIME2 = auto() - DATETIME64 = auto() - SMALLDATETIME = auto() - DATE = auto() - DATE32 = auto() - INT4RANGE = auto() - INT4MULTIRANGE = auto() - INT8RANGE = auto() - INT8MULTIRANGE = auto() - NUMRANGE = auto() - NUMMULTIRANGE = auto() - TSRANGE = auto() - TSMULTIRANGE = auto() - TSTZRANGE = auto() - TSTZMULTIRANGE = auto() - DATERANGE = auto() - DATEMULTIRANGE = auto() - UUID = auto() - GEOGRAPHY = auto() - GEOGRAPHYPOINT = auto() - NULLABLE = auto() - GEOMETRY = auto() - POINT = auto() - RING = auto() - LINESTRING = auto() - LOCALTIME = auto() - LOCALTIMESTAMP = auto() - MULTILINESTRING = auto() - POLYGON = auto() - MULTIPOLYGON = auto() - HLLSKETCH = auto() - HSTORE = auto() - SUPER = auto() - SERIAL = auto() - SMALLSERIAL = auto() - BIGSERIAL = auto() - XML = auto() - YEAR = auto() - USERDEFINED = auto() - MONEY = auto() - SMALLMONEY = auto() - ROWVERSION = auto() - IMAGE = auto() - VARIANT = auto() - OBJECT = auto() - INET = auto() - IPADDRESS = auto() - IPPREFIX = auto() - IPV4 = auto() - IPV6 = auto() - ENUM = auto() - ENUM8 = auto() - ENUM16 = auto() - FIXEDSTRING = auto() - LOWCARDINALITY = auto() - NESTED = auto() - AGGREGATEFUNCTION = auto() - SIMPLEAGGREGATEFUNCTION = auto() - TDIGEST = auto() - UNKNOWN = auto() - VECTOR = auto() - DYNAMIC = auto() - VOID = auto() - - # keywords - ALIAS = auto() - ALTER = auto() - ALL = auto() - ANTI = auto() - ANY = auto() - APPLY = auto() - ARRAY = auto() - ASC = auto() - ASOF = auto() - ATTACH = auto() - AUTO_INCREMENT = auto() - BEGIN = auto() - BETWEEN = auto() - BULK_COLLECT_INTO = auto() - CACHE = auto() - CASE = auto() - CHARACTER_SET = auto() - CLUSTER_BY = auto() - COLLATE = auto() - COMMAND = auto() - COMMENT = auto() - COMMIT = auto() - CONNECT_BY = auto() - CONSTRAINT = auto() - COPY = auto() - CREATE = auto() - CROSS = auto() - CUBE = auto() - CURRENT_DATE = auto() - CURRENT_DATETIME = auto() - CURRENT_SCHEMA = auto() - CURRENT_TIME = auto() - CURRENT_TIMESTAMP = auto() - CURRENT_USER = auto() - CURRENT_ROLE = auto() - CURRENT_CATALOG = auto() - DECLARE = auto() - DEFAULT = auto() - DELETE = auto() - DESC = auto() - DESCRIBE = auto() - DETACH = auto() - DICTIONARY = auto() - DISTINCT = auto() - DISTRIBUTE_BY = auto() - DIV = auto() - DROP = auto() - ELSE = auto() - END = auto() - ESCAPE = auto() - EXCEPT = auto() - EXECUTE = auto() - EXISTS = auto() - FALSE = auto() - FETCH = auto() - FILE = auto() - FILE_FORMAT = auto() - FILTER = auto() - FINAL = auto() - FIRST = auto() - FOR = auto() - FORCE = auto() - FOREIGN_KEY = auto() - FORMAT = auto() - FROM = auto() - FULL = auto() - FUNCTION = auto() - GET = auto() - GLOB = auto() - GLOBAL = auto() - GRANT = auto() - GROUP_BY = auto() - GROUPING_SETS = auto() - HAVING = auto() - HINT = auto() - IGNORE = auto() - ILIKE = auto() - IN = auto() - INDEX = auto() - INDEXED_BY = auto() - INNER = auto() - INSERT = auto() - INSTALL = auto() - INTERSECT = auto() - INTERVAL = auto() - INTO = auto() - INTRODUCER = auto() - IRLIKE = auto() - IS = auto() - ISNULL = auto() - JOIN = auto() - JOIN_MARKER = auto() - KEEP = auto() - KEY = auto() - KILL = auto() - LANGUAGE = auto() - LATERAL = auto() - LEFT = auto() - LIKE = auto() - LIMIT = auto() - LIST = auto() - LOAD = auto() - LOCK = auto() - MAP = auto() - MATCH = auto() - MATCH_CONDITION = auto() - MATCH_RECOGNIZE = auto() - MEMBER_OF = auto() - MERGE = auto() - MOD = auto() - MODEL = auto() - NATURAL = auto() - NEXT = auto() - NOTHING = auto() - NOTNULL = auto() - NULL = auto() - OBJECT_IDENTIFIER = auto() - OFFSET = auto() - ON = auto() - ONLY = auto() - OPERATOR = auto() - ORDER_BY = auto() - ORDER_SIBLINGS_BY = auto() - ORDERED = auto() - ORDINALITY = auto() - OUTER = auto() - OVER = auto() - OVERLAPS = auto() - OVERWRITE = auto() - PARTITION = auto() - PARTITION_BY = auto() - PERCENT = auto() - PIVOT = auto() - PLACEHOLDER = auto() - POSITIONAL = auto() - PRAGMA = auto() - PREWHERE = auto() - PRIMARY_KEY = auto() - PROCEDURE = auto() - PROPERTIES = auto() - PSEUDO_TYPE = auto() - PUT = auto() - QUALIFY = auto() - QUOTE = auto() - QDCOLON = auto() - RANGE = auto() - RECURSIVE = auto() - REFRESH = auto() - RENAME = auto() - REPLACE = auto() - RETURNING = auto() - REVOKE = auto() - REFERENCES = auto() - RIGHT = auto() - RLIKE = auto() - ROLLBACK = auto() - ROLLUP = auto() - ROW = auto() - ROWS = auto() - SELECT = auto() - SEMI = auto() - SEPARATOR = auto() - SEQUENCE = auto() - SERDE_PROPERTIES = auto() - SET = auto() - SETTINGS = auto() - SHOW = auto() - SIMILAR_TO = auto() - SOME = auto() - SORT_BY = auto() - SOUNDS_LIKE = auto() - START_WITH = auto() - STORAGE_INTEGRATION = auto() - STRAIGHT_JOIN = auto() - STRUCT = auto() - SUMMARIZE = auto() - TABLE_SAMPLE = auto() - TAG = auto() - TEMPORARY = auto() - TOP = auto() - THEN = auto() - TRUE = auto() - TRUNCATE = auto() - UNCACHE = auto() - UNION = auto() - UNNEST = auto() - UNPIVOT = auto() - UPDATE = auto() - USE = auto() - USING = auto() - VALUES = auto() - VIEW = auto() - SEMANTIC_VIEW = auto() - VOLATILE = auto() - WHEN = auto() - WHERE = auto() - WINDOW = auto() - WITH = auto() - UNIQUE = auto() - UTC_DATE = auto() - UTC_TIME = auto() - UTC_TIMESTAMP = auto() - VERSION_SNAPSHOT = auto() - TIMESTAMP_SNAPSHOT = auto() - OPTION = auto() - SINK = auto() - SOURCE = auto() - ANALYZE = auto() - NAMESPACE = auto() - EXPORT = auto() - - # sentinel - HIVE_TOKEN_STREAM = auto() - - -_ALL_TOKEN_TYPES = list(TokenType) -_TOKEN_TYPE_TO_INDEX = {token_type: i for i, token_type in enumerate(_ALL_TOKEN_TYPES)} - - -class Token: - __slots__ = ("token_type", "text", "line", "col", "start", "end", "comments") - - @classmethod - def number(cls, number: int) -> Token: - """Returns a NUMBER token with `number` as its text.""" - return cls(TokenType.NUMBER, str(number)) - - @classmethod - def string(cls, string: str) -> Token: - """Returns a STRING token with `string` as its text.""" - return cls(TokenType.STRING, string) - - @classmethod - def identifier(cls, identifier: str) -> Token: - """Returns an IDENTIFIER token with `identifier` as its text.""" - return cls(TokenType.IDENTIFIER, identifier) - - @classmethod - def var(cls, var: str) -> Token: - """Returns an VAR token with `var` as its text.""" - return cls(TokenType.VAR, var) - - def __init__( - self, - token_type: TokenType, - text: str, - line: int = 1, - col: int = 1, - start: int = 0, - end: int = 0, - comments: t.Optional[t.List[str]] = None, - ) -> None: - """Token initializer. - - Args: - token_type: The TokenType Enum. - text: The text of the token. - line: The line that the token ends on. - col: The column that the token ends on. - start: The start index of the token. - end: The ending index of the token. - comments: The comments to attach to the token. - """ - self.token_type = token_type - self.text = text - self.line = line - self.col = col - self.start = start - self.end = end - self.comments = [] if comments is None else comments - - def __repr__(self) -> str: - attributes = ", ".join(f"{k}: {getattr(self, k)}" for k in self.__slots__) - return f"" - - -class _Tokenizer(type): - def __new__(cls, clsname, bases, attrs): - klass = super().__new__(cls, clsname, bases, attrs) - - def _convert_quotes(arr: t.List[str | t.Tuple[str, str]]) -> t.Dict[str, str]: - return dict( - (item, item) if isinstance(item, str) else (item[0], item[1]) - for item in arr - ) - - def _quotes_to_format( - token_type: TokenType, arr: t.List[str | t.Tuple[str, str]] - ) -> t.Dict[str, t.Tuple[str, TokenType]]: - return {k: (v, token_type) for k, v in _convert_quotes(arr).items()} - - klass._QUOTES = _convert_quotes(klass.QUOTES) - klass._IDENTIFIERS = _convert_quotes(klass.IDENTIFIERS) - - klass._FORMAT_STRINGS = { - **{ - p + s: (e, TokenType.NATIONAL_STRING) - for s, e in klass._QUOTES.items() - for p in ("n", "N") - }, - **_quotes_to_format(TokenType.BIT_STRING, klass.BIT_STRINGS), - **_quotes_to_format(TokenType.BYTE_STRING, klass.BYTE_STRINGS), - **_quotes_to_format(TokenType.HEX_STRING, klass.HEX_STRINGS), - **_quotes_to_format(TokenType.RAW_STRING, klass.RAW_STRINGS), - **_quotes_to_format(TokenType.HEREDOC_STRING, klass.HEREDOC_STRINGS), - **_quotes_to_format(TokenType.UNICODE_STRING, klass.UNICODE_STRINGS), - } - - klass._STRING_ESCAPES = set(klass.STRING_ESCAPES) - klass._ESCAPE_FOLLOW_CHARS = set(klass.ESCAPE_FOLLOW_CHARS) - klass._IDENTIFIER_ESCAPES = set(klass.IDENTIFIER_ESCAPES) - klass._COMMENTS = { - **dict( - (comment, None) - if isinstance(comment, str) - else (comment[0], comment[1]) - for comment in klass.COMMENTS - ), - "{#": "#}", # Ensure Jinja comments are tokenized correctly in all dialects - } - if klass.HINT_START in klass.KEYWORDS: - klass._COMMENTS[klass.HINT_START] = "*/" - - klass._KEYWORD_TRIE = new_trie( - key.upper() - for key in ( - *klass.KEYWORDS, - *klass._COMMENTS, - *klass._QUOTES, - *klass._FORMAT_STRINGS, - ) - if " " in key or any(single in key for single in klass.SINGLE_TOKENS) - ) - - if USE_RS_TOKENIZER: - settings = RsTokenizerSettings( - white_space={ - k: _TOKEN_TYPE_TO_INDEX[v] for k, v in klass.WHITE_SPACE.items() - }, - single_tokens={ - k: _TOKEN_TYPE_TO_INDEX[v] for k, v in klass.SINGLE_TOKENS.items() - }, - keywords={ - k: _TOKEN_TYPE_TO_INDEX[v] for k, v in klass.KEYWORDS.items() - }, - numeric_literals=klass.NUMERIC_LITERALS, - identifiers=klass._IDENTIFIERS, - identifier_escapes=klass._IDENTIFIER_ESCAPES, - string_escapes=klass._STRING_ESCAPES, - quotes=klass._QUOTES, - format_strings={ - k: (v1, _TOKEN_TYPE_TO_INDEX[v2]) - for k, (v1, v2) in klass._FORMAT_STRINGS.items() - }, - has_bit_strings=bool(klass.BIT_STRINGS), - has_hex_strings=bool(klass.HEX_STRINGS), - comments=klass._COMMENTS, - var_single_tokens=klass.VAR_SINGLE_TOKENS, - commands={_TOKEN_TYPE_TO_INDEX[v] for v in klass.COMMANDS}, - command_prefix_tokens={ - _TOKEN_TYPE_TO_INDEX[v] for v in klass.COMMAND_PREFIX_TOKENS - }, - heredoc_tag_is_identifier=klass.HEREDOC_TAG_IS_IDENTIFIER, - string_escapes_allowed_in_raw_strings=klass.STRING_ESCAPES_ALLOWED_IN_RAW_STRINGS, - nested_comments=klass.NESTED_COMMENTS, - hint_start=klass.HINT_START, - tokens_preceding_hint={ - _TOKEN_TYPE_TO_INDEX[v] for v in klass.TOKENS_PRECEDING_HINT - }, - escape_follow_chars=klass._ESCAPE_FOLLOW_CHARS, - ) - token_types = RsTokenTypeSettings( - bit_string=_TOKEN_TYPE_TO_INDEX[TokenType.BIT_STRING], - break_=_TOKEN_TYPE_TO_INDEX[TokenType.BREAK], - dcolon=_TOKEN_TYPE_TO_INDEX[TokenType.DCOLON], - heredoc_string=_TOKEN_TYPE_TO_INDEX[TokenType.HEREDOC_STRING], - raw_string=_TOKEN_TYPE_TO_INDEX[TokenType.RAW_STRING], - hex_string=_TOKEN_TYPE_TO_INDEX[TokenType.HEX_STRING], - identifier=_TOKEN_TYPE_TO_INDEX[TokenType.IDENTIFIER], - number=_TOKEN_TYPE_TO_INDEX[TokenType.NUMBER], - parameter=_TOKEN_TYPE_TO_INDEX[TokenType.PARAMETER], - semicolon=_TOKEN_TYPE_TO_INDEX[TokenType.SEMICOLON], - string=_TOKEN_TYPE_TO_INDEX[TokenType.STRING], - var=_TOKEN_TYPE_TO_INDEX[TokenType.VAR], - heredoc_string_alternative=_TOKEN_TYPE_TO_INDEX[ - klass.HEREDOC_STRING_ALTERNATIVE - ], - hint=_TOKEN_TYPE_TO_INDEX[TokenType.HINT], - ) - klass._RS_TOKENIZER = RsTokenizer(settings, token_types) - else: - klass._RS_TOKENIZER = None - - return klass - - -class Tokenizer(metaclass=_Tokenizer): - SINGLE_TOKENS = { - "(": TokenType.L_PAREN, - ")": TokenType.R_PAREN, - "[": TokenType.L_BRACKET, - "]": TokenType.R_BRACKET, - "{": TokenType.L_BRACE, - "}": TokenType.R_BRACE, - "&": TokenType.AMP, - "^": TokenType.CARET, - ":": TokenType.COLON, - ",": TokenType.COMMA, - ".": TokenType.DOT, - "-": TokenType.DASH, - "=": TokenType.EQ, - ">": TokenType.GT, - "<": TokenType.LT, - "%": TokenType.MOD, - "!": TokenType.NOT, - "|": TokenType.PIPE, - "+": TokenType.PLUS, - ";": TokenType.SEMICOLON, - "/": TokenType.SLASH, - "\\": TokenType.BACKSLASH, - "*": TokenType.STAR, - "~": TokenType.TILDA, - "?": TokenType.PLACEHOLDER, - "@": TokenType.PARAMETER, - "#": TokenType.HASH, - # Used for breaking a var like x'y' but nothing else the token type doesn't matter - "'": TokenType.UNKNOWN, - "`": TokenType.UNKNOWN, - '"': TokenType.UNKNOWN, - } - - BIT_STRINGS: t.List[str | t.Tuple[str, str]] = [] - BYTE_STRINGS: t.List[str | t.Tuple[str, str]] = [] - HEX_STRINGS: t.List[str | t.Tuple[str, str]] = [] - RAW_STRINGS: t.List[str | t.Tuple[str, str]] = [] - HEREDOC_STRINGS: t.List[str | t.Tuple[str, str]] = [] - UNICODE_STRINGS: t.List[str | t.Tuple[str, str]] = [] - IDENTIFIERS: t.List[str | t.Tuple[str, str]] = ['"'] - QUOTES: t.List[t.Tuple[str, str] | str] = ["'"] - STRING_ESCAPES = ["'"] - VAR_SINGLE_TOKENS: t.Set[str] = set() - ESCAPE_FOLLOW_CHARS: t.List[str] = [] - - # The strings in this list can always be used as escapes, regardless of the surrounding - # identifier delimiters. By default, the closing delimiter is assumed to also act as an - # identifier escape, e.g. if we use double-quotes, then they also act as escapes: "x""" - IDENTIFIER_ESCAPES: t.List[str] = [] - - # Whether the heredoc tags follow the same lexical rules as unquoted identifiers - HEREDOC_TAG_IS_IDENTIFIER = False - - # Token that we'll generate as a fallback if the heredoc prefix doesn't correspond to a heredoc - HEREDOC_STRING_ALTERNATIVE = TokenType.VAR - - # Whether string escape characters function as such when placed within raw strings - STRING_ESCAPES_ALLOWED_IN_RAW_STRINGS = True - - NESTED_COMMENTS = True - - HINT_START = "/*+" - - TOKENS_PRECEDING_HINT = { - TokenType.SELECT, - TokenType.INSERT, - TokenType.UPDATE, - TokenType.DELETE, - } - - # Autofilled - _COMMENTS: t.Dict[str, str] = {} - _FORMAT_STRINGS: t.Dict[str, t.Tuple[str, TokenType]] = {} - _IDENTIFIERS: t.Dict[str, str] = {} - _IDENTIFIER_ESCAPES: t.Set[str] = set() - _QUOTES: t.Dict[str, str] = {} - _STRING_ESCAPES: t.Set[str] = set() - _KEYWORD_TRIE: t.Dict = {} - _RS_TOKENIZER: t.Optional[t.Any] = None - _ESCAPE_FOLLOW_CHARS: t.Set[str] = set() - - KEYWORDS: t.Dict[str, TokenType] = { - **{f"{{%{postfix}": TokenType.BLOCK_START for postfix in ("", "+", "-")}, - **{f"{prefix}%}}": TokenType.BLOCK_END for prefix in ("", "+", "-")}, - **{f"{{{{{postfix}": TokenType.BLOCK_START for postfix in ("+", "-")}, - **{f"{prefix}}}}}": TokenType.BLOCK_END for prefix in ("+", "-")}, - HINT_START: TokenType.HINT, - "&<": TokenType.AMP_LT, - "&>": TokenType.AMP_GT, - "==": TokenType.EQ, - "::": TokenType.DCOLON, - "?::": TokenType.QDCOLON, - "||": TokenType.DPIPE, - "|>": TokenType.PIPE_GT, - ">=": TokenType.GTE, - "<=": TokenType.LTE, - "<>": TokenType.NEQ, - "!=": TokenType.NEQ, - ":=": TokenType.COLON_EQ, - "<=>": TokenType.NULLSAFE_EQ, - "->": TokenType.ARROW, - "->>": TokenType.DARROW, - "=>": TokenType.FARROW, - "#>": TokenType.HASH_ARROW, - "#>>": TokenType.DHASH_ARROW, - "<->": TokenType.LR_ARROW, - "&&": TokenType.DAMP, - "??": TokenType.DQMARK, - "~~~": TokenType.GLOB, - "~~": TokenType.LIKE, - "~~*": TokenType.ILIKE, - "~*": TokenType.IRLIKE, - "-|-": TokenType.ADJACENT, - "ALL": TokenType.ALL, - "AND": TokenType.AND, - "ANTI": TokenType.ANTI, - "ANY": TokenType.ANY, - "ASC": TokenType.ASC, - "AS": TokenType.ALIAS, - "ASOF": TokenType.ASOF, - "AUTOINCREMENT": TokenType.AUTO_INCREMENT, - "AUTO_INCREMENT": TokenType.AUTO_INCREMENT, - "BEGIN": TokenType.BEGIN, - "BETWEEN": TokenType.BETWEEN, - "CACHE": TokenType.CACHE, - "UNCACHE": TokenType.UNCACHE, - "CASE": TokenType.CASE, - "CHARACTER SET": TokenType.CHARACTER_SET, - "CLUSTER BY": TokenType.CLUSTER_BY, - "COLLATE": TokenType.COLLATE, - "COLUMN": TokenType.COLUMN, - "COMMIT": TokenType.COMMIT, - "CONNECT BY": TokenType.CONNECT_BY, - "CONSTRAINT": TokenType.CONSTRAINT, - "COPY": TokenType.COPY, - "CREATE": TokenType.CREATE, - "CROSS": TokenType.CROSS, - "CUBE": TokenType.CUBE, - "CURRENT_DATE": TokenType.CURRENT_DATE, - "CURRENT_SCHEMA": TokenType.CURRENT_SCHEMA, - "CURRENT_TIME": TokenType.CURRENT_TIME, - "CURRENT_TIMESTAMP": TokenType.CURRENT_TIMESTAMP, - "CURRENT_USER": TokenType.CURRENT_USER, - "CURRENT_CATALOG": TokenType.CURRENT_CATALOG, - "DATABASE": TokenType.DATABASE, - "DEFAULT": TokenType.DEFAULT, - "DELETE": TokenType.DELETE, - "DESC": TokenType.DESC, - "DESCRIBE": TokenType.DESCRIBE, - "DISTINCT": TokenType.DISTINCT, - "DISTRIBUTE BY": TokenType.DISTRIBUTE_BY, - "DIV": TokenType.DIV, - "DROP": TokenType.DROP, - "ELSE": TokenType.ELSE, - "END": TokenType.END, - "ENUM": TokenType.ENUM, - "ESCAPE": TokenType.ESCAPE, - "EXCEPT": TokenType.EXCEPT, - "EXECUTE": TokenType.EXECUTE, - "EXISTS": TokenType.EXISTS, - "FALSE": TokenType.FALSE, - "FETCH": TokenType.FETCH, - "FILTER": TokenType.FILTER, - "FILE": TokenType.FILE, - "FIRST": TokenType.FIRST, - "FULL": TokenType.FULL, - "FUNCTION": TokenType.FUNCTION, - "FOR": TokenType.FOR, - "FOREIGN KEY": TokenType.FOREIGN_KEY, - "FORMAT": TokenType.FORMAT, - "FROM": TokenType.FROM, - "GEOGRAPHY": TokenType.GEOGRAPHY, - "GEOMETRY": TokenType.GEOMETRY, - "GLOB": TokenType.GLOB, - "GROUP BY": TokenType.GROUP_BY, - "GROUPING SETS": TokenType.GROUPING_SETS, - "HAVING": TokenType.HAVING, - "ILIKE": TokenType.ILIKE, - "IN": TokenType.IN, - "INDEX": TokenType.INDEX, - "INET": TokenType.INET, - "INNER": TokenType.INNER, - "INSERT": TokenType.INSERT, - "INTERVAL": TokenType.INTERVAL, - "INTERSECT": TokenType.INTERSECT, - "INTO": TokenType.INTO, - "IS": TokenType.IS, - "ISNULL": TokenType.ISNULL, - "JOIN": TokenType.JOIN, - "KEEP": TokenType.KEEP, - "KILL": TokenType.KILL, - "LATERAL": TokenType.LATERAL, - "LEFT": TokenType.LEFT, - "LIKE": TokenType.LIKE, - "LIMIT": TokenType.LIMIT, - "LOAD": TokenType.LOAD, - "LOCALTIME": TokenType.LOCALTIME, - "LOCALTIMESTAMP": TokenType.LOCALTIMESTAMP, - "LOCK": TokenType.LOCK, - "MERGE": TokenType.MERGE, - "NAMESPACE": TokenType.NAMESPACE, - "NATURAL": TokenType.NATURAL, - "NEXT": TokenType.NEXT, - "NOT": TokenType.NOT, - "NOTNULL": TokenType.NOTNULL, - "NULL": TokenType.NULL, - "OBJECT": TokenType.OBJECT, - "OFFSET": TokenType.OFFSET, - "ON": TokenType.ON, - "OR": TokenType.OR, - "XOR": TokenType.XOR, - "ORDER BY": TokenType.ORDER_BY, - "ORDINALITY": TokenType.ORDINALITY, - "OUTER": TokenType.OUTER, - "OVER": TokenType.OVER, - "OVERLAPS": TokenType.OVERLAPS, - "OVERWRITE": TokenType.OVERWRITE, - "PARTITION": TokenType.PARTITION, - "PARTITION BY": TokenType.PARTITION_BY, - "PARTITIONED BY": TokenType.PARTITION_BY, - "PARTITIONED_BY": TokenType.PARTITION_BY, - "PERCENT": TokenType.PERCENT, - "PIVOT": TokenType.PIVOT, - "PRAGMA": TokenType.PRAGMA, - "PRIMARY KEY": TokenType.PRIMARY_KEY, - "PROCEDURE": TokenType.PROCEDURE, - "OPERATOR": TokenType.OPERATOR, - "QUALIFY": TokenType.QUALIFY, - "RANGE": TokenType.RANGE, - "RECURSIVE": TokenType.RECURSIVE, - "REGEXP": TokenType.RLIKE, - "RENAME": TokenType.RENAME, - "REPLACE": TokenType.REPLACE, - "RETURNING": TokenType.RETURNING, - "REFERENCES": TokenType.REFERENCES, - "RIGHT": TokenType.RIGHT, - "RLIKE": TokenType.RLIKE, - "ROLLBACK": TokenType.ROLLBACK, - "ROLLUP": TokenType.ROLLUP, - "ROW": TokenType.ROW, - "ROWS": TokenType.ROWS, - "SCHEMA": TokenType.SCHEMA, - "SELECT": TokenType.SELECT, - "SEMI": TokenType.SEMI, - "SESSION": TokenType.SESSION, - "SESSION_USER": TokenType.SESSION_USER, - "SET": TokenType.SET, - "SETTINGS": TokenType.SETTINGS, - "SHOW": TokenType.SHOW, - "SIMILAR TO": TokenType.SIMILAR_TO, - "SOME": TokenType.SOME, - "SORT BY": TokenType.SORT_BY, - "START WITH": TokenType.START_WITH, - "STRAIGHT_JOIN": TokenType.STRAIGHT_JOIN, - "TABLE": TokenType.TABLE, - "TABLESAMPLE": TokenType.TABLE_SAMPLE, - "TEMP": TokenType.TEMPORARY, - "TEMPORARY": TokenType.TEMPORARY, - "THEN": TokenType.THEN, - "TRUE": TokenType.TRUE, - "TRUNCATE": TokenType.TRUNCATE, - "UNION": TokenType.UNION, - "UNKNOWN": TokenType.UNKNOWN, - "UNNEST": TokenType.UNNEST, - "UNPIVOT": TokenType.UNPIVOT, - "UPDATE": TokenType.UPDATE, - "USE": TokenType.USE, - "USING": TokenType.USING, - "UUID": TokenType.UUID, - "VALUES": TokenType.VALUES, - "VIEW": TokenType.VIEW, - "VOLATILE": TokenType.VOLATILE, - "WHEN": TokenType.WHEN, - "WHERE": TokenType.WHERE, - "WINDOW": TokenType.WINDOW, - "WITH": TokenType.WITH, - "APPLY": TokenType.APPLY, - "ARRAY": TokenType.ARRAY, - "BIT": TokenType.BIT, - "BOOL": TokenType.BOOLEAN, - "BOOLEAN": TokenType.BOOLEAN, - "BYTE": TokenType.TINYINT, - "MEDIUMINT": TokenType.MEDIUMINT, - "INT1": TokenType.TINYINT, - "TINYINT": TokenType.TINYINT, - "INT16": TokenType.SMALLINT, - "SHORT": TokenType.SMALLINT, - "SMALLINT": TokenType.SMALLINT, - "HUGEINT": TokenType.INT128, - "UHUGEINT": TokenType.UINT128, - "INT2": TokenType.SMALLINT, - "INTEGER": TokenType.INT, - "INT": TokenType.INT, - "INT4": TokenType.INT, - "INT32": TokenType.INT, - "INT64": TokenType.BIGINT, - "INT128": TokenType.INT128, - "INT256": TokenType.INT256, - "LONG": TokenType.BIGINT, - "BIGINT": TokenType.BIGINT, - "INT8": TokenType.TINYINT, - "UINT": TokenType.UINT, - "UINT128": TokenType.UINT128, - "UINT256": TokenType.UINT256, - "DEC": TokenType.DECIMAL, - "DECIMAL": TokenType.DECIMAL, - "DECIMAL32": TokenType.DECIMAL32, - "DECIMAL64": TokenType.DECIMAL64, - "DECIMAL128": TokenType.DECIMAL128, - "DECIMAL256": TokenType.DECIMAL256, - "DECFLOAT": TokenType.DECFLOAT, - "BIGDECIMAL": TokenType.BIGDECIMAL, - "BIGNUMERIC": TokenType.BIGDECIMAL, - "BIGNUM": TokenType.BIGNUM, - "LIST": TokenType.LIST, - "MAP": TokenType.MAP, - "NULLABLE": TokenType.NULLABLE, - "NUMBER": TokenType.DECIMAL, - "NUMERIC": TokenType.DECIMAL, - "FIXED": TokenType.DECIMAL, - "REAL": TokenType.FLOAT, - "FLOAT": TokenType.FLOAT, - "FLOAT4": TokenType.FLOAT, - "FLOAT8": TokenType.DOUBLE, - "DOUBLE": TokenType.DOUBLE, - "DOUBLE PRECISION": TokenType.DOUBLE, - "JSON": TokenType.JSON, - "JSONB": TokenType.JSONB, - "CHAR": TokenType.CHAR, - "CHARACTER": TokenType.CHAR, - "CHAR VARYING": TokenType.VARCHAR, - "CHARACTER VARYING": TokenType.VARCHAR, - "NCHAR": TokenType.NCHAR, - "VARCHAR": TokenType.VARCHAR, - "VARCHAR2": TokenType.VARCHAR, - "NVARCHAR": TokenType.NVARCHAR, - "NVARCHAR2": TokenType.NVARCHAR, - "BPCHAR": TokenType.BPCHAR, - "STR": TokenType.TEXT, - "STRING": TokenType.TEXT, - "TEXT": TokenType.TEXT, - "LONGTEXT": TokenType.LONGTEXT, - "MEDIUMTEXT": TokenType.MEDIUMTEXT, - "TINYTEXT": TokenType.TINYTEXT, - "CLOB": TokenType.TEXT, - "LONGVARCHAR": TokenType.TEXT, - "BINARY": TokenType.BINARY, - "BLOB": TokenType.VARBINARY, - "LONGBLOB": TokenType.LONGBLOB, - "MEDIUMBLOB": TokenType.MEDIUMBLOB, - "TINYBLOB": TokenType.TINYBLOB, - "BYTEA": TokenType.VARBINARY, - "VARBINARY": TokenType.VARBINARY, - "TIME": TokenType.TIME, - "TIMETZ": TokenType.TIMETZ, - "TIME_NS": TokenType.TIME_NS, - "TIMESTAMP": TokenType.TIMESTAMP, - "TIMESTAMPTZ": TokenType.TIMESTAMPTZ, - "TIMESTAMPLTZ": TokenType.TIMESTAMPLTZ, - "TIMESTAMP_LTZ": TokenType.TIMESTAMPLTZ, - "TIMESTAMPNTZ": TokenType.TIMESTAMPNTZ, - "TIMESTAMP_NTZ": TokenType.TIMESTAMPNTZ, - "DATE": TokenType.DATE, - "DATETIME": TokenType.DATETIME, - "INT4RANGE": TokenType.INT4RANGE, - "INT4MULTIRANGE": TokenType.INT4MULTIRANGE, - "INT8RANGE": TokenType.INT8RANGE, - "INT8MULTIRANGE": TokenType.INT8MULTIRANGE, - "NUMRANGE": TokenType.NUMRANGE, - "NUMMULTIRANGE": TokenType.NUMMULTIRANGE, - "TSRANGE": TokenType.TSRANGE, - "TSMULTIRANGE": TokenType.TSMULTIRANGE, - "TSTZRANGE": TokenType.TSTZRANGE, - "TSTZMULTIRANGE": TokenType.TSTZMULTIRANGE, - "DATERANGE": TokenType.DATERANGE, - "DATEMULTIRANGE": TokenType.DATEMULTIRANGE, - "UNIQUE": TokenType.UNIQUE, - "VECTOR": TokenType.VECTOR, - "STRUCT": TokenType.STRUCT, - "SEQUENCE": TokenType.SEQUENCE, - "VARIANT": TokenType.VARIANT, - "ALTER": TokenType.ALTER, - "ANALYZE": TokenType.ANALYZE, - "CALL": TokenType.COMMAND, - "COMMENT": TokenType.COMMENT, - "EXPLAIN": TokenType.COMMAND, - "GRANT": TokenType.GRANT, - "REVOKE": TokenType.REVOKE, - "OPTIMIZE": TokenType.COMMAND, - "PREPARE": TokenType.COMMAND, - "VACUUM": TokenType.COMMAND, - "USER-DEFINED": TokenType.USERDEFINED, - "FOR VERSION": TokenType.VERSION_SNAPSHOT, - "FOR TIMESTAMP": TokenType.TIMESTAMP_SNAPSHOT, - } - - WHITE_SPACE: t.Dict[t.Optional[str], TokenType] = { - " ": TokenType.SPACE, - "\t": TokenType.SPACE, - "\n": TokenType.BREAK, - "\r": TokenType.BREAK, - } - - COMMANDS = { - TokenType.COMMAND, - TokenType.EXECUTE, - TokenType.FETCH, - TokenType.SHOW, - TokenType.RENAME, - } - - COMMAND_PREFIX_TOKENS = {TokenType.SEMICOLON, TokenType.BEGIN} - - # Handle numeric literals like in hive (3L = BIGINT) - NUMERIC_LITERALS: t.Dict[str, str] = {} - - COMMENTS = ["--", ("/*", "*/")] - - __slots__ = ( - "sql", - "size", - "tokens", - "dialect", - "use_rs_tokenizer", - "_start", - "_current", - "_line", - "_col", - "_comments", - "_char", - "_end", - "_peek", - "_prev_token_line", - "_rs_dialect_settings", - ) - - def __init__( - self, - dialect: DialectType = None, - use_rs_tokenizer: t.Optional[bool] = None, - **opts: t.Any, - ) -> None: - from bigframes_vendored.sqlglot.dialects import Dialect - - self.dialect = Dialect.get_or_raise(dialect) - - # initialize `use_rs_tokenizer`, and allow it to be overwritten per Tokenizer instance - self.use_rs_tokenizer = ( - use_rs_tokenizer if use_rs_tokenizer is not None else USE_RS_TOKENIZER - ) - - if self.use_rs_tokenizer: - self._rs_dialect_settings = RsTokenizerDialectSettings( - unescaped_sequences=self.dialect.UNESCAPED_SEQUENCES, - identifiers_can_start_with_digit=self.dialect.IDENTIFIERS_CAN_START_WITH_DIGIT, - numbers_can_be_underscore_separated=self.dialect.NUMBERS_CAN_BE_UNDERSCORE_SEPARATED, - ) - - self.reset() - - def reset(self) -> None: - self.sql = "" - self.size = 0 - self.tokens: t.List[Token] = [] - self._start = 0 - self._current = 0 - self._line = 1 - self._col = 0 - self._comments: t.List[str] = [] - - self._char = "" - self._end = False - self._peek = "" - self._prev_token_line = -1 - - def tokenize(self, sql: str) -> t.List[Token]: - """Returns a list of tokens corresponding to the SQL string `sql`.""" - if self.use_rs_tokenizer: - return self.tokenize_rs(sql) - - self.reset() - self.sql = sql - self.size = len(sql) - - try: - self._scan() - except Exception as e: - start = max(self._current - 50, 0) - end = min(self._current + 50, self.size - 1) - context = self.sql[start:end] - raise TokenError(f"Error tokenizing '{context}'") from e - - return self.tokens - - def _scan(self, until: t.Optional[t.Callable] = None) -> None: - while self.size and not self._end: - current = self._current - - # Skip spaces here rather than iteratively calling advance() for performance reasons - while current < self.size: - char = self.sql[current] - - if char.isspace() and (char == " " or char == "\t"): - current += 1 - else: - break - - offset = current - self._current if current > self._current else 1 - - self._start = current - self._advance(offset) - - if not self._char.isspace(): - if self._char.isdigit(): - self._scan_number() - elif self._char in self._IDENTIFIERS: - self._scan_identifier(self._IDENTIFIERS[self._char]) - else: - self._scan_keywords() - - if until and until(): - break - - if self.tokens and self._comments: - self.tokens[-1].comments.extend(self._comments) - - def _chars(self, size: int) -> str: - if size == 1: - return self._char - - start = self._current - 1 - end = start + size - - return self.sql[start:end] if end <= self.size else "" - - def _advance(self, i: int = 1, alnum: bool = False) -> None: - if self.WHITE_SPACE.get(self._char) is TokenType.BREAK: - # Ensures we don't count an extra line if we get a \r\n line break sequence - if not (self._char == "\r" and self._peek == "\n"): - self._col = i - self._line += 1 - else: - self._col += i - - self._current += i - self._end = self._current >= self.size - self._char = self.sql[self._current - 1] - self._peek = "" if self._end else self.sql[self._current] - - if alnum and self._char.isalnum(): - # Here we use local variables instead of attributes for better performance - _col = self._col - _current = self._current - _end = self._end - _peek = self._peek - - while _peek.isalnum(): - _col += 1 - _current += 1 - _end = _current >= self.size - _peek = "" if _end else self.sql[_current] - - self._col = _col - self._current = _current - self._end = _end - self._peek = _peek - self._char = self.sql[_current - 1] - - @property - def _text(self) -> str: - return self.sql[self._start : self._current] - - def _add(self, token_type: TokenType, text: t.Optional[str] = None) -> None: - self._prev_token_line = self._line - - if self._comments and token_type == TokenType.SEMICOLON and self.tokens: - self.tokens[-1].comments.extend(self._comments) - self._comments = [] - - self.tokens.append( - Token( - token_type, - text=self._text if text is None else text, - line=self._line, - col=self._col, - start=self._start, - end=self._current - 1, - comments=self._comments, - ) - ) - self._comments = [] - - # If we have either a semicolon or a begin token before the command's token, we'll parse - # whatever follows the command's token as a string - if ( - token_type in self.COMMANDS - and self._peek != ";" - and ( - len(self.tokens) == 1 - or self.tokens[-2].token_type in self.COMMAND_PREFIX_TOKENS - ) - ): - start = self._current - tokens = len(self.tokens) - self._scan(lambda: self._peek == ";") - self.tokens = self.tokens[:tokens] - text = self.sql[start : self._current].strip() - if text: - self._add(TokenType.STRING, text) - - def _scan_keywords(self) -> None: - size = 0 - word = None - chars = self._text - char = chars - prev_space = False - skip = False - trie = self._KEYWORD_TRIE - single_token = char in self.SINGLE_TOKENS - - while chars: - if skip: - result = TrieResult.PREFIX - else: - result, trie = in_trie(trie, char.upper()) - - if result == TrieResult.FAILED: - break - if result == TrieResult.EXISTS: - word = chars - - end = self._current + size - size += 1 - - if end < self.size: - char = self.sql[end] - single_token = single_token or char in self.SINGLE_TOKENS - is_space = char.isspace() - - if not is_space or not prev_space: - if is_space: - char = " " - chars += char - prev_space = is_space - skip = False - else: - skip = True - else: - char = "" - break - - if word: - if self._scan_string(word): - return - if self._scan_comment(word): - return - if prev_space or single_token or not char: - self._advance(size - 1) - word = word.upper() - self._add(self.KEYWORDS[word], text=word) - return - - if self._char in self.SINGLE_TOKENS: - self._add(self.SINGLE_TOKENS[self._char], text=self._char) - return - - self._scan_var() - - def _scan_comment(self, comment_start: str) -> bool: - if comment_start not in self._COMMENTS: - return False - - comment_start_line = self._line - comment_start_size = len(comment_start) - comment_end = self._COMMENTS[comment_start] - - if comment_end: - # Skip the comment's start delimiter - self._advance(comment_start_size) - - comment_count = 1 - comment_end_size = len(comment_end) - - while not self._end: - if self._chars(comment_end_size) == comment_end: - comment_count -= 1 - if not comment_count: - break - - self._advance(alnum=True) - - # Nested comments are allowed by some dialects, e.g. databricks, duckdb, postgres - if ( - self.NESTED_COMMENTS - and not self._end - and self._chars(comment_end_size) == comment_start - ): - self._advance(comment_start_size) - comment_count += 1 - - self._comments.append( - self._text[comment_start_size : -comment_end_size + 1] - ) - self._advance(comment_end_size - 1) - else: - while ( - not self._end - and self.WHITE_SPACE.get(self._peek) is not TokenType.BREAK - ): - self._advance(alnum=True) - self._comments.append(self._text[comment_start_size:]) - - if ( - comment_start == self.HINT_START - and self.tokens - and self.tokens[-1].token_type in self.TOKENS_PRECEDING_HINT - ): - self._add(TokenType.HINT) - - # Leading comment is attached to the succeeding token, whilst trailing comment to the preceding. - # Multiple consecutive comments are preserved by appending them to the current comments list. - if comment_start_line == self._prev_token_line: - self.tokens[-1].comments.extend(self._comments) - self._comments = [] - self._prev_token_line = self._line - - return True - - def _scan_number(self) -> None: - if self._char == "0": - peek = self._peek.upper() - if peek == "B": - return ( - self._scan_bits() - if self.BIT_STRINGS - else self._add(TokenType.NUMBER) - ) - elif peek == "X": - return ( - self._scan_hex() - if self.HEX_STRINGS - else self._add(TokenType.NUMBER) - ) - - decimal = False - scientific = 0 - - while True: - if self._peek.isdigit(): - self._advance() - elif self._peek == "." and not decimal: - if self.tokens and self.tokens[-1].token_type == TokenType.PARAMETER: - return self._add(TokenType.NUMBER) - decimal = True - self._advance() - elif self._peek in ("-", "+") and scientific == 1: - # Only consume +/- if followed by a digit - if ( - self._current + 1 < self.size - and self.sql[self._current + 1].isdigit() - ): - scientific += 1 - self._advance() - else: - return self._add(TokenType.NUMBER) - elif self._peek.upper() == "E" and not scientific: - scientific += 1 - self._advance() - elif self._peek == "_" and self.dialect.NUMBERS_CAN_BE_UNDERSCORE_SEPARATED: - self._advance() - elif self._peek.isidentifier(): - number_text = self._text - literal = "" - - while self._peek.strip() and self._peek not in self.SINGLE_TOKENS: - literal += self._peek - self._advance() - - token_type = self.KEYWORDS.get( - self.NUMERIC_LITERALS.get(literal.upper(), "") - ) - - if token_type: - self._add(TokenType.NUMBER, number_text) - self._add(TokenType.DCOLON, "::") - return self._add(token_type, literal) - elif self.dialect.IDENTIFIERS_CAN_START_WITH_DIGIT: - return self._add(TokenType.VAR) - - self._advance(-len(literal)) - return self._add(TokenType.NUMBER, number_text) - else: - return self._add(TokenType.NUMBER) - - def _scan_bits(self) -> None: - self._advance() - value = self._extract_value() - try: - # If `value` can't be converted to a binary, fallback to tokenizing it as an identifier - int(value, 2) - self._add(TokenType.BIT_STRING, value[2:]) # Drop the 0b - except ValueError: - self._add(TokenType.IDENTIFIER) - - def _scan_hex(self) -> None: - self._advance() - value = self._extract_value() - try: - # If `value` can't be converted to a hex, fallback to tokenizing it as an identifier - int(value, 16) - self._add(TokenType.HEX_STRING, value[2:]) # Drop the 0x - except ValueError: - self._add(TokenType.IDENTIFIER) - - def _extract_value(self) -> str: - while True: - char = self._peek.strip() - if char and char not in self.SINGLE_TOKENS: - self._advance(alnum=True) - else: - break - - return self._text - - def _scan_string(self, start: str) -> bool: - base = None - token_type = TokenType.STRING - - if start in self._QUOTES: - end = self._QUOTES[start] - elif start in self._FORMAT_STRINGS: - end, token_type = self._FORMAT_STRINGS[start] - - if token_type == TokenType.HEX_STRING: - base = 16 - elif token_type == TokenType.BIT_STRING: - base = 2 - elif token_type == TokenType.HEREDOC_STRING: - self._advance() - - if self._char == end: - tag = "" - else: - tag = self._extract_string( - end, - raw_string=True, - raise_unmatched=not self.HEREDOC_TAG_IS_IDENTIFIER, - ) - - if ( - tag - and self.HEREDOC_TAG_IS_IDENTIFIER - and (self._end or tag.isdigit() or any(c.isspace() for c in tag)) - ): - if not self._end: - self._advance(-1) - - self._advance(-len(tag)) - self._add(self.HEREDOC_STRING_ALTERNATIVE) - return True - - end = f"{start}{tag}{end}" - else: - return False - - self._advance(len(start)) - text = self._extract_string(end, raw_string=token_type == TokenType.RAW_STRING) - - if base and text: - try: - int(text, base) - except Exception: - raise TokenError( - f"Numeric string contains invalid characters from {self._line}:{self._start}" - ) - - self._add(token_type, text) - return True - - def _scan_identifier(self, identifier_end: str) -> None: - self._advance() - text = self._extract_string( - identifier_end, escapes=self._IDENTIFIER_ESCAPES | {identifier_end} - ) - self._add(TokenType.IDENTIFIER, text) - - def _scan_var(self) -> None: - while True: - char = self._peek.strip() - if char and ( - char in self.VAR_SINGLE_TOKENS or char not in self.SINGLE_TOKENS - ): - self._advance(alnum=True) - else: - break - - self._add( - TokenType.VAR - if self.tokens and self.tokens[-1].token_type == TokenType.PARAMETER - else self.KEYWORDS.get(self._text.upper(), TokenType.VAR) - ) - - def _extract_string( - self, - delimiter: str, - escapes: t.Optional[t.Set[str]] = None, - raw_string: bool = False, - raise_unmatched: bool = True, - ) -> str: - text = "" - delim_size = len(delimiter) - escapes = self._STRING_ESCAPES if escapes is None else escapes - - while True: - if ( - not raw_string - and self.dialect.UNESCAPED_SEQUENCES - and self._peek - and self._char in self.STRING_ESCAPES - ): - unescaped_sequence = self.dialect.UNESCAPED_SEQUENCES.get( - self._char + self._peek - ) - if unescaped_sequence: - self._advance(2) - text += unescaped_sequence - continue - - is_valid_custom_escape = ( - self.ESCAPE_FOLLOW_CHARS - and self._char == "\\" - and self._peek not in self.ESCAPE_FOLLOW_CHARS - ) - - if ( - (self.STRING_ESCAPES_ALLOWED_IN_RAW_STRINGS or not raw_string) - and self._char in escapes - and ( - self._peek == delimiter - or self._peek in escapes - or is_valid_custom_escape - ) - and (self._char not in self._QUOTES or self._char == self._peek) - ): - if self._peek == delimiter: - text += self._peek - elif is_valid_custom_escape and self._char != self._peek: - text += self._peek - else: - text += self._char + self._peek - - if self._current + 1 < self.size: - self._advance(2) - else: - raise TokenError( - f"Missing {delimiter} from {self._line}:{self._current}" - ) - else: - if self._chars(delim_size) == delimiter: - if delim_size > 1: - self._advance(delim_size - 1) - break - - if self._end: - if not raise_unmatched: - return text + self._char - - raise TokenError( - f"Missing {delimiter} from {self._line}:{self._start}" - ) - - current = self._current - 1 - self._advance(alnum=True) - text += self.sql[current : self._current - 1] - - return text - - def tokenize_rs(self, sql: str) -> t.List[Token]: - if not self._RS_TOKENIZER: - raise SqlglotError("Rust tokenizer is not available") - - tokens, error_msg = self._RS_TOKENIZER.tokenize(sql, self._rs_dialect_settings) - for token in tokens: - token.token_type = _ALL_TOKEN_TYPES[token.token_type_index] - - # Setting this here so partial token lists can be inspected even if there is a failure - self.tokens = tokens - - if error_msg is not None: - raise TokenError(error_msg) - - return tokens diff --git a/third_party/bigframes_vendored/sqlglot/transforms.py b/third_party/bigframes_vendored/sqlglot/transforms.py deleted file mode 100644 index edb1a21d6cb..00000000000 --- a/third_party/bigframes_vendored/sqlglot/transforms.py +++ /dev/null @@ -1,1127 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/transforms.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import expressions as exp -from bigframes_vendored.sqlglot.errors import UnsupportedError -from bigframes_vendored.sqlglot.helper import find_new_name, name_sequence, seq_get - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot._typing import E - from bigframes_vendored.sqlglot.generator import Generator - - -def preprocess( - transforms: t.List[t.Callable[[exp.Expression], exp.Expression]], - generator: t.Optional[t.Callable[[Generator, exp.Expression], str]] = None, -) -> t.Callable[[Generator, exp.Expression], str]: - """ - Creates a new transform by chaining a sequence of transformations and converts the resulting - expression to SQL, using either the "_sql" method corresponding to the resulting expression, - or the appropriate `Generator.TRANSFORMS` function (when applicable -- see below). - - Args: - transforms: sequence of transform functions. These will be called in order. - - Returns: - Function that can be used as a generator transform. - """ - - def _to_sql(self, expression: exp.Expression) -> str: - expression_type = type(expression) - - try: - expression = transforms[0](expression) - for transform in transforms[1:]: - expression = transform(expression) - except UnsupportedError as unsupported_error: - self.unsupported(str(unsupported_error)) - - if generator: - return generator(self, expression) - - _sql_handler = getattr(self, expression.key + "_sql", None) - if _sql_handler: - return _sql_handler(expression) - - transforms_handler = self.TRANSFORMS.get(type(expression)) - if transforms_handler: - if expression_type is type(expression): - if isinstance(expression, exp.Func): - return self.function_fallback_sql(expression) - - # Ensures we don't enter an infinite loop. This can happen when the original expression - # has the same type as the final expression and there's no _sql method available for it, - # because then it'd re-enter _to_sql. - raise ValueError( - f"Expression type {expression.__class__.__name__} requires a _sql method in order to be transformed." - ) - - return transforms_handler(self, expression) - - raise ValueError( - f"Unsupported expression type {expression.__class__.__name__}." - ) - - return _to_sql - - -def unnest_generate_date_array_using_recursive_cte( - expression: exp.Expression, -) -> exp.Expression: - if isinstance(expression, exp.Select): - count = 0 - recursive_ctes = [] - - for unnest in expression.find_all(exp.Unnest): - if ( - not isinstance(unnest.parent, (exp.From, exp.Join)) - or len(unnest.expressions) != 1 - or not isinstance(unnest.expressions[0], exp.GenerateDateArray) - ): - continue - - generate_date_array = unnest.expressions[0] - start = generate_date_array.args.get("start") - end = generate_date_array.args.get("end") - step = generate_date_array.args.get("step") - - if not start or not end or not isinstance(step, exp.Interval): - continue - - alias = unnest.args.get("alias") - column_name = ( - alias.columns[0] if isinstance(alias, exp.TableAlias) else "date_value" - ) - - start = exp.cast(start, "date") - date_add = exp.func( - "date_add", - column_name, - exp.Literal.number(step.name), - step.args.get("unit"), - ) - cast_date_add = exp.cast(date_add, "date") - - cte_name = "_generated_dates" + (f"_{count}" if count else "") - - base_query = exp.select(start.as_(column_name)) - recursive_query = ( - exp.select(cast_date_add) - .from_(cte_name) - .where(cast_date_add <= exp.cast(end, "date")) - ) - cte_query = base_query.union(recursive_query, distinct=False) - - generate_dates_query = exp.select(column_name).from_(cte_name) - unnest.replace(generate_dates_query.subquery(cte_name)) - - recursive_ctes.append( - exp.alias_(exp.CTE(this=cte_query), cte_name, table=[column_name]) - ) - count += 1 - - if recursive_ctes: - with_expression = expression.args.get("with_") or exp.With() - with_expression.set("recursive", True) - with_expression.set( - "expressions", [*recursive_ctes, *with_expression.expressions] - ) - expression.set("with_", with_expression) - - return expression - - -def unnest_generate_series(expression: exp.Expression) -> exp.Expression: - """Unnests GENERATE_SERIES or SEQUENCE table references.""" - this = expression.this - if isinstance(expression, exp.Table) and isinstance(this, exp.GenerateSeries): - unnest = exp.Unnest(expressions=[this]) - if expression.alias: - return exp.alias_(unnest, alias="_u", table=[expression.alias], copy=False) - - return unnest - - return expression - - -def eliminate_distinct_on(expression: exp.Expression) -> exp.Expression: - """ - Convert SELECT DISTINCT ON statements to a subquery with a window function. - - This is useful for dialects that don't support SELECT DISTINCT ON but support window functions. - - Args: - expression: the expression that will be transformed. - - Returns: - The transformed expression. - """ - if ( - isinstance(expression, exp.Select) - and expression.args.get("distinct") - and isinstance(expression.args["distinct"].args.get("on"), exp.Tuple) - ): - row_number_window_alias = find_new_name(expression.named_selects, "_row_number") - - distinct_cols = expression.args["distinct"].pop().args["on"].expressions - window = exp.Window(this=exp.RowNumber(), partition_by=distinct_cols) - - order = expression.args.get("order") - if order: - window.set("order", order.pop()) - else: - window.set( - "order", exp.Order(expressions=[c.copy() for c in distinct_cols]) - ) - - window = exp.alias_(window, row_number_window_alias) - expression.select(window, copy=False) - - # We add aliases to the projections so that we can safely reference them in the outer query - new_selects = [] - taken_names = {row_number_window_alias} - for select in expression.selects[:-1]: - if select.is_star: - new_selects = [exp.Star()] - break - - if not isinstance(select, exp.Alias): - alias = find_new_name(taken_names, select.output_name or "_col") - quoted = ( - select.this.args.get("quoted") - if isinstance(select, exp.Column) - else None - ) - select = select.replace(exp.alias_(select, alias, quoted=quoted)) - - taken_names.add(select.output_name) - new_selects.append(select.args["alias"]) - - return ( - exp.select(*new_selects, copy=False) - .from_(expression.subquery("_t", copy=False), copy=False) - .where(exp.column(row_number_window_alias).eq(1), copy=False) - ) - - return expression - - -def eliminate_qualify(expression: exp.Expression) -> exp.Expression: - """ - Convert SELECT statements that contain the QUALIFY clause into subqueries, filtered equivalently. - - The idea behind this transformation can be seen in Snowflake's documentation for QUALIFY: - https://docs.snowflake.com/en/sql-reference/constructs/qualify - - Some dialects don't support window functions in the WHERE clause, so we need to include them as - projections in the subquery, in order to refer to them in the outer filter using aliases. Also, - if a column is referenced in the QUALIFY clause but is not selected, we need to include it too, - otherwise we won't be able to refer to it in the outer query's WHERE clause. Finally, if a - newly aliased projection is referenced in the QUALIFY clause, it will be replaced by the - corresponding expression to avoid creating invalid column references. - """ - if isinstance(expression, exp.Select) and expression.args.get("qualify"): - taken = set(expression.named_selects) - for select in expression.selects: - if not select.alias_or_name: - alias = find_new_name(taken, "_c") - select.replace(exp.alias_(select, alias)) - taken.add(alias) - - def _select_alias_or_name(select: exp.Expression) -> str | exp.Column: - alias_or_name = select.alias_or_name - identifier = select.args.get("alias") or select.this - if isinstance(identifier, exp.Identifier): - return exp.column(alias_or_name, quoted=identifier.args.get("quoted")) - return alias_or_name - - outer_selects = exp.select( - *list(map(_select_alias_or_name, expression.selects)) - ) - qualify_filters = expression.args["qualify"].pop().this - expression_by_alias = { - select.alias: select.this - for select in expression.selects - if isinstance(select, exp.Alias) - } - - select_candidates = ( - exp.Window if expression.is_star else (exp.Window, exp.Column) - ) - for select_candidate in list(qualify_filters.find_all(select_candidates)): - if isinstance(select_candidate, exp.Window): - if expression_by_alias: - for column in select_candidate.find_all(exp.Column): - expr = expression_by_alias.get(column.name) - if expr: - column.replace(expr) - - alias = find_new_name(expression.named_selects, "_w") - expression.select(exp.alias_(select_candidate, alias), copy=False) - column = exp.column(alias) - - if isinstance(select_candidate.parent, exp.Qualify): - qualify_filters = column - else: - select_candidate.replace(column) - elif select_candidate.name not in expression.named_selects: - expression.select(select_candidate.copy(), copy=False) - - return outer_selects.from_( - expression.subquery(alias="_t", copy=False), copy=False - ).where(qualify_filters, copy=False) - - return expression - - -def remove_precision_parameterized_types(expression: exp.Expression) -> exp.Expression: - """ - Some dialects only allow the precision for parameterized types to be defined in the DDL and not in - other expressions. This transforms removes the precision from parameterized types in expressions. - """ - for node in expression.find_all(exp.DataType): - node.set( - "expressions", - [e for e in node.expressions if not isinstance(e, exp.DataTypeParam)], - ) - - return expression - - -def unqualify_unnest(expression: exp.Expression) -> exp.Expression: - """Remove references to unnest table aliases, added by the optimizer's qualify_columns step.""" - from bigframes_vendored.sqlglot.optimizer.scope import find_all_in_scope - - if isinstance(expression, exp.Select): - unnest_aliases = { - unnest.alias - for unnest in find_all_in_scope(expression, exp.Unnest) - if isinstance(unnest.parent, (exp.From, exp.Join)) - } - if unnest_aliases: - for column in expression.find_all(exp.Column): - leftmost_part = column.parts[0] - if ( - leftmost_part.arg_key != "this" - and leftmost_part.this in unnest_aliases - ): - leftmost_part.pop() - - return expression - - -def unnest_to_explode( - expression: exp.Expression, - unnest_using_arrays_zip: bool = True, -) -> exp.Expression: - """Convert cross join unnest into lateral view explode.""" - - def _unnest_zip_exprs( - u: exp.Unnest, unnest_exprs: t.List[exp.Expression], has_multi_expr: bool - ) -> t.List[exp.Expression]: - if has_multi_expr: - if not unnest_using_arrays_zip: - raise UnsupportedError( - "Cannot transpile UNNEST with multiple input arrays" - ) - - # Use INLINE(ARRAYS_ZIP(...)) for multiple expressions - zip_exprs: t.List[exp.Expression] = [ - exp.Anonymous(this="ARRAYS_ZIP", expressions=unnest_exprs) - ] - u.set("expressions", zip_exprs) - return zip_exprs - return unnest_exprs - - def _udtf_type(u: exp.Unnest, has_multi_expr: bool) -> t.Type[exp.Func]: - if u.args.get("offset"): - return exp.Posexplode - return exp.Inline if has_multi_expr else exp.Explode - - if isinstance(expression, exp.Select): - from_ = expression.args.get("from_") - - if from_ and isinstance(from_.this, exp.Unnest): - unnest = from_.this - alias = unnest.args.get("alias") - exprs = unnest.expressions - has_multi_expr = len(exprs) > 1 - this, *_ = _unnest_zip_exprs(unnest, exprs, has_multi_expr) - - columns = alias.columns if alias else [] - offset = unnest.args.get("offset") - if offset: - columns.insert( - 0, - offset - if isinstance(offset, exp.Identifier) - else exp.to_identifier("pos"), - ) - - unnest.replace( - exp.Table( - this=_udtf_type(unnest, has_multi_expr)(this=this), - alias=exp.TableAlias(this=alias.this, columns=columns) - if alias - else None, - ) - ) - - joins = expression.args.get("joins") or [] - for join in list(joins): - join_expr = join.this - - is_lateral = isinstance(join_expr, exp.Lateral) - - unnest = join_expr.this if is_lateral else join_expr - - if isinstance(unnest, exp.Unnest): - if is_lateral: - alias = join_expr.args.get("alias") - else: - alias = unnest.args.get("alias") - exprs = unnest.expressions - # The number of unnest.expressions will be changed by _unnest_zip_exprs, we need to record it here - has_multi_expr = len(exprs) > 1 - exprs = _unnest_zip_exprs(unnest, exprs, has_multi_expr) - - joins.remove(join) - - alias_cols = alias.columns if alias else [] - - # # Handle UNNEST to LATERAL VIEW EXPLODE: Exception is raised when there are 0 or > 2 aliases - # Spark LATERAL VIEW EXPLODE requires single alias for array/struct and two for Map type column unlike unnest in trino/presto which can take an arbitrary amount. - # Refs: https://spark.apache.org/docs/latest/sql-ref-syntax-qry-select-lateral-view.html - - if not has_multi_expr and len(alias_cols) not in (1, 2): - raise UnsupportedError( - "CROSS JOIN UNNEST to LATERAL VIEW EXPLODE transformation requires explicit column aliases" - ) - - offset = unnest.args.get("offset") - if offset: - alias_cols.insert( - 0, - offset - if isinstance(offset, exp.Identifier) - else exp.to_identifier("pos"), - ) - - for e, column in zip(exprs, alias_cols): - expression.append( - "laterals", - exp.Lateral( - this=_udtf_type(unnest, has_multi_expr)(this=e), - view=True, - alias=exp.TableAlias( - this=alias.this, # type: ignore - columns=alias_cols, - ), - ), - ) - - return expression - - -def explode_projection_to_unnest( - index_offset: int = 0, -) -> t.Callable[[exp.Expression], exp.Expression]: - """Convert explode/posexplode projections into unnests.""" - - def _explode_projection_to_unnest(expression: exp.Expression) -> exp.Expression: - if isinstance(expression, exp.Select): - from bigframes_vendored.sqlglot.optimizer.scope import Scope - - taken_select_names = set(expression.named_selects) - taken_source_names = {name for name, _ in Scope(expression).references} - - def new_name(names: t.Set[str], name: str) -> str: - name = find_new_name(names, name) - names.add(name) - return name - - arrays: t.List[exp.Condition] = [] - series_alias = new_name(taken_select_names, "pos") - series = exp.alias_( - exp.Unnest( - expressions=[ - exp.GenerateSeries(start=exp.Literal.number(index_offset)) - ] - ), - new_name(taken_source_names, "_u"), - table=[series_alias], - ) - - # we use list here because expression.selects is mutated inside the loop - for select in list(expression.selects): - explode = select.find(exp.Explode) - - if explode: - pos_alias = "" - explode_alias = "" - - if isinstance(select, exp.Alias): - explode_alias = select.args["alias"] - alias = select - elif isinstance(select, exp.Aliases): - pos_alias = select.aliases[0] - explode_alias = select.aliases[1] - alias = select.replace(exp.alias_(select.this, "", copy=False)) - else: - alias = select.replace(exp.alias_(select, "")) - explode = alias.find(exp.Explode) - assert explode - - is_posexplode = isinstance(explode, exp.Posexplode) - explode_arg = explode.this - - if isinstance(explode, exp.ExplodeOuter): - bracket = explode_arg[0] - bracket.set("safe", True) - bracket.set("offset", True) - explode_arg = exp.func( - "IF", - exp.func( - "ARRAY_SIZE", - exp.func("COALESCE", explode_arg, exp.Array()), - ).eq(0), - exp.array(bracket, copy=False), - explode_arg, - ) - - # This ensures that we won't use [POS]EXPLODE's argument as a new selection - if isinstance(explode_arg, exp.Column): - taken_select_names.add(explode_arg.output_name) - - unnest_source_alias = new_name(taken_source_names, "_u") - - if not explode_alias: - explode_alias = new_name(taken_select_names, "col") - - if is_posexplode: - pos_alias = new_name(taken_select_names, "pos") - - if not pos_alias: - pos_alias = new_name(taken_select_names, "pos") - - alias.set("alias", exp.to_identifier(explode_alias)) - - series_table_alias = series.args["alias"].this - column = exp.If( - this=exp.column(series_alias, table=series_table_alias).eq( - exp.column(pos_alias, table=unnest_source_alias) - ), - true=exp.column(explode_alias, table=unnest_source_alias), - ) - - explode.replace(column) - - if is_posexplode: - expressions = expression.expressions - expressions.insert( - expressions.index(alias) + 1, - exp.If( - this=exp.column( - series_alias, table=series_table_alias - ).eq(exp.column(pos_alias, table=unnest_source_alias)), - true=exp.column(pos_alias, table=unnest_source_alias), - ).as_(pos_alias), - ) - expression.set("expressions", expressions) - - if not arrays: - if expression.args.get("from_"): - expression.join(series, copy=False, join_type="CROSS") - else: - expression.from_(series, copy=False) - - size: exp.Condition = exp.ArraySize(this=explode_arg.copy()) - arrays.append(size) - - # trino doesn't support left join unnest with on conditions - # if it did, this would be much simpler - expression.join( - exp.alias_( - exp.Unnest( - expressions=[explode_arg.copy()], - offset=exp.to_identifier(pos_alias), - ), - unnest_source_alias, - table=[explode_alias], - ), - join_type="CROSS", - copy=False, - ) - - if index_offset != 1: - size = size - 1 - - expression.where( - exp.column(series_alias, table=series_table_alias) - .eq(exp.column(pos_alias, table=unnest_source_alias)) - .or_( - ( - exp.column(series_alias, table=series_table_alias) - > size - ).and_( - exp.column(pos_alias, table=unnest_source_alias).eq( - size - ) - ) - ), - copy=False, - ) - - if arrays: - end: exp.Condition = exp.Greatest( - this=arrays[0], expressions=arrays[1:] - ) - - if index_offset != 1: - end = end - (1 - index_offset) - series.expressions[0].set("end", end) - - return expression - - return _explode_projection_to_unnest - - -def add_within_group_for_percentiles(expression: exp.Expression) -> exp.Expression: - """Transforms percentiles by adding a WITHIN GROUP clause to them.""" - if ( - isinstance(expression, exp.PERCENTILES) - and not isinstance(expression.parent, exp.WithinGroup) - and expression.expression - ): - column = expression.this.pop() - expression.set("this", expression.expression.pop()) - order = exp.Order(expressions=[exp.Ordered(this=column)]) - expression = exp.WithinGroup(this=expression, expression=order) - - return expression - - -def remove_within_group_for_percentiles(expression: exp.Expression) -> exp.Expression: - """Transforms percentiles by getting rid of their corresponding WITHIN GROUP clause.""" - if ( - isinstance(expression, exp.WithinGroup) - and isinstance(expression.this, exp.PERCENTILES) - and isinstance(expression.expression, exp.Order) - ): - quantile = expression.this.this - input_value = t.cast(exp.Ordered, expression.find(exp.Ordered)).this - return expression.replace( - exp.ApproxQuantile(this=input_value, quantile=quantile) - ) - - return expression - - -def add_recursive_cte_column_names(expression: exp.Expression) -> exp.Expression: - """Uses projection output names in recursive CTE definitions to define the CTEs' columns.""" - if isinstance(expression, exp.With) and expression.recursive: - next_name = name_sequence("_c_") - - for cte in expression.expressions: - if not cte.args["alias"].columns: - query = cte.this - if isinstance(query, exp.SetOperation): - query = query.this - - cte.args["alias"].set( - "columns", - [ - exp.to_identifier(s.alias_or_name or next_name()) - for s in query.selects - ], - ) - - return expression - - -def epoch_cast_to_ts(expression: exp.Expression) -> exp.Expression: - """Replace 'epoch' in casts by the equivalent date literal.""" - if ( - isinstance(expression, (exp.Cast, exp.TryCast)) - and expression.name.lower() == "epoch" - and expression.to.this in exp.DataType.TEMPORAL_TYPES - ): - expression.this.replace(exp.Literal.string("1970-01-01 00:00:00")) - - return expression - - -def eliminate_semi_and_anti_joins(expression: exp.Expression) -> exp.Expression: - """Convert SEMI and ANTI joins into equivalent forms that use EXIST instead.""" - if isinstance(expression, exp.Select): - for join in expression.args.get("joins") or []: - on = join.args.get("on") - if on and join.kind in ("SEMI", "ANTI"): - subquery = exp.select("1").from_(join.this).where(on) - exists = exp.Exists(this=subquery) - if join.kind == "ANTI": - exists = exists.not_(copy=False) - - join.pop() - expression.where(exists, copy=False) - - return expression - - -def eliminate_full_outer_join(expression: exp.Expression) -> exp.Expression: - """ - Converts a query with a FULL OUTER join to a union of identical queries that - use LEFT/RIGHT OUTER joins instead. This transformation currently only works - for queries that have a single FULL OUTER join. - """ - if isinstance(expression, exp.Select): - full_outer_joins = [ - (index, join) - for index, join in enumerate(expression.args.get("joins") or []) - if join.side == "FULL" - ] - - if len(full_outer_joins) == 1: - expression_copy = expression.copy() - expression.set("limit", None) - index, full_outer_join = full_outer_joins[0] - - tables = ( - expression.args["from_"].alias_or_name, - full_outer_join.alias_or_name, - ) - join_conditions = full_outer_join.args.get("on") or exp.and_( - *[ - exp.column(col, tables[0]).eq(exp.column(col, tables[1])) - for col in full_outer_join.args.get("using") - ] - ) - - full_outer_join.set("side", "left") - anti_join_clause = ( - exp.select("1").from_(expression.args["from_"]).where(join_conditions) - ) - expression_copy.args["joins"][index].set("side", "right") - expression_copy = expression_copy.where( - exp.Exists(this=anti_join_clause).not_() - ) - expression_copy.set("with_", None) # remove CTEs from RIGHT side - expression.set("order", None) # remove order by from LEFT side - - return exp.union(expression, expression_copy, copy=False, distinct=False) - - return expression - - -def move_ctes_to_top_level(expression: E) -> E: - """ - Some dialects (e.g. Hive, T-SQL, Spark prior to version 3) only allow CTEs to be - defined at the top-level, so for example queries like: - - SELECT * FROM (WITH t(c) AS (SELECT 1) SELECT * FROM t) AS subq - - are invalid in those dialects. This transformation can be used to ensure all CTEs are - moved to the top level so that the final SQL code is valid from a syntax standpoint. - - TODO: handle name clashes whilst moving CTEs (it can get quite tricky & costly). - """ - top_level_with = expression.args.get("with_") - for inner_with in expression.find_all(exp.With): - if inner_with.parent is expression: - continue - - if not top_level_with: - top_level_with = inner_with.pop() - expression.set("with_", top_level_with) - else: - if inner_with.recursive: - top_level_with.set("recursive", True) - - parent_cte = inner_with.find_ancestor(exp.CTE) - inner_with.pop() - - if parent_cte: - i = top_level_with.expressions.index(parent_cte) - top_level_with.expressions[i:i] = inner_with.expressions - top_level_with.set("expressions", top_level_with.expressions) - else: - top_level_with.set( - "expressions", top_level_with.expressions + inner_with.expressions - ) - - return expression - - -def ensure_bools(expression: exp.Expression) -> exp.Expression: - """Converts numeric values used in conditions into explicit boolean expressions.""" - from bigframes_vendored.sqlglot.optimizer.canonicalize import ensure_bools - - def _ensure_bool(node: exp.Expression) -> None: - if ( - node.is_number - or ( - not isinstance(node, exp.SubqueryPredicate) - and node.is_type(exp.DataType.Type.UNKNOWN, *exp.DataType.NUMERIC_TYPES) - ) - or (isinstance(node, exp.Column) and not node.type) - ): - node.replace(node.neq(0)) - - for node in expression.walk(): - ensure_bools(node, _ensure_bool) - - return expression - - -def unqualify_columns(expression: exp.Expression) -> exp.Expression: - for column in expression.find_all(exp.Column): - # We only wanna pop off the table, db, catalog args - for part in column.parts[:-1]: - part.pop() - - return expression - - -def remove_unique_constraints(expression: exp.Expression) -> exp.Expression: - assert isinstance(expression, exp.Create) - for constraint in expression.find_all(exp.UniqueColumnConstraint): - if constraint.parent: - constraint.parent.pop() - - return expression - - -def ctas_with_tmp_tables_to_create_tmp_view( - expression: exp.Expression, - tmp_storage_provider: t.Callable[[exp.Expression], exp.Expression] = lambda e: e, -) -> exp.Expression: - assert isinstance(expression, exp.Create) - properties = expression.args.get("properties") - temporary = any( - isinstance(prop, exp.TemporaryProperty) - for prop in (properties.expressions if properties else []) - ) - - # CTAS with temp tables map to CREATE TEMPORARY VIEW - if expression.kind == "TABLE" and temporary: - if expression.expression: - return exp.Create( - kind="TEMPORARY VIEW", - this=expression.this, - expression=expression.expression, - ) - return tmp_storage_provider(expression) - - return expression - - -def move_schema_columns_to_partitioned_by(expression: exp.Expression) -> exp.Expression: - """ - In Hive, the PARTITIONED BY property acts as an extension of a table's schema. When the - PARTITIONED BY value is an array of column names, they are transformed into a schema. - The corresponding columns are removed from the create statement. - """ - assert isinstance(expression, exp.Create) - has_schema = isinstance(expression.this, exp.Schema) - is_partitionable = expression.kind in {"TABLE", "VIEW"} - - if has_schema and is_partitionable: - prop = expression.find(exp.PartitionedByProperty) - if prop and prop.this and not isinstance(prop.this, exp.Schema): - schema = expression.this - columns = {v.name.upper() for v in prop.this.expressions} - partitions = [ - col for col in schema.expressions if col.name.upper() in columns - ] - schema.set( - "expressions", [e for e in schema.expressions if e not in partitions] - ) - prop.replace( - exp.PartitionedByProperty(this=exp.Schema(expressions=partitions)) - ) - expression.set("this", schema) - - return expression - - -def move_partitioned_by_to_schema_columns(expression: exp.Expression) -> exp.Expression: - """ - Spark 3 supports both "HIVEFORMAT" and "DATASOURCE" formats for CREATE TABLE. - - Currently, SQLGlot uses the DATASOURCE format for Spark 3. - """ - assert isinstance(expression, exp.Create) - prop = expression.find(exp.PartitionedByProperty) - if ( - prop - and prop.this - and isinstance(prop.this, exp.Schema) - and all(isinstance(e, exp.ColumnDef) and e.kind for e in prop.this.expressions) - ): - prop_this = exp.Tuple( - expressions=[exp.to_identifier(e.this) for e in prop.this.expressions] - ) - schema = expression.this - for e in prop.this.expressions: - schema.append("expressions", e) - prop.set("this", prop_this) - - return expression - - -def struct_kv_to_alias(expression: exp.Expression) -> exp.Expression: - """Converts struct arguments to aliases, e.g. STRUCT(1 AS y).""" - if isinstance(expression, exp.Struct): - expression.set( - "expressions", - [ - exp.alias_(e.expression, e.this) if isinstance(e, exp.PropertyEQ) else e - for e in expression.expressions - ], - ) - - return expression - - -def eliminate_join_marks(expression: exp.Expression) -> exp.Expression: - """https://docs.oracle.com/cd/B19306_01/server.102/b14200/queries006.htm#sthref3178 - - 1. You cannot specify the (+) operator in a query block that also contains FROM clause join syntax. - - 2. The (+) operator can appear only in the WHERE clause or, in the context of left-correlation (that is, when specifying the TABLE clause) in the FROM clause, and can be applied only to a column of a table or view. - - The (+) operator does not produce an outer join if you specify one table in the outer query and the other table in an inner query. - - You cannot use the (+) operator to outer-join a table to itself, although self joins are valid. - - The (+) operator can be applied only to a column, not to an arbitrary expression. However, an arbitrary expression can contain one or more columns marked with the (+) operator. - - A WHERE condition containing the (+) operator cannot be combined with another condition using the OR logical operator. - - A WHERE condition cannot use the IN comparison condition to compare a column marked with the (+) operator with an expression. - - A WHERE condition cannot compare any column marked with the (+) operator with a subquery. - - -- example with WHERE - SELECT d.department_name, sum(e.salary) as total_salary - FROM departments d, employees e - WHERE e.department_id(+) = d.department_id - group by department_name - - -- example of left correlation in select - SELECT d.department_name, ( - SELECT SUM(e.salary) - FROM employees e - WHERE e.department_id(+) = d.department_id) AS total_salary - FROM departments d; - - -- example of left correlation in from - SELECT d.department_name, t.total_salary - FROM departments d, ( - SELECT SUM(e.salary) AS total_salary - FROM employees e - WHERE e.department_id(+) = d.department_id - ) t - """ - - from collections import defaultdict - - from bigframes_vendored.sqlglot.optimizer.normalize import normalize, normalized - from bigframes_vendored.sqlglot.optimizer.scope import traverse_scope - - # we go in reverse to check the main query for left correlation - for scope in reversed(traverse_scope(expression)): - query = scope.expression - - where = query.args.get("where") - joins = query.args.get("joins", []) - - if not where or not any( - c.args.get("join_mark") for c in where.find_all(exp.Column) - ): - continue - - # knockout: we do not support left correlation (see point 2) - assert not scope.is_correlated_subquery, "Correlated queries are not supported" - - # make sure we have AND of ORs to have clear join terms - where = normalize(where.this) - assert normalized(where), "Cannot normalize JOIN predicates" - - joins_ons = defaultdict(list) # dict of {name: list of join AND conditions} - for cond in [where] if not isinstance(where, exp.And) else where.flatten(): - join_cols = [ - col for col in cond.find_all(exp.Column) if col.args.get("join_mark") - ] - - left_join_table = set(col.table for col in join_cols) - if not left_join_table: - continue - - assert not (len(left_join_table) > 1), ( - "Cannot combine JOIN predicates from different tables" - ) - - for col in join_cols: - col.set("join_mark", False) - - joins_ons[left_join_table.pop()].append(cond) - - old_joins = {join.alias_or_name: join for join in joins} - new_joins = {} - query_from = query.args["from_"] - - for table, predicates in joins_ons.items(): - join_what = old_joins.get(table, query_from).this.copy() - new_joins[join_what.alias_or_name] = exp.Join( - this=join_what, on=exp.and_(*predicates), kind="LEFT" - ) - - for p in predicates: - while isinstance(p.parent, exp.Paren): - p.parent.replace(p) - - parent = p.parent - p.pop() - if isinstance(parent, exp.Binary): - parent.replace(parent.right if parent.left is None else parent.left) - elif isinstance(parent, exp.Where): - parent.pop() - - if query_from.alias_or_name in new_joins: - only_old_joins = old_joins.keys() - new_joins.keys() - assert len(only_old_joins) >= 1, ( - "Cannot determine which table to use in the new FROM clause" - ) - - new_from_name = list(only_old_joins)[0] - query.set("from_", exp.From(this=old_joins[new_from_name].this)) - - if new_joins: - for n, j in old_joins.items(): # preserve any other joins - if n not in new_joins and n != query.args["from_"].name: - if not j.kind: - j.set("kind", "CROSS") - new_joins[n] = j - query.set("joins", list(new_joins.values())) - - return expression - - -def any_to_exists(expression: exp.Expression) -> exp.Expression: - """ - Transform ANY operator to Spark's EXISTS - - For example, - - Postgres: SELECT * FROM tbl WHERE 5 > ANY(tbl.col) - - Spark: SELECT * FROM tbl WHERE EXISTS(tbl.col, x -> x < 5) - - Both ANY and EXISTS accept queries but currently only array expressions are supported for this - transformation - """ - if isinstance(expression, exp.Select): - for any_expr in expression.find_all(exp.Any): - this = any_expr.this - if isinstance(this, exp.Query) or isinstance( - any_expr.parent, (exp.Like, exp.ILike) - ): - continue - - binop = any_expr.parent - if isinstance(binop, exp.Binary): - lambda_arg = exp.to_identifier("x") - any_expr.replace(lambda_arg) - lambda_expr = exp.Lambda(this=binop.copy(), expressions=[lambda_arg]) - binop.replace(exp.Exists(this=this.unnest(), expression=lambda_expr)) - - return expression - - -def eliminate_window_clause(expression: exp.Expression) -> exp.Expression: - """Eliminates the `WINDOW` query clause by inling each named window.""" - if isinstance(expression, exp.Select) and expression.args.get("windows"): - from bigframes_vendored.sqlglot.optimizer.scope import find_all_in_scope - - windows = expression.args["windows"] - expression.set("windows", None) - - window_expression: t.Dict[str, exp.Expression] = {} - - def _inline_inherited_window(window: exp.Expression) -> None: - inherited_window = window_expression.get(window.alias.lower()) - if not inherited_window: - return - - window.set("alias", None) - for key in ("partition_by", "order", "spec"): - arg = inherited_window.args.get(key) - if arg: - window.set(key, arg.copy()) - - for window in windows: - _inline_inherited_window(window) - window_expression[window.name.lower()] = window - - for window in find_all_in_scope(expression, exp.Window): - _inline_inherited_window(window) - - return expression - - -def inherit_struct_field_names(expression: exp.Expression) -> exp.Expression: - """ - Inherit field names from the first struct in an array. - - BigQuery supports implicitly inheriting names from the first STRUCT in an array: - - Example: - ARRAY[ - STRUCT('Alice' AS name, 85 AS score), -- defines names - STRUCT('Bob', 92), -- inherits names - STRUCT('Diana', 95) -- inherits names - ] - - This transformation makes the field names explicit on all structs by adding - PropertyEQ nodes, in order to facilitate transpilation to other dialects. - - Args: - expression: The expression tree to transform - - Returns: - The modified expression with field names inherited in all structs - """ - if ( - isinstance(expression, exp.Array) - and expression.args.get("struct_name_inheritance") - and isinstance(first_item := seq_get(expression.expressions, 0), exp.Struct) - and all(isinstance(fld, exp.PropertyEQ) for fld in first_item.expressions) - ): - field_names = [fld.this for fld in first_item.expressions] - - # Apply field names to subsequent structs that don't have them - for struct in expression.expressions[1:]: - if not isinstance(struct, exp.Struct) or len(struct.expressions) != len( - field_names - ): - continue - - # Convert unnamed expressions to PropertyEQ with inherited names - new_expressions = [] - for i, expr in enumerate(struct.expressions): - if not isinstance(expr, exp.PropertyEQ): - # Create PropertyEQ: field_name := value - new_expressions.append( - exp.PropertyEQ( - this=exp.Identifier(this=field_names[i].copy()), - expression=expr, - ) - ) - else: - new_expressions.append(expr) - - struct.set("expressions", new_expressions) - - return expression diff --git a/third_party/bigframes_vendored/sqlglot/trie.py b/third_party/bigframes_vendored/sqlglot/trie.py deleted file mode 100644 index 1475ea58774..00000000000 --- a/third_party/bigframes_vendored/sqlglot/trie.py +++ /dev/null @@ -1,83 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/trie.py - -import typing as t -from enum import Enum, auto - -key = t.Sequence[t.Hashable] - - -class TrieResult(Enum): - FAILED = auto() - PREFIX = auto() - EXISTS = auto() - - -def new_trie(keywords: t.Iterable[key], trie: t.Optional[t.Dict] = None) -> t.Dict: - """ - Creates a new trie out of a collection of keywords. - - The trie is represented as a sequence of nested dictionaries keyed by either single - character strings, or by 0, which is used to designate that a keyword is in the trie. - - Example: - >>> new_trie(["bla", "foo", "blab"]) - {'b': {'l': {'a': {0: True, 'b': {0: True}}}}, 'f': {'o': {'o': {0: True}}}} - - Args: - keywords: the keywords to create the trie from. - trie: a trie to mutate instead of creating a new one - - Returns: - The trie corresponding to `keywords`. - """ - trie = {} if trie is None else trie - - for key in keywords: - current = trie - for char in key: - current = current.setdefault(char, {}) - - current[0] = True - - return trie - - -def in_trie(trie: t.Dict, key: key) -> t.Tuple[TrieResult, t.Dict]: - """ - Checks whether a key is in a trie. - - Examples: - >>> in_trie(new_trie(["cat"]), "bob") - (, {'c': {'a': {'t': {0: True}}}}) - - >>> in_trie(new_trie(["cat"]), "ca") - (, {'t': {0: True}}) - - >>> in_trie(new_trie(["cat"]), "cat") - (, {0: True}) - - Args: - trie: The trie to be searched. - key: The target key. - - Returns: - A pair `(value, subtrie)`, where `subtrie` is the sub-trie we get at the point - where the search stops, and `value` is a TrieResult value that can be one of: - - - TrieResult.FAILED: the search was unsuccessful - - TrieResult.PREFIX: `value` is a prefix of a keyword in `trie` - - TrieResult.EXISTS: `key` exists in `trie` - """ - if not key: - return (TrieResult.FAILED, trie) - - current = trie - for char in key: - if char not in current: - return (TrieResult.FAILED, current) - current = current[char] - - if 0 in current: - return (TrieResult.EXISTS, current) - - return (TrieResult.PREFIX, current) diff --git a/third_party/bigframes_vendored/sqlglot/typing/__init__.py b/third_party/bigframes_vendored/sqlglot/typing/__init__.py deleted file mode 100644 index 0e666836196..00000000000 --- a/third_party/bigframes_vendored/sqlglot/typing/__init__.py +++ /dev/null @@ -1,360 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/typing/__init__.py - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.helper import subclasses - -ExpressionMetadataType = t.Dict[type[exp.Expression], t.Dict[str, t.Any]] - -TIMESTAMP_EXPRESSIONS = { - exp.CurrentTimestamp, - exp.StrToTime, - exp.TimeStrToTime, - exp.TimestampAdd, - exp.TimestampSub, - exp.UnixToTime, -} - -EXPRESSION_METADATA: ExpressionMetadataType = { - **{ - expr_type: {"annotator": lambda self, e: self._annotate_binary(e)} - for expr_type in subclasses(exp.__name__, exp.Binary) - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_unary(e)} - for expr_type in subclasses(exp.__name__, (exp.Unary, exp.Alias)) - }, - **{ - expr_type: {"returns": exp.DataType.Type.BIGINT} - for expr_type in { - exp.ApproxDistinct, - exp.ArraySize, - exp.CountIf, - exp.Int64, - exp.Length, - exp.UnixDate, - exp.UnixSeconds, - exp.UnixMicros, - exp.UnixMillis, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.BINARY} - for expr_type in { - exp.FromBase32, - exp.FromBase64, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.BOOLEAN} - for expr_type in { - exp.All, - exp.Any, - exp.Between, - exp.Boolean, - exp.Contains, - exp.EndsWith, - exp.Exists, - exp.In, - exp.LogicalAnd, - exp.LogicalOr, - exp.RegexpLike, - exp.StartsWith, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.DATE} - for expr_type in { - exp.CurrentDate, - exp.Date, - exp.DateFromParts, - exp.DateStrToDate, - exp.DiToDate, - exp.LastDay, - exp.StrToDate, - exp.TimeStrToDate, - exp.TsOrDsToDate, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.DATETIME} - for expr_type in { - exp.CurrentDatetime, - exp.Datetime, - exp.DatetimeAdd, - exp.DatetimeSub, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.DOUBLE} - for expr_type in { - exp.ApproxQuantile, - exp.Avg, - exp.Exp, - exp.Ln, - exp.Log, - exp.Pi, - exp.Pow, - exp.Quantile, - exp.Radians, - exp.Round, - exp.SafeDivide, - exp.Sqrt, - exp.Stddev, - exp.StddevPop, - exp.StddevSamp, - exp.ToDouble, - exp.Variance, - exp.VariancePop, - exp.Skewness, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.INT} - for expr_type in { - exp.Ascii, - exp.Ceil, - exp.DatetimeDiff, - exp.TimestampDiff, - exp.TimeDiff, - exp.Unicode, - exp.DateToDi, - exp.Levenshtein, - exp.Sign, - exp.StrPosition, - exp.TsOrDiToDi, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.INTERVAL} - for expr_type in { - exp.Interval, - exp.JustifyDays, - exp.JustifyHours, - exp.JustifyInterval, - exp.MakeInterval, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.JSON} - for expr_type in { - exp.ParseJSON, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIME} - for expr_type in { - exp.CurrentTime, - exp.Time, - exp.TimeAdd, - exp.TimeSub, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIMESTAMPLTZ} - for expr_type in { - exp.TimestampLtzFromParts, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIMESTAMPTZ} - for expr_type in { - exp.CurrentTimestampLTZ, - exp.TimestampTzFromParts, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIMESTAMP} - for expr_type in TIMESTAMP_EXPRESSIONS - }, - **{ - expr_type: {"returns": exp.DataType.Type.TINYINT} - for expr_type in { - exp.Day, - exp.DayOfMonth, - exp.DayOfWeek, - exp.DayOfWeekIso, - exp.DayOfYear, - exp.Month, - exp.Quarter, - exp.Week, - exp.WeekOfYear, - exp.Year, - exp.YearOfWeek, - exp.YearOfWeekIso, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.VARCHAR} - for expr_type in { - exp.ArrayToString, - exp.Concat, - exp.ConcatWs, - exp.Chr, - exp.DateToDateStr, - exp.DPipe, - exp.GroupConcat, - exp.Initcap, - exp.Lower, - exp.Substring, - exp.String, - exp.TimeToStr, - exp.TimeToTimeStr, - exp.Trim, - exp.ToBase32, - exp.ToBase64, - exp.TsOrDsToDateStr, - exp.UnixToStr, - exp.UnixToTimeStr, - exp.Upper, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_by_args(e, "this")} - for expr_type in { - exp.Abs, - exp.AnyValue, - exp.ArrayConcatAgg, - exp.ArrayReverse, - exp.ArraySlice, - exp.Filter, - exp.HavingMax, - exp.LastValue, - exp.Limit, - exp.Order, - exp.SortArray, - exp.Window, - } - }, - **{ - expr_type: { - "annotator": lambda self, e: self._annotate_by_args( - e, "this", "expressions" - ) - } - for expr_type in { - exp.ArrayConcat, - exp.Coalesce, - exp.Greatest, - exp.Least, - exp.Max, - exp.Min, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_by_array_element(e)} - for expr_type in { - exp.ArrayFirst, - exp.ArrayLast, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.UNKNOWN} - for expr_type in { - exp.Anonymous, - exp.Slice, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_timeunit(e)} - for expr_type in { - exp.DateAdd, - exp.DateSub, - exp.DateTrunc, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._set_type(e, e.args["to"])} - for expr_type in { - exp.Cast, - exp.TryCast, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_map(e)} - for expr_type in { - exp.Map, - exp.VarMap, - } - }, - exp.Array: { - "annotator": lambda self, e: self._annotate_by_args( - e, "expressions", array=True - ) - }, - exp.ArrayAgg: { - "annotator": lambda self, e: self._annotate_by_args(e, "this", array=True) - }, - exp.Bracket: {"annotator": lambda self, e: self._annotate_bracket(e)}, - exp.Case: { - "annotator": lambda self, e: self._annotate_by_args( - e, *[if_expr.args["true"] for if_expr in e.args["ifs"]], "default" - ) - }, - exp.Count: { - "annotator": lambda self, e: self._set_type( - e, - exp.DataType.Type.BIGINT - if e.args.get("big_int") - else exp.DataType.Type.INT, - ) - }, - exp.DateDiff: { - "annotator": lambda self, e: self._set_type( - e, - exp.DataType.Type.BIGINT - if e.args.get("big_int") - else exp.DataType.Type.INT, - ) - }, - exp.DataType: {"annotator": lambda self, e: self._set_type(e, e.copy())}, - exp.Div: {"annotator": lambda self, e: self._annotate_div(e)}, - exp.Distinct: { - "annotator": lambda self, e: self._annotate_by_args(e, "expressions") - }, - exp.Dot: {"annotator": lambda self, e: self._annotate_dot(e)}, - exp.Explode: {"annotator": lambda self, e: self._annotate_explode(e)}, - exp.Extract: {"annotator": lambda self, e: self._annotate_extract(e)}, - exp.GenerateSeries: { - "annotator": lambda self, e: self._annotate_by_args( - e, "start", "end", "step", array=True - ) - }, - exp.GenerateDateArray: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY") - ) - }, - exp.GenerateTimestampArray: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY") - ) - }, - exp.If: {"annotator": lambda self, e: self._annotate_by_args(e, "true", "false")}, - exp.Literal: {"annotator": lambda self, e: self._annotate_literal(e)}, - exp.Null: {"returns": exp.DataType.Type.NULL}, - exp.Nullif: { - "annotator": lambda self, e: self._annotate_by_args(e, "this", "expression") - }, - exp.PropertyEQ: { - "annotator": lambda self, e: self._annotate_by_args(e, "expression") - }, - exp.Struct: {"annotator": lambda self, e: self._annotate_struct(e)}, - exp.Sum: { - "annotator": lambda self, e: self._annotate_by_args( - e, "this", "expressions", promote=True - ) - }, - exp.Timestamp: { - "annotator": lambda self, e: self._set_type( - e, - exp.DataType.Type.TIMESTAMPTZ - if e.args.get("with_tz") - else exp.DataType.Type.TIMESTAMP, - ) - }, - exp.ToMap: {"annotator": lambda self, e: self._annotate_to_map(e)}, - exp.Unnest: {"annotator": lambda self, e: self._annotate_unnest(e)}, - exp.Subquery: {"annotator": lambda self, e: self._annotate_subquery(e)}, -} diff --git a/third_party/bigframes_vendored/sqlglot/typing/bigquery.py b/third_party/bigframes_vendored/sqlglot/typing/bigquery.py deleted file mode 100644 index 37304eef36c..00000000000 --- a/third_party/bigframes_vendored/sqlglot/typing/bigquery.py +++ /dev/null @@ -1,402 +0,0 @@ -# Contains code from https://github.com/tobymao/sqlglot/blob/v28.5.0/sqlglot/typing/bigquery.py - -from __future__ import annotations - -import typing as t - -from bigframes_vendored.sqlglot import exp -from bigframes_vendored.sqlglot.typing import EXPRESSION_METADATA, TIMESTAMP_EXPRESSIONS - -if t.TYPE_CHECKING: - from bigframes_vendored.sqlglot.optimizer.annotate_types import TypeAnnotator - - -def _annotate_math_functions( - self: TypeAnnotator, expression: exp.Expression -) -> exp.Expression: - """ - Many BigQuery math functions such as CEIL, FLOOR etc follow this return type convention: - +---------+---------+---------+------------+---------+ - | INPUT | INT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - +---------+---------+---------+------------+---------+ - | OUTPUT | FLOAT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - +---------+---------+---------+------------+---------+ - """ - this: exp.Expression = expression.this - - self._set_type( - expression, - exp.DataType.Type.DOUBLE - if this.is_type(*exp.DataType.INTEGER_TYPES) - else this.type, - ) - return expression - - -def _annotate_safe_divide( - self: TypeAnnotator, expression: exp.SafeDivide -) -> exp.Expression: - """ - +------------+------------+------------+-------------+---------+ - | INPUT | INT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - +------------+------------+------------+-------------+---------+ - | INT64 | FLOAT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - | NUMERIC | NUMERIC | NUMERIC | BIGNUMERIC | FLOAT64 | - | BIGNUMERIC | BIGNUMERIC | BIGNUMERIC | BIGNUMERIC | FLOAT64 | - | FLOAT64 | FLOAT64 | FLOAT64 | FLOAT64 | FLOAT64 | - +------------+------------+------------+-------------+---------+ - """ - if expression.this.is_type( - *exp.DataType.INTEGER_TYPES - ) and expression.expression.is_type(*exp.DataType.INTEGER_TYPES): - return self._set_type(expression, exp.DataType.Type.DOUBLE) - - return _annotate_by_args_with_coerce(self, expression) - - -def _annotate_by_args_with_coerce( - self: TypeAnnotator, expression: exp.Expression -) -> exp.Expression: - """ - +------------+------------+------------+-------------+---------+ - | INPUT | INT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - +------------+------------+------------+-------------+---------+ - | INT64 | INT64 | NUMERIC | BIGNUMERIC | FLOAT64 | - | NUMERIC | NUMERIC | NUMERIC | BIGNUMERIC | FLOAT64 | - | BIGNUMERIC | BIGNUMERIC | BIGNUMERIC | BIGNUMERIC | FLOAT64 | - | FLOAT64 | FLOAT64 | FLOAT64 | FLOAT64 | FLOAT64 | - +------------+------------+------------+-------------+---------+ - """ - self._set_type( - expression, self._maybe_coerce(expression.this.type, expression.expression.type) - ) - return expression - - -def _annotate_by_args_approx_top( - self: TypeAnnotator, expression: exp.ApproxTopK -) -> exp.ApproxTopK: - struct_type = exp.DataType( - this=exp.DataType.Type.STRUCT, - expressions=[expression.this.type, exp.DataType(this=exp.DataType.Type.BIGINT)], - nested=True, - ) - self._set_type( - expression, - exp.DataType( - this=exp.DataType.Type.ARRAY, expressions=[struct_type], nested=True - ), - ) - - return expression - - -def _annotate_concat(self: TypeAnnotator, expression: exp.Concat) -> exp.Concat: - annotated = self._annotate_by_args(expression, "expressions") - - # Args must be BYTES or types that can be cast to STRING, return type is either BYTES or STRING - # https://cloud.google.com/bigquery/docs/reference/standard-sql/string_functions#concat - if not annotated.is_type(exp.DataType.Type.BINARY, exp.DataType.Type.UNKNOWN): - self._set_type(annotated, exp.DataType.Type.VARCHAR) - - return annotated - - -def _annotate_array(self: TypeAnnotator, expression: exp.Array) -> exp.Array: - array_args = expression.expressions - - # BigQuery behaves as follows: - # - # SELECT t, TYPEOF(t) FROM (SELECT 'foo') AS t -- foo, STRUCT - # SELECT ARRAY(SELECT 'foo'), TYPEOF(ARRAY(SELECT 'foo')) -- foo, ARRAY - # ARRAY(SELECT ... UNION ALL SELECT ...) -- ARRAY - if len(array_args) == 1: - unnested = array_args[0].unnest() - projection_type: t.Optional[exp.DataType | exp.DataType.Type] = None - - # Handle ARRAY(SELECT ...) - single SELECT query - if isinstance(unnested, exp.Select): - if ( - (query_type := unnested.meta.get("query_type")) is not None - and query_type.is_type(exp.DataType.Type.STRUCT) - and len(query_type.expressions) == 1 - and isinstance(col_def := query_type.expressions[0], exp.ColumnDef) - and (col_type := col_def.kind) is not None - and not col_type.is_type(exp.DataType.Type.UNKNOWN) - ): - projection_type = col_type - - # Handle ARRAY(SELECT ... UNION ALL SELECT ...) - set operations - elif isinstance(unnested, exp.SetOperation): - # Get all column types for the SetOperation - col_types = self._get_setop_column_types(unnested) - # For ARRAY constructor, there should only be one projection - # https://docs.cloud.google.com/bigquery/docs/reference/standard-sql/array_functions#array - if col_types and unnested.left.selects: - first_col_name = unnested.left.selects[0].alias_or_name - projection_type = col_types.get(first_col_name) - - # If we successfully determine a projection type and it's not UNKNOWN, wrap it in ARRAY - if projection_type and not ( - ( - isinstance(projection_type, exp.DataType) - and projection_type.is_type(exp.DataType.Type.UNKNOWN) - ) - or projection_type == exp.DataType.Type.UNKNOWN - ): - element_type = ( - projection_type.copy() - if isinstance(projection_type, exp.DataType) - else exp.DataType(this=projection_type) - ) - array_type = exp.DataType( - this=exp.DataType.Type.ARRAY, - expressions=[element_type], - nested=True, - ) - return self._set_type(expression, array_type) - - return self._annotate_by_args(expression, "expressions", array=True) - - -EXPRESSION_METADATA = { - **EXPRESSION_METADATA, - **{ - expr_type: {"annotator": lambda self, e: _annotate_math_functions(self, e)} - for expr_type in { - exp.Avg, - exp.Ceil, - exp.Exp, - exp.Floor, - exp.Ln, - exp.Log, - exp.Round, - exp.Sqrt, - } - }, - **{ - expr_type: {"annotator": lambda self, e: self._annotate_by_args(e, "this")} - for expr_type in { - exp.Abs, - exp.ArgMax, - exp.ArgMin, - exp.DateTrunc, - exp.DatetimeTrunc, - exp.FirstValue, - exp.GroupConcat, - exp.IgnoreNulls, - exp.JSONExtract, - exp.Lead, - exp.Left, - exp.Lower, - exp.NthValue, - exp.Pad, - exp.PercentileDisc, - exp.RegexpExtract, - exp.RegexpReplace, - exp.Repeat, - exp.Replace, - exp.RespectNulls, - exp.Reverse, - exp.Right, - exp.SafeNegate, - exp.Sign, - exp.Substring, - exp.TimestampTrunc, - exp.Translate, - exp.Trim, - exp.Upper, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.BIGINT} - for expr_type in { - exp.Ascii, - exp.BitwiseAndAgg, - exp.BitwiseCount, - exp.BitwiseOrAgg, - exp.BitwiseXorAgg, - exp.ByteLength, - exp.DenseRank, - exp.FarmFingerprint, - exp.Grouping, - exp.LaxInt64, - exp.Length, - exp.Ntile, - exp.Rank, - exp.RangeBucket, - exp.RegexpInstr, - exp.RowNumber, - exp.Unicode, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.BINARY} - for expr_type in { - exp.ByteString, - exp.CodePointsToBytes, - exp.MD5Digest, - exp.SHA, - exp.SHA2, - exp.SHA1Digest, - exp.SHA2Digest, - exp.Unhex, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.BOOLEAN} - for expr_type in { - exp.IsInf, - exp.IsNan, - exp.JSONBool, - exp.LaxBool, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.DATETIME} - for expr_type in { - exp.ParseDatetime, - exp.TimestampFromParts, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.DOUBLE} - for expr_type in { - exp.Acos, - exp.Acosh, - exp.Asin, - exp.Asinh, - exp.Atan, - exp.Atan2, - exp.Atanh, - exp.Cbrt, - exp.Corr, - exp.CosineDistance, - exp.Cot, - exp.Coth, - exp.CovarPop, - exp.CovarSamp, - exp.Csc, - exp.Csch, - exp.CumeDist, - exp.EuclideanDistance, - exp.Float64, - exp.LaxFloat64, - exp.PercentRank, - exp.Rand, - exp.Sec, - exp.Sech, - exp.Sin, - exp.Sinh, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.JSON} - for expr_type in { - exp.JSONArray, - exp.JSONArrayAppend, - exp.JSONArrayInsert, - exp.JSONObject, - exp.JSONRemove, - exp.JSONSet, - exp.JSONStripNulls, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIME} - for expr_type in { - exp.ParseTime, - exp.TimeFromParts, - exp.TimeTrunc, - exp.TsOrDsToTime, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.VARCHAR} - for expr_type in { - exp.CodePointsToString, - exp.Format, - exp.JSONExtractScalar, - exp.JSONType, - exp.LaxString, - exp.LowerHex, - exp.MD5, - exp.NetHost, - exp.Normalize, - exp.SafeConvertBytesToString, - exp.Soundex, - exp.Uuid, - } - }, - **{ - expr_type: {"annotator": lambda self, e: _annotate_by_args_with_coerce(self, e)} - for expr_type in { - exp.PercentileCont, - exp.SafeAdd, - exp.SafeDivide, - exp.SafeMultiply, - exp.SafeSubtract, - } - }, - **{ - expr_type: { - "annotator": lambda self, e: self._annotate_by_args(e, "this", array=True) - } - for expr_type in { - exp.ApproxQuantiles, - exp.JSONExtractArray, - exp.RegexpExtractAll, - exp.Split, - } - }, - **{ - expr_type: {"returns": exp.DataType.Type.TIMESTAMPTZ} - for expr_type in TIMESTAMP_EXPRESSIONS - }, - exp.ApproxTopK: { - "annotator": lambda self, e: _annotate_by_args_approx_top(self, e) - }, - exp.ApproxTopSum: { - "annotator": lambda self, e: _annotate_by_args_approx_top(self, e) - }, - exp.Array: {"annotator": _annotate_array}, - exp.ArrayConcat: { - "annotator": lambda self, e: self._annotate_by_args(e, "this", "expressions") - }, - exp.Concat: {"annotator": _annotate_concat}, - exp.DateFromUnixDate: {"returns": exp.DataType.Type.DATE}, - exp.GenerateTimestampArray: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY", dialect="bigquery") - ) - }, - exp.JSONFormat: { - "annotator": lambda self, e: self._set_type( - e, - exp.DataType.Type.JSON - if e.args.get("to_json") - else exp.DataType.Type.VARCHAR, - ) - }, - exp.JSONKeysAtDepth: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY", dialect="bigquery") - ) - }, - exp.JSONValueArray: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY", dialect="bigquery") - ) - }, - exp.Lag: { - "annotator": lambda self, e: self._annotate_by_args(e, "this", "default") - }, - exp.ParseBignumeric: {"returns": exp.DataType.Type.BIGDECIMAL}, - exp.ParseNumeric: {"returns": exp.DataType.Type.DECIMAL}, - exp.SafeDivide: {"annotator": lambda self, e: _annotate_safe_divide(self, e)}, - exp.ToCodePoints: { - "annotator": lambda self, e: self._set_type( - e, exp.DataType.build("ARRAY", dialect="bigquery") - ) - }, -} diff --git a/third_party/bigframes_vendored/tpch/queries/q1.py b/third_party/bigframes_vendored/tpch/queries/q1.py index aa6289866c5..f533776e858 100644 --- a/third_party/bigframes_vendored/tpch/queries/q1.py +++ b/third_party/bigframes_vendored/tpch/queries/q1.py @@ -1,7 +1,7 @@ # Contains code from https://github.com/pola-rs/tpch/blob/main/queries/pandas/q1.py -import typing from datetime import datetime +import typing import bigframes import bigframes.pandas as bpd diff --git a/third_party/bigframes_vendored/tpch/queries/q10.py b/third_party/bigframes_vendored/tpch/queries/q10.py index 19a33b07ec2..8c0d93dc261 100644 --- a/third_party/bigframes_vendored/tpch/queries/q10.py +++ b/third_party/bigframes_vendored/tpch/queries/q10.py @@ -1,7 +1,7 @@ # Contains code from https://github.com/pola-rs/tpch/blob/main/queries/polars/q10.py -import typing from datetime import date +import typing import bigframes import bigframes.pandas as bpd diff --git a/third_party/bigframes_vendored/tpch/queries/q12.py b/third_party/bigframes_vendored/tpch/queries/q12.py index 20097f72ca1..1bc22f1167e 100644 --- a/third_party/bigframes_vendored/tpch/queries/q12.py +++ b/third_party/bigframes_vendored/tpch/queries/q12.py @@ -1,7 +1,7 @@ # Contains code from https://github.com/pola-rs/tpch/blob/main/queries/polars/q12.py -import typing from datetime import date +import typing import bigframes import bigframes.pandas as bpd diff --git a/third_party/bigframes_vendored/tpch/queries/q19.py b/third_party/bigframes_vendored/tpch/queries/q19.py index a217db3dc32..1371af53fc0 100644 --- a/third_party/bigframes_vendored/tpch/queries/q19.py +++ b/third_party/bigframes_vendored/tpch/queries/q19.py @@ -53,11 +53,5 @@ def q(project_id: str, dataset_id: str, session: bigframes.Session): ) ] - result_df = ( - (filtered["L_EXTENDEDPRICE"] * (1 - filtered["L_DISCOUNT"])) - .agg(["sum"]) - .rename("REVENUE") - .to_frame() - ) - - next(result_df.to_pandas_batches(max_results=1500)) + revenue = (filtered["L_EXTENDEDPRICE"] * (1 - filtered["L_DISCOUNT"])).sum() + _ = round(revenue, 2) diff --git a/third_party/bigframes_vendored/tpch/queries/q4.py b/third_party/bigframes_vendored/tpch/queries/q4.py index 9c855704ab9..3782a7273fc 100644 --- a/third_party/bigframes_vendored/tpch/queries/q4.py +++ b/third_party/bigframes_vendored/tpch/queries/q4.py @@ -1,8 +1,8 @@ # Contains code from https://github.com/pola-rs/tpch/blob/main/queries/pandas/q4.py -import typing from datetime import date +import typing import bigframes import bigframes.pandas as bpd diff --git a/third_party/bigframes_vendored/tpch/queries/q7.py b/third_party/bigframes_vendored/tpch/queries/q7.py index 0756bcc6566..81cdda87886 100644 --- a/third_party/bigframes_vendored/tpch/queries/q7.py +++ b/third_party/bigframes_vendored/tpch/queries/q7.py @@ -1,7 +1,7 @@ # Contains code from https://github.com/pola-rs/tpch/blob/main/queries/pandas/q7.py -import typing from datetime import date +import typing import bigframes import bigframes.dataframe diff --git a/third_party/bigframes_vendored/version.py b/third_party/bigframes_vendored/version.py index 3eecebee5a1..b9aa5d1855f 100644 --- a/third_party/bigframes_vendored/version.py +++ b/third_party/bigframes_vendored/version.py @@ -12,8 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.48.0" +__version__ = "2.17.0" # {x-release-please-start-date} -__release_date__ = "2026-06-12" +__release_date__ = "2025-08-22" # {x-release-please-end} diff --git a/third_party/sphinx/LICENSE.rst b/third_party/sphinx/LICENSE.rst deleted file mode 100644 index de3688cd2c6..00000000000 --- a/third_party/sphinx/LICENSE.rst +++ /dev/null @@ -1,31 +0,0 @@ -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 deleted file mode 100644 index 6651591be64..00000000000 --- a/third_party/sphinx/ext/autosummary/templates/autosummary/class.rst +++ /dev/null @@ -1,42 +0,0 @@ -{{ fullname | escape | underline}} - -.. currentmodule:: {{ module }} - -{% set is_pandas = module.startswith("bigframes.pandas") or module.startswith("bigframes.geopandas") %} -{% set skip_inherited = is_pandas and not module.startswith("bigframes.pandas.typing.api") %} - -{% if is_pandas %} -.. autoclass:: {{ objname }} - :no-members: - - {% block attributes %} - {% if attributes %} - .. rubric:: {{ _('Attributes') }} - - .. autosummary:: - :toctree: - {% for item in attributes %} - {%- if not skip_inherited or not item in inherited_members%} - ~{{ name }}.{{ item }} - {%- endif %} - {%- endfor %} - {% endif %} - {% endblock %} - - {% block methods %} - {% if methods %} - .. rubric:: {{ _('Methods') }} - - .. autosummary:: - :toctree: - - {% for item in methods %} - {%- if not skip_inherited or not item in inherited_members%} - ~{{ name }}.{{ item }} - {%- endif %} - {%- endfor %} - {% endif %} - {% endblock %} -{% else %} -.. autoclass:: {{ objname }} -{% endif %} diff --git a/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst b/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst deleted file mode 100644 index 98d86d15230..00000000000 --- a/third_party/sphinx/ext/autosummary/templates/autosummary/module.rst +++ /dev/null @@ -1,57 +0,0 @@ -{{ 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 %}